diff --git a/.gitignore b/.gitignore index 21431b8..6bbef20 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CODING_RULES.md b/CODING_RULES.md index 0cb4984..2ace743 100644 --- a/CODING_RULES.md +++ b/CODING_RULES.md @@ -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/`(如 `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)。 diff --git a/debug-admin-auth-misjudge.md b/debug-admin-auth-misjudge.md deleted file mode 100644 index 0dc4b0a..0000000 --- a/debug-admin-auth-misjudge.md +++ /dev/null @@ -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`、路由守卫还是后端鉴权分叉。 -- 基于证据做最小修复,再做前后对比验证。 diff --git a/debug-knowledge-chat-401.md b/debug-knowledge-chat-401.md deleted file mode 100644 index b32f20e..0000000 --- a/debug-knowledge-chat-401.md +++ /dev/null @@ -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) diff --git a/docs/00_AI_Context/README.md b/docs/00_AI_Context/README.md index 63c6bbd..2e82541 100644 --- a/docs/00_AI_Context/README.md +++ b/docs/00_AI_Context/README.md @@ -8,9 +8,11 @@ | 文件 | 说明 | |------|------| | `README.md` | 本索引文件 | -| `forai01_engineering_progress.md` | 工程进度摘要 | -| `forai02_2026-08-16_导航品牌与系统配置调整交接.md` | 本轮前端导航、品牌与系统配置拆分交接 | ## 使用规范 - `foraiNN_*.md` — 按需创建的特定上下文 / 决策记录 + +## 当前状态 + +早期 AI 交接稿已清理;如果后续确实需要新的 AI 协作上下文,再按当前架构重新创建。 diff --git a/docs/00_AI_Context/forai01_engineering_progress.md b/docs/00_AI_Context/forai01_engineering_progress.md deleted file mode 100644 index b313f5c..0000000 --- a/docs/00_AI_Context/forai01_engineering_progress.md +++ /dev/null @@ -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 个场景陪练模板。 -- 一级导航逐步演进为:知识库 / 智能体 / 学习与考试 / 陪练与认证 / 内容运营 / 组织与系统。 - -## 当前问题 - -- 无阻塞项。文档层已消除定位与技术栈的较大冲突。 diff --git a/docs/00_AI_Context/forai02_2026-08-16_导航品牌与系统配置调整交接.md b/docs/00_AI_Context/forai02_2026-08-16_导航品牌与系统配置调整交接.md deleted file mode 100644 index 1d387d6..0000000 --- a/docs/00_AI_Context/forai02_2026-08-16_导航品牌与系统配置调整交接.md +++ /dev/null @@ -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. 请基于当前状态继续,不要回退已有改动 diff --git a/docs/01_System_Overall/SY01_System_Overview.md b/docs/01_System_Overall/SY01_System_Overview.md index b67db51..d912173 100644 --- a/docs/01_System_Overall/SY01_System_Overview.md +++ b/docs/01_System_Overall/SY01_System_Overview.md @@ -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)→ 仍保持本地化 diff --git a/docs/01_System_Overall/SY02_Design_Principles.md b/docs/01_System_Overall/SY02_Design_Principles.md index a026c74..889941d 100644 --- a/docs/01_System_Overall/SY02_Design_Principles.md +++ b/docs/01_System_Overall/SY02_Design_Principles.md @@ -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:审批前置 diff --git a/docs/01_System_Overall/SY03_Knowledge_Centric_Agent_Platform_Strategy.md b/docs/01_System_Overall/SY03_Knowledge_Centric_Agent_Platform_Strategy.md index bcdd9bd..a56fde9 100644 --- a/docs/01_System_Overall/SY03_Knowledge_Centric_Agent_Platform_Strategy.md +++ b/docs/01_System_Overall/SY03_Knowledge_Centric_Agent_Platform_Strategy.md @@ -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. 对当前项目的最终判断 diff --git a/docs/01_System_Overall/SY17_Workbench_UI_Wireframes.md b/docs/01_System_Overall/SY17_Workbench_UI_Wireframes.md index 0d7c1b2..193e656 100644 --- a/docs/01_System_Overall/SY17_Workbench_UI_Wireframes.md +++ b/docs/01_System_Overall/SY17_Workbench_UI_Wireframes.md @@ -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. 文档目的 diff --git a/docs/01_System_Overall/SY18_DWP_DW_ADW_Formal_Design_Contract.md b/docs/01_System_Overall/SY18_DWP_DW_ADW_Formal_Design_Contract.md index 53a2ab8..85b748b 100644 --- a/docs/01_System_Overall/SY18_DWP_DW_ADW_Formal_Design_Contract.md +++ b/docs/01_System_Overall/SY18_DWP_DW_ADW_Formal_Design_Contract.md @@ -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` 模型可以看作: diff --git a/docs/02_Architecture/AR01_Backend_Arch.md b/docs/02_Architecture/AR01_Backend_Arch.md index 18240e1..99ce29c 100644 --- a/docs/02_Architecture/AR01_Backend_Arch.md +++ b/docs/02_Architecture/AR01_Backend_Arch.md @@ -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. 单二进制交付 -文档转换管线(审批通过后异步执行): +```bash +CGO_ENABLED=0 go build -o bin/eai_agentplatform-server ./cmd/server +# 输出:statically linked,无运行时依赖 +``` -```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() -``` \ No newline at end of file +部署:`systemd` 直接拉起单二进制,`data/` 目录下 SQLite 数据库自动建表。 diff --git a/docs/02_Architecture/AR02_Frontend_Arch.md b/docs/02_Architecture/AR02_Frontend_Arch.md index 373b458..403501a 100644 --- a/docs/02_Architecture/AR02_Frontend_Arch.md +++ b/docs/02_Architecture/AR02_Frontend_Arch.md @@ -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 模型路由。 --- @@ -172,4 +179,4 @@ export const useAiChatStore = defineStore('aiChat', { clearMessages() { this.messages = [] }, } }) -``` \ No newline at end of file +``` diff --git a/docs/02_Architecture/AR03_Database_Arch.md b/docs/02_Architecture/AR03_Database_Arch.md index 4e7a258..9d0de71 100644 --- a/docs/02_Architecture/AR03_Database_Arch.md +++ b/docs/02_Architecture/AR03_Database_Arch.md @@ -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/ diff --git a/docs/02_Architecture/AR04_Deploy_Arch.md b/docs/02_Architecture/AR04_Deploy_Arch.md index c8e51b5..a505493 100644 --- a/docs/02_Architecture/AR04_Deploy_Arch.md +++ b/docs/02_Architecture/AR04_Deploy_Arch.md @@ -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 命名 + 只读预览 | -| 数据库 | 独立用户 + 最小权限 | -| LLM | 仅内网地址,严禁公网 API | \ No newline at end of file +| 文件 | 白名单扩展名 + UUID 命名 + proxy_pass 内部预览 | +| 数据库 | SQLite 单文件(系统级文件权限,systemd ProtectSystem=strict) | +| 备份 | 内建定期 VACUUM INTO,默认 24h 间隔,保留 7 份 | +| LLM | 仅内网地址,严禁公网 API | diff --git a/docs/04_Backend/BE02_Exam_Module.md b/docs/04_Backend/BE02_Exam_Module.md index 942f7d2..eedc54a 100644 --- a/docs/04_Backend/BE02_Exam_Module.md +++ b/docs/04_Backend/BE02_Exam_Module.md @@ -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": [ { @@ -133,4 +127,4 @@ def grade_paper(questions: list, answers: dict) -> dict: ## 9. 权限 - **员工:** 仅查看自己的考试记录 -- **管理员:** 查看全部考试记录(`/api/system/exam-records`) \ No newline at end of file +- **管理员:** 查看全部考试记录(`/api/system/exam-records`) diff --git a/docs/04_Backend/BE03_Media_Module.md b/docs/04_Backend/BE03_Media_Module.md index 2a40b2a..e189e84 100644 --- a/docs/04_Backend/BE03_Media_Module.md +++ b/docs/04_Backend/BE03_Media_Module.md @@ -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": -} +// 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) - - # 方式2:Docker 容器 HTTP 接口 - # response = requests.post( - # f"{settings.libreoffice_url}/convert", - # files={"file": open(input_path, "rb")} - # ) - # return response.json()["pdf_path"] + uid := middleware.GetUserID(c) + err := mediaSvc.Audit(c, uint(mediaID), uid, req.Action, req.RejectReason) + if err != nil { c.JSON(500, web.FAIL); return } + + 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 类型校验 + 扩展名双重校验 \ No newline at end of file +- MIME 类型校验 + 扩展名双重校验 +- SQLite 单文件,数据目录 `/opt/eai_agentplatform/data/media/` \ No newline at end of file diff --git a/docs/04_Backend/BE04_AI_Chat_Module.md b/docs/04_Backend/BE04_AI_Chat_Module.md index 89c7953..11eebe7 100644 --- a/docs/04_Backend/BE04_AI_Chat_Module.md +++ b/docs/04_Backend/BE04_AI_Chat_Module.md @@ -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 状态 | 响应 | @@ -347,4 +310,4 @@ def trigger_quick_action( | LLM 未配置(缺 base_url/key/model) | 501 | `{"error": "llm_not_configured", "message": "请管理员在系统参数配置中补充..."}` | | LLM 调用超时/网络错误 | 502 | `{"error": "llm_request_failed", "message": "LLM 服务不可达,请检查网络连接"}` | | LLM 返回空正文 | 502 | `{"error": "llm_empty_response", "message": "LLM 返回空结果"}` | -| LLM 响应格式异常 | 502 | `{"error": "llm_response_error", "message": "LLM 响应异常"}` | \ No newline at end of file +| LLM 响应格式异常 | 502 | `{"error": "llm_response_error", "message": "LLM 响应异常"}` | diff --git a/docs/04_Backend/BE05_Knowledge_Ingest_Module.md b/docs/04_Backend/BE05_Knowledge_Ingest_Module.md index a04f44f..969817a 100644 --- a/docs/04_Backend/BE05_Knowledge_Ingest_Module.md +++ b/docs/04_Backend/BE05_Knowledge_Ingest_Module.md @@ -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 # 摄入单个已审批源 -""" +`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) diff --git a/docs/04_Backend/BE06_AI_Config_Credits_Module.md b/docs/04_Backend/BE06_AI_Config_Credits_Module.md index d673d81..4e5843e 100644 --- a/docs/04_Backend/BE06_AI_Config_Credits_Module.md +++ b/docs/04_Backend/BE06_AI_Config_Credits_Module.md @@ -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 | diff --git a/docs/04_Backend/README.md b/docs/04_Backend/README.md index e65ec84..e53c297 100644 --- a/docs/04_Backend/README.md +++ b/docs/04_Backend/README.md @@ -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 配置与算力点计费模块 | diff --git a/docs/06_Product_Lines/PL01_Global_Layout.md b/docs/06_Product_Lines/PL01_Global_Layout.md index fdd2d3e..3ddf44e 100644 --- a/docs/06_Product_Lines/PL01_Global_Layout.md +++ b/docs/06_Product_Lines/PL01_Global_Layout.md @@ -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. 布局结构 @@ -376,4 +383,4 @@ App.vue - 左边导航固定 **220px**,不响应 - AI 面板固定 **360px**,可收起(收起后主内容区占满) - 主内容区自适应剩余宽度,最小宽度 800px -- 登录页独立全屏,不包含此布局 \ No newline at end of file +- 登录页独立全屏,不包含此布局 diff --git a/docs/06_Product_Lines/PL02_Employee_Views.md b/docs/06_Product_Lines/PL02_Employee_Views.md index f0e631d..13978f5 100644 --- a/docs/06_Product_Lines/PL02_Employee_Views.md +++ b/docs/06_Product_Lines/PL02_Employee_Views.md @@ -1,6 +1,11 @@ # PL02 — 员工端视图设计 > **版本:V1.1 | 框架:Vue3 + Element Plus** +> +> **2026-09-16 口径同步说明:** +> 本文档是 V1 员工端页面稿,保留培训平台时期的页面定义。 +> 当前正式一级导航与对象化工作台口径,请以 `SY22_Role_Skill_App_Unified_Task_Architecture.md` 为准。 +> 文中“首页 / 公司介绍培训 / 产品知识 / 销售培训 / 考试”等名称,均属于历史页面模型,不代表当前导航目标态。 --- @@ -263,4 +268,4 @@ │ │识考试│ │ 通过│08-05│ 详情] ││ │ └─────┴──────┴────┴────┴────┴────────┘│ └─────────────────────────────────────────┘ -``` \ No newline at end of file +``` diff --git a/docs/06_Product_Lines/PL03_Admin_Views.md b/docs/06_Product_Lines/PL03_Admin_Views.md index 92c878d..f98aa9f 100644 --- a/docs/06_Product_Lines/PL03_Admin_Views.md +++ b/docs/06_Product_Lines/PL03_Admin_Views.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` 为准。 +> 文中“知识管理 / 系统管理 / 全部考试成绩”等名称,均属于历史页面模型,不代表当前导航目标态。 --- @@ -263,4 +268,4 @@ - 新增/编辑:el-dialog + el-form - 删除确认:el-message-box.confirm - 状态变更:el-switch 或 el-select -``` \ No newline at end of file +``` diff --git a/docs/08_Design_Rules/DR02_Navigation_Rules.md b/docs/08_Design_Rules/DR02_Navigation_Rules.md index 297e542..9d466a7 100644 --- a/docs/08_Design_Rules/DR02_Navigation_Rules.md +++ b/docs/08_Design_Rules/DR02_Navigation_Rules.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 历史结构,而不是当前目标导航。 --- @@ -143,4 +148,4 @@ export default router > > 即使员工通过直接输入 URL 访问 `/knowledge/materials`: > 1. 前端路由守卫会拦截并重定向 → UX 保护 -> 2. 如果前端拦截被绕过,后端 API 返回 403 → 真正安全边界 \ No newline at end of file +> 2. 如果前端拦截被绕过,后端 API 返回 403 → 真正安全边界 diff --git a/docs/08_Design_Rules/DR04_ChatFirst_Redesign.md b/docs/08_Design_Rules/DR04_ChatFirst_Redesign.md index 20a3d0b..d368887 100644 --- a/docs/08_Design_Rules/DR04_ChatFirst_Redesign.md +++ b/docs/08_Design_Rules/DR04_ChatFirst_Redesign.md @@ -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)**。 核查其后端支撑: diff --git a/docs/08_Design_Rules/README.md b/docs/08_Design_Rules/README.md index d64a442..fcb1cae 100644 --- a/docs/08_Design_Rules/README.md +++ b/docs/08_Design_Rules/README.md @@ -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 阶段方案,用于记录历史约束与演进过程;阅读时需以上述当前口径为准。 ## 文件清单 diff --git a/docs/10-eaiintro/博昇慧源AI研究院 · 技术能力概览 · V1.0.md b/docs/10-eaiintro/博昇慧源AI研究院 · 技术能力概览 · V1.0.md index 715171f..3641881 100644 --- a/docs/10-eaiintro/博昇慧源AI研究院 · 技术能力概览 · V1.0.md +++ b/docs/10-eaiintro/博昇慧源AI研究院 · 技术能力概览 · V1.0.md @@ -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 | diff --git a/docs/10-eaiintro/博昇慧源AI研究院(EAI)· 完整版介绍.md b/docs/10-eaiintro/博昇慧源AI研究院(EAI)· 完整版介绍.md index 9631f54..f3b62ac 100644 --- a/docs/10-eaiintro/博昇慧源AI研究院(EAI)· 完整版介绍.md +++ b/docs/10-eaiintro/博昇慧源AI研究院(EAI)· 完整版介绍.md @@ -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 六大场景架构 diff --git a/docs/PRD.md b/docs/PRD.md index c58aa57..258a2c0 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -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 ``` -完全无外网、无云服务。 \ No newline at end of file +完全无外网、无云服务、无 Docker、无 MySQL、无 FAISS。 diff --git a/docs/api.md b/docs/api.md index e9cfd80..7570a5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 ` ### 响应格式 + ```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" } -``` \ No newline at end of file +``` diff --git a/docs/changelog.md b/docs/changelog.md index d3d2dcc..6f7378d 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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,15 +151,8 @@ - 核心数据表字段设计(8 张核心表 + system_config) - 部署架构补充 LLM 服务与 Nginx 反代 -### 变更 -- V1.0 → V1.1 整体修订 - -### 修复 -- 消除「无外网依赖」与「配置 LLM 地址」的矛盾 -- 消除「不做转码」与「视频进 AI」的矛盾 - --- ## V1.0(初始版) -初始产品需求文档。 \ No newline at end of file +初始产品需求文档。 diff --git a/docs/db_schema.md b/docs/db_schema.md index 11a80d1..9c55a43 100644 --- a/docs/db_schema.md +++ b/docs/db_schema.md @@ -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,8 +325,8 @@ 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` | 站内消息通知(事件钩子驱动)| -**列级变更:** `user.learning_points`(V1.6)、`user.position_id`(V1.4)、`user.department`(V1.7)、`exam_paper.position_id`(V1.4)、`question.type` 增加 `essay`(V1.5)。 \ No newline at end of file +**列级变更:** `user.learning_points`(V1.6)、`user.position_id`(V1.4)、`user.department`(V1.7)、`exam_paper.position_id`(V1.4)、`question.type` 增加 `essay`(V1.5)。 diff --git a/docs/deploy.md b/docs/deploy.md index c093886..d155edf 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 diff --git a/docs/eaisalestrain_app设计稿.md b/docs/eaisalestrain_app设计稿.md index 45d03a4..78195aa 100644 --- a/docs/eaisalestrain_app设计稿.md +++ b/docs/eaisalestrain_app设计稿.md @@ -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 完全无外网、无云服务 diff --git a/docs/完整分析_pj0231_vs_pj006-zhilianyuan2.md b/docs/完整分析_pj0231_vs_pj006-zhilianyuan2.md index 3643537..bec13fa 100644 --- a/docs/完整分析_pj0231_vs_pj006-zhilianyuan2.md +++ b/docs/完整分析_pj0231_vs_pj006-zhilianyuan2.md @@ -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` 与**实际代码**为准。 --- diff --git a/docs/对比分析_pj0231_vs_pj006-zhilianyuan2.md b/docs/对比分析_pj0231_vs_pj006-zhilianyuan2.md index 134015e..5d39eca 100644 --- a/docs/对比分析_pj0231_vs_pj006-zhilianyuan2.md +++ b/docs/对比分析_pj0231_vs_pj006-zhilianyuan2.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` 多租户根) diff --git a/docs/微趣MyGPT调研与办公智能体架构对比.md b/docs/微趣MyGPT调研与办公智能体架构对比.md index 3714698..4c30454 100644 --- a/docs/微趣MyGPT调研与办公智能体架构对比.md +++ b/docs/微趣MyGPT调研与办公智能体架构对比.md @@ -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 - **已落地模块**: - 知识库全套(上传→审批→摄入→混合检索→导出) diff --git a/docs/设计稿_岗位与知识对应能力.md b/docs/设计稿_岗位与知识对应能力.md index aa56a95..784d9c8 100644 --- a/docs/设计稿_岗位与知识对应能力.md +++ b/docs/设计稿_岗位与知识对应能力.md @@ -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 抽题 diff --git a/eai_agentplatform/.gitignore b/eai_agentplatform/.gitignore index c86d82c..3671582 100644 --- a/eai_agentplatform/.gitignore +++ b/eai_agentplatform/.gitignore @@ -8,10 +8,6 @@ backend-go/data/ frontend/node_modules/ frontend/dist/ -# 本地知识服务 -knowledge_service/data/ -knowledge_service/__pycache__/ - # 环境变量 .env backend-go/.env diff --git a/eai_agentplatform/CLAUDE.md b/eai_agentplatform/CLAUDE.md index 812e37b..5e2a2ef 100644 --- a/eai_agentplatform/CLAUDE.md +++ b/eai_agentplatform/CLAUDE.md @@ -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 向量库 diff --git a/eai_agentplatform/PROJECT_STATE.md b/eai_agentplatform/PROJECT_STATE.md index d87d3b8..6a58812 100644 --- a/eai_agentplatform/PROJECT_STATE.md +++ b/eai_agentplatform/PROJECT_STATE.md @@ -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(运行时数据) diff --git a/eai_agentplatform/backend-go/cmd/inspect_oa_debug/main.go b/eai_agentplatform/backend-go/cmd/inspect_oa_debug/main.go deleted file mode 100644 index b49e4ff..0000000 --- a/eai_agentplatform/backend-go/cmd/inspect_oa_debug/main.go +++ /dev/null @@ -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() -} diff --git a/eai_agentplatform/backend-go/cmd/server/main.go b/eai_agentplatform/backend-go/cmd/server/main.go index 598595b..f56986a 100644 --- a/eai_agentplatform/backend-go/cmd/server/main.go +++ b/eai_agentplatform/backend-go/cmd/server/main.go @@ -36,6 +36,8 @@ func main() { log.Fatalf("种子数据初始化失败: %v", err) } + config.StartAIRouteHealthLoop(30 * time.Minute) + // 定期备份:SQLite 是单文件且无外部副本,坏一份就是全丢。 // 失败只在日志里报,绝不因此拦下服务。 if cfg.BackupEnabled { diff --git a/eai_agentplatform/backend-go/config/ai_config.json b/eai_agentplatform/backend-go/config/ai_config.json index 5cd5d0b..fa2ca0d 100644 --- a/eai_agentplatform/backend-go/config/ai_config.json +++ b/eai_agentplatform/backend-go/config/ai_config.json @@ -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"} diff --git a/eai_agentplatform/backend-go/internal/ai/knowledge_service_client.go b/eai_agentplatform/backend-go/internal/ai/knowledge_service_client.go index db2cf7d..c010668 100644 --- a/eai_agentplatform/backend-go/internal/ai/knowledge_service_client.go +++ b/eai_agentplatform/backend-go/internal/ai/knowledge_service_client.go @@ -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 -} diff --git a/eai_agentplatform/backend-go/internal/ai/retrieve.go b/eai_agentplatform/backend-go/internal/ai/retrieve.go index 5a2998e..8cf4c67 100644 --- a/eai_agentplatform/backend-go/internal/ai/retrieve.go +++ b/eai_agentplatform/backend-go/internal/ai/retrieve.go @@ -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 { diff --git a/eai_agentplatform/backend-go/internal/api/ai_admin.go b/eai_agentplatform/backend-go/internal/api/ai_admin.go index d47885c..9e82d51 100644 --- a/eai_agentplatform/backend-go/internal/api/ai_admin.go +++ b/eai_agentplatform/backend-go/internal/api/ai_admin.go @@ -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}) } diff --git a/eai_agentplatform/backend-go/internal/api/ai_chat.go b/eai_agentplatform/backend-go/internal/api/ai_chat.go index 172f1ac..955701c 100644 --- a/eai_agentplatform/backend-go/internal/api/ai_chat.go +++ b/eai_agentplatform/backend-go/internal/api/ai_chat.go @@ -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()), }) diff --git a/eai_agentplatform/backend-go/internal/api/audio_transcribe.go b/eai_agentplatform/backend-go/internal/api/audio_transcribe.go index facb4df..9566a69 100644 --- a/eai_agentplatform/backend-go/internal/api/audio_transcribe.go +++ b/eai_agentplatform/backend-go/internal/api/audio_transcribe.go @@ -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") } diff --git a/eai_agentplatform/backend-go/internal/api/capability_definition.go b/eai_agentplatform/backend-go/internal/api/capability_definition.go new file mode 100644 index 0000000..e3221aa --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/capability_definition.go @@ -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}) +} diff --git a/eai_agentplatform/backend-go/internal/api/company_train.go b/eai_agentplatform/backend-go/internal/api/company_train.go index 508392b..92f5ca3 100644 --- a/eai_agentplatform/backend-go/internal/api/company_train.go +++ b/eai_agentplatform/backend-go/internal/api/company_train.go @@ -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 } diff --git a/eai_agentplatform/backend-go/internal/api/copy_proofreading.go b/eai_agentplatform/backend-go/internal/api/copy_proofreading.go index 9c70ac2..f0ff0f8 100644 --- a/eai_agentplatform/backend-go/internal/api/copy_proofreading.go +++ b/eai_agentplatform/backend-go/internal/api/copy_proofreading.go @@ -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" } diff --git a/eai_agentplatform/backend-go/internal/api/document_translate.go b/eai_agentplatform/backend-go/internal/api/document_translate.go index 170f9d9..6d40668 100644 --- a/eai_agentplatform/backend-go/internal/api/document_translate.go +++ b/eai_agentplatform/backend-go/internal/api/document_translate.go @@ -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 简易回退翻译 diff --git a/eai_agentplatform/backend-go/internal/api/essay_grade.go b/eai_agentplatform/backend-go/internal/api/essay_grade.go index b24bef4..a36018a 100644 --- a/eai_agentplatform/backend-go/internal/api/essay_grade.go +++ b/eai_agentplatform/backend-go/internal/api/essay_grade.go @@ -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()), }) diff --git a/eai_agentplatform/backend-go/internal/api/knowledge_index.go b/eai_agentplatform/backend-go/internal/api/knowledge_index.go index 77af5ba..d0801bd 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge_index.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge_index.go @@ -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 } diff --git a/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go b/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go index 7a60cab..aa4efe6 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go @@ -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 { diff --git a/eai_agentplatform/backend-go/internal/api/media.go b/eai_agentplatform/backend-go/internal/api/media.go index 91ac986..097faa4 100644 --- a/eai_agentplatform/backend-go/internal/api/media.go +++ b/eai_agentplatform/backend-go/internal/api/media.go @@ -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 } diff --git a/eai_agentplatform/backend-go/internal/api/media_test.go b/eai_agentplatform/backend-go/internal/api/media_test.go new file mode 100644 index 0000000..ddae72c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/media_test.go @@ -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) + } + } +} diff --git a/eai_agentplatform/backend-go/internal/api/my_app_center.go b/eai_agentplatform/backend-go/internal/api/my_app_center.go new file mode 100644 index 0000000..cc24720 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/my_app_center.go @@ -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) +} diff --git a/eai_agentplatform/backend-go/internal/api/my_task.go b/eai_agentplatform/backend-go/internal/api/my_task.go index 786b38e..35f96c1 100644 --- a/eai_agentplatform/backend-go/internal/api/my_task.go +++ b/eai_agentplatform/backend-go/internal/api/my_task.go @@ -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 } diff --git a/eai_agentplatform/backend-go/internal/api/office_skill.go b/eai_agentplatform/backend-go/internal/api/office_skill.go new file mode 100644 index 0000000..018ef34 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/office_skill.go @@ -0,0 +1,2240 @@ +package api + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "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" +) + +type officeSkillExecuteReq struct { + TaskID uint `json:"task_id"` + SkillKey string `json:"skill_key"` + InputText string `json:"input_text"` + Mode string `json:"mode"` +} + +type paddleOCRPayload struct { + Text string `json:"text"` + Lines []map[string]any `json:"lines"` + Engine string `json:"engine"` + Error string `json:"error"` + Raw map[string]any `json:"-"` +} + +var officeLineTrimPattern = regexp.MustCompile("^[#>*`\\-\\d.\\s]+") + +func ExecuteOfficeSkill(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + + var req officeSkillExecuteReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + req.SkillKey = strings.TrimSpace(req.SkillKey) + req.InputText = strings.TrimSpace(req.InputText) + if req.TaskID == 0 || req.SkillKey == "" || req.InputText == "" { + web.Fail(c, web.NewBadRequest("task_id、skill_key、input_text 为必填")) + return + } + + task, ok := loadMyOwnedTask(c, user, req.TaskID) + if !ok { + return + } + + definition, ok := officeSkillDefinitions[req.SkillKey] + if !ok { + web.Fail(c, web.NewNotFoundError("技能不存在")) + return + } + + result := buildOfficeSkillResultMap(definition, req.InputText) + run, createdArtifacts, err := persistOfficeExecution(task, definition, req.InputText, result) + if err != nil { + web.Fail(c, web.NewBadRequest(err.Error())) + return + } + + freshTask, _ := reloadWorkerTask(task.ID) + web.OK(c, gin.H{ + "result": result, + "task": freshTask, + "run": run, + "artifacts": createdArtifacts, + }) +} + +func ExecuteOfficeSkillOCR(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + + taskID, ok := parseUintFromForm(c.PostForm("task_id")) + if !ok || taskID == 0 { + web.Fail(c, web.NewBadRequest("task_id 必填")) + return + } + prompt := strings.TrimSpace(c.PostForm("prompt")) + + task, taskOK := loadMyOwnedTask(c, user, taskID) + if !taskOK { + return + } + + file, err := c.FormFile("file") + if err != nil { + web.Fail(c, web.NewBadRequest("请上传图片文件")) + return + } + if !looksLikeImage(file.Filename) { + web.Fail(c, web.NewBadRequest("目前仅支持上传图片进行 OCR 识别")) + return + } + + src, err := file.Open() + if err != nil { + web.Fail(c, web.NewBadRequest("读取上传文件失败")) + return + } + defer src.Close() + + tempFile, err := os.CreateTemp("", "office-ocr-*"+strings.ToLower(filepath.Ext(file.Filename))) + if err != nil { + web.Fail(c, web.NewBadRequest("创建临时文件失败")) + return + } + defer os.Remove(tempFile.Name()) + defer tempFile.Close() + + if _, err := io.Copy(tempFile, src); err != nil { + web.Fail(c, web.NewBadRequest("保存上传文件失败")) + return + } + + ocrPayload, err := runPaddleOCR(tempFile.Name()) + if err != nil { + web.Fail(c, web.NewBadRequest(err.Error())) + return + } + if strings.TrimSpace(ocrPayload.Text) == "" { + web.Fail(c, web.NewBadRequest("没有识别到可用文字,请换一张更清晰的图片")) + return + } + + uploadItem := gin.H{ + "title": file.Filename, + "detail": "这张图片已作为 OCR 输入材料保留在任务里。", + "file_format": strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Filename)), "."), + "file_size": file.Size, + } + artifactPayload := gin.H{ + "engine": firstNonEmpty(ocrPayload.Engine, "paddleocr"), + "prompt": prompt, + "text": ocrPayload.Text, + "lines": ocrPayload.Lines, + "upload_item": uploadItem, + } + artifactPayloadJSON, _ := json.Marshal(artifactPayload) + sourceRefsJSON, _ := json.Marshal([]gin.H{ + { + "type": "upload", + "title": file.Filename, + "size": file.Size, + }, + }) + now := time.Now() + inputJSON, _ := json.Marshal(gin.H{ + "prompt": prompt, + "file_name": file.Filename, + "file_size": file.Size, + }) + outputJSON, _ := json.Marshal(gin.H{ + "summary": fmt.Sprintf("已识别图片《%s》中的文本,并整理为可复制的 OCR 结果。", file.Filename), + "text": ocrPayload.Text, + "lines": ocrPayload.Lines, + }) + logsJSON, _ := json.Marshal([]string{ + fmt.Sprintf("接收上传文件:%s", file.Filename), + fmt.Sprintf("OCR 引擎:%s", firstNonEmpty(ocrPayload.Engine, "paddleocr")), + "已生成 OCR 识别结果", + }) + + var run model.WorkerRun + var artifact model.WorkerArtifact + err = store.DB.Transaction(func(tx *gorm.DB) error { + run = model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: "ocr-understanding", + ActionTitle: "OCR / 图文理解", + ActionType: "ocr", + Status: "done", + InputJSON: string(inputJSON), + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := tx.Create(&run).Error; err != nil { + return err + } + + artifact = model.WorkerArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: fmt.Sprintf("%s - OCR 识别结果", task.Title), + ArtifactType: "ocr_text", + Status: workerArtifactStatusDraft, + ContentText: ocrPayload.Text, + ContentJSON: string(artifactPayloadJSON), + SourceRefsJSON: string(sourceRefsJSON), + CreatedByRunID: &run.ID, + } + if err := tx.Create(&artifact).Error; err != nil { + return err + } + + contextJSON := parseWorkerContextJSON(task.ContextJSON) + contextJSON["uploaded_items"] = appendUploadItem(contextJSON["uploaded_items"], uploadItem) + task.ContextJSON = mustMarshalJSON(contextJSON) + task.CurrentRunID = &run.ID + task.CurrentResult = fmt.Sprintf("已识别图片《%s》中的文本。", file.Filename) + task.Status = workerTaskStatusDraft + task.LastTriggeredAt = &now + return tx.Save(&task).Error + }) + if err != nil { + web.Fail(c, web.NewBadRequest("保存 OCR 结果失败")) + return + } + + freshTask, _ := reloadWorkerTask(task.ID) + web.OK(c, gin.H{ + "text": ocrPayload.Text, + "lines": ocrPayload.Lines, + "engine": firstNonEmpty(ocrPayload.Engine, "paddleocr"), + "artifact": artifact, + "task": freshTask, + "upload_item": uploadItem, + }) +} + +type officeSkillDefinition struct { + Key string + Label string + PlanTitle string + PlanSteps []string + ResultTitle string + Summary string + Highlights []string + ActionType string +} + +var officeSkillDefinitions = map[string]officeSkillDefinition{ + "ppt-generation": { + Key: "ppt-generation", + Label: "PPT 生成", + PlanTitle: "PPT 生成流程", + PlanSteps: []string{"确认汇报目标", "规划页面结构", "提炼每页要点", "整理讲稿备注"}, + ResultTitle: "演示文稿骨架", + Summary: "已按输入内容整理出演示逻辑,并生成可继续下载和细化的 PPT 结果。", + Highlights: []string{"支持直接下载 .pptx", "同时保留大纲与讲稿备注", "适合汇报、方案和复盘"}, + ActionType: "presentation", + }, + "mind-map": { + Key: "mind-map", + Label: "思维导图", + PlanTitle: "思维导图生成流程", + PlanSteps: []string{"识别主题主线", "拆分一级分支", "补齐关键节点", "整理导图源结构"}, + ResultTitle: "导图结构草案", + Summary: "已把输入内容压成导图结构,并生成可继续分享和编辑的导图结果。", + Highlights: []string{"可下载导图 SVG", "保留导图文字结构", "适合任务拆解与知识梳理"}, + ActionType: "mindmap", + }, + "longform-writing": { + Key: "longform-writing", + Label: "长文创作", + PlanTitle: "长文创作流程", + PlanSteps: []string{"明确文体与受众", "规划章节大纲", "生成正文骨架", "整理摘要与交付稿"}, + ResultTitle: "长文草稿", + Summary: "已输出一版长文大纲和正文骨架,方便继续扩写为正式稿。", + Highlights: []string{"先给大纲再给正文草稿", "适合方案、制度和长文章", "结果可下载继续改写"}, + ActionType: "document", + }, + "text-toolkit": { + Key: "text-toolkit", + Label: "文本处理", + PlanTitle: "文本处理流程", + PlanSteps: []string{"识别清洗目标", "去除噪音符号", "统一格式与标点", "输出可复用文本"}, + ResultTitle: "清洗后文本", + Summary: "已完成文本清洗、格式整理和统计,可直接复制或下载结果。", + Highlights: []string{"去 Markdown 噪音", "输出文本统计", "适合高频复制到 Word 和 IM"}, + ActionType: "text", + }, + "ocr-understanding": { + Key: "ocr-understanding", + Label: "OCR / 图文理解", + PlanTitle: "图文理解流程", + PlanSteps: []string{"识别图像内容", "抽取文字与字段", "整理结构摘要", "输出可复用结果"}, + ResultTitle: "识别结果摘要", + Summary: "上传图片后,会优先使用 PaddleOCR 识别文字,并把结果写入当前任务产物。", + Highlights: []string{"走后端正式 OCR 链路", "识别结果会写入任务产物", "上传图片会长期保留到任务输入"}, + ActionType: "ocr", + }, + "meeting-minutes": { + Key: "meeting-minutes", + Label: "会议纪要", + PlanTitle: "会议纪要整理流程", + PlanSteps: []string{"识别会议主题", "提炼关键结论", "整理行动项", "输出同步版本"}, + ResultTitle: "会议纪要草稿", + Summary: "已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。", + Highlights: []string{"自动提炼结论与待办", "适合例会、评审会和复盘会", "默认沉淀纪要正文和行动项清单"}, + ActionType: "document", + }, + "project-planning": { + Key: "project-planning", + Label: "项目规划", + PlanTitle: "项目规划流程", + PlanSteps: []string{"明确目标边界", "拆解阶段路径", "整理里程碑", "补齐风险与依赖"}, + ResultTitle: "项目规划草案", + Summary: "已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。", + Highlights: []string{"适合启动、排期和推进对齐", "默认沉淀阶段计划与风险清单", "结果适合继续流转到任务治理"}, + ActionType: "plan", + }, + "email-drafting": { + Key: "email-drafting", + Label: "邮件起草", + PlanTitle: "邮件起草流程", + PlanSteps: []string{"识别沟通目的", "整理背景与重点", "生成正式邮件", "补充简短跟进版本"}, + ResultTitle: "邮件草稿", + Summary: "已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。", + Highlights: []string{"适合汇报、催办、确认和通知", "兼顾正式语气与清晰表达", "默认沉淀长邮件和短消息版本"}, + ActionType: "communication", + }, + "table-cleanup": { + Key: "table-cleanup", + Label: "表格整理", + PlanTitle: "表格整理流程", + PlanSteps: []string{"识别字段结构", "清洗原始数据", "整理表格内容", "补充字段说明"}, + ResultTitle: "表格整理结果", + Summary: "已把输入内容整理成结构化表格,并补了字段说明和清洗备注。", + Highlights: []string{"适合名单、台账、记录整理", "默认沉淀表格正文和字段说明", "便于继续导入 Excel 或系统"}, + ActionType: "table", + }, + "proposal-summary": { + Key: "proposal-summary", + Label: "方案摘要", + PlanTitle: "方案摘要流程", + PlanSteps: []string{"识别方案目标", "提炼核心亮点", "压缩实施路径", "输出摘要版本"}, + ResultTitle: "方案摘要稿", + Summary: "已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。", + Highlights: []string{"适合方案汇报和管理层预读", "自动压缩价值点与实施路径", "默认沉淀摘要正文和亮点清单"}, + ActionType: "summary", + }, + "progress-report": { + Key: "progress-report", + Label: "周报 / 日报", + PlanTitle: "进展汇报流程", + PlanSteps: []string{"归并本期工作", "提炼关键进展", "整理风险事项", "输出汇报版本"}, + ResultTitle: "进展汇报草稿", + Summary: "已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。", + Highlights: []string{"适合日报、周报和阶段同步", "自动补齐风险与下步计划", "默认沉淀正式汇报版和简短摘要版"}, + ActionType: "report", + }, + "contract-brief": { + Key: "contract-brief", + Label: "合同要点提炼", + PlanTitle: "合同要点提炼流程", + PlanSteps: []string{"识别合同结构", "提炼核心条款", "标记风险事项", "输出摘要版本"}, + ResultTitle: "合同要点摘要", + Summary: "已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。", + Highlights: []string{"适合采购、合作、服务类合同", "自动压缩核心条款与风险点", "默认沉淀摘要、风险和待确认项"}, + ActionType: "summary", + }, + "interview-summary": { + Key: "interview-summary", + Label: "访谈整理", + PlanTitle: "访谈整理流程", + PlanSteps: []string{"归并访谈内容", "提炼主题观点", "总结关键洞察", "输出整理版本"}, + ResultTitle: "访谈整理稿", + Summary: "已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。", + Highlights: []string{"适合用户访谈、需求调研、深度沟通", "自动提炼主题与洞察", "默认沉淀摘要、洞察和行动建议"}, + ActionType: "document", + }, + "policy-rewrite": { + Key: "policy-rewrite", + Label: "制度改写", + PlanTitle: "制度改写流程", + PlanSteps: []string{"识别制度目标", "统一结构口径", "重写正式正文", "补充修订说明"}, + ResultTitle: "制度改写稿", + Summary: "已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。", + Highlights: []string{"适合制度、流程、规范文档", "兼顾正式表达与清晰可读", "默认沉淀改写正文和修订说明"}, + ActionType: "document", + }, + "survey-summary": { + Key: "survey-summary", + Label: "表单问卷汇总", + PlanTitle: "问卷汇总流程", + PlanSteps: []string{"归并表单结果", "提炼重点发现", "整理问题与建议", "输出汇总版本"}, + ResultTitle: "问卷汇总稿", + Summary: "已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。", + Highlights: []string{"适合反馈表、报名表、调查问卷", "自动压缩重点发现和改进建议", "默认沉淀汇总报告和行动建议"}, + ActionType: "report", + }, +} + +func buildOfficeSkillResultMap(definition officeSkillDefinition, inputText string) gin.H { + artifacts := buildOfficeResultArtifacts(definition.Key, inputText) + primaryArtifact := gin.H{} + if len(artifacts) > 0 { + primaryArtifact = artifacts[0] + } + return gin.H{ + "status": "ready", + "skillKey": definition.Key, + "title": definition.ResultTitle, + "summary": definition.Summary, + "highlights": definition.Highlights, + "request": gin.H{ + "plainText": inputText, + }, + "artifacts": artifacts, + "artifact": gin.H{ + "type": firstNonEmpty(asString(primaryArtifact["kind"]), definition.ActionType, "document"), + "title": firstNonEmpty(asString(primaryArtifact["title"]), definition.ResultTitle), + "fileFormat": firstNonEmpty(asString(primaryArtifact["fileFormat"]), "md"), + "preview": firstNonEmpty(asString(primaryArtifact["content"]), inputText), + }, + "taskPlan": gin.H{ + "title": definition.PlanTitle, + "steps": buildOfficePlanSteps(definition.PlanSteps), + }, + } +} + +func buildOfficeResultArtifacts(skillKey, inputText string) []gin.H { + switch skillKey { + case "ppt-generation": + return buildPPTArtifacts(inputText) + case "mind-map": + return buildMindMapArtifacts(inputText) + case "longform-writing": + return buildLongformArtifacts(inputText) + case "text-toolkit": + return buildTextToolkitArtifacts(inputText) + case "meeting-minutes": + return buildMeetingMinutesArtifacts(inputText) + case "project-planning": + return buildProjectPlanningArtifacts(inputText) + case "email-drafting": + return buildEmailDraftingArtifacts(inputText) + case "table-cleanup": + return buildTableCleanupArtifacts(inputText) + case "proposal-summary": + return buildProposalSummaryArtifacts(inputText) + case "progress-report": + return buildProgressReportArtifacts(inputText) + case "contract-brief": + return buildContractBriefArtifacts(inputText) + case "interview-summary": + return buildInterviewSummaryArtifacts(inputText) + case "policy-rewrite": + return buildPolicyRewriteArtifacts(inputText) + case "survey-summary": + return buildSurveySummaryArtifacts(inputText) + default: + return []gin.H{} + } +} + +func buildPPTArtifacts(inputText string) []gin.H { + fields, bodyText := parseOfficeFields(inputText, map[string][]string{ + "topic": {"主题", "标题", "topic", "title"}, + "audience": {"受众", "对象", "audience"}, + "goal": {"目标", "用途", "goal"}, + "pages": {"页数", "页面数", "slides", "pages"}, + "style": {"风格", "语气", "style", "tone"}, + }) + topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "汇报主题")) + audience := firstNonEmpty(fields["audience"], "管理层 / 项目相关方") + goal := firstNonEmpty(fields["goal"], "帮助受众快速理解核心结论并推动下一步") + pageCount := maxInt(6, minInt(parsePositiveInt(fields["pages"], 8), 12)) + style := firstNonEmpty(fields["style"], "专业、简洁、结论先行") + points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"交代背景和目标", "整理当前问题与机会", "给出解决路径与方案", "明确执行节奏与下一步"}) + slides := []gin.H{ + {"title": "封面", "bullets": []string{topic, "受众:" + audience, "风格:" + style}}, + {"title": "背景与目标", "bullets": sliceStrings(points, 0, 2)}, + {"title": "现状与问题", "bullets": sliceStrings(points, 1, 4)}, + {"title": "方案路径", "bullets": sliceStrings(points, 2, 5)}, + {"title": "执行计划", "bullets": []string{goal, "阶段 1:启动与对齐", "阶段 2:执行与校正"}}, + {"title": "风险与下一步", "bullets": []string{"风险项与依赖", "需要支持的事项", "下一步行动清单"}}, + } + for len(slides) < pageCount { + slides = append(slides[:len(slides)-1], append([]gin.H{{ + "title": fmt.Sprintf("补充页 %d", len(slides)-3), + "bullets": firstNonEmptyStringSlice(sliceStrings(points, 0, 3), []string{"补充论据", "数据支撑", "案例说明"}), + }}, slides[len(slides)-1:]...)...) + } + outline := buildOfficeMarkdownOutline(topic+" PPT 大纲", slides) + notesLines := make([]string, 0, len(slides)*4) + for index, slide := range slides { + title := asString(slide["title"]) + notesLines = append(notesLines, fmt.Sprintf("## 第 %d 页:%s", index+1, title)) + notesLines = append(notesLines, fmt.Sprintf("- 建议讲法:先说明“%s”的核心判断,再展开 2-3 个重点。", title)) + for _, bullet := range anyToStrings(slide["bullets"]) { + notesLines = append(notesLines, fmt.Sprintf("- 讲稿提示:%s", bullet)) + } + notesLines = append(notesLines, "") + } + slug := officeSlug(topic, "presentation") + return []gin.H{ + { + "key": "presentation", + "title": "演示文稿", + "description": "浏览器端即时生成的 PPT 文件。", + "kind": "pptx", + "fileFormat": "pptx", + "downloadName": slug + ".pptx", + "data": gin.H{ + "topic": topic, + "slides": slides, + }, + }, + { + "key": "outline", + "title": "演示大纲", + "description": "适合先确认页面结构和内容分配。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-outline.md", + "mimeType": "text/markdown;charset=utf-8", + "content": outline, + }, + { + "key": "notes", + "title": "讲稿备注", + "description": "逐页的讲解提示和过渡说明。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-notes.md", + "mimeType": "text/markdown;charset=utf-8", + "content": strings.TrimSpace(strings.Join(notesLines, "\n")), + }, + } +} + +func buildMindMapArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "任务导图") + branches := extractOfficePoints(inputText, []string{"目标与范围", "输入材料", "关键步骤", "风险与依赖"}) + if len(branches) > 6 { + branches = branches[:6] + } + slug := officeSlug(topic, "mind-map") + return []gin.H{ + { + "key": "mindmap-svg", + "title": "导图图片", + "description": "便于直接查看和分享的 SVG 导图。", + "kind": "svg", + "fileFormat": "svg", + "downloadName": slug + ".svg", + "mimeType": "image/svg+xml;charset=utf-8", + "content": buildMindMapSVG(topic, branches), + }, + { + "key": "mindmap-outline", + "title": "导图源结构", + "description": "可继续编辑和扩展的文字结构版。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": "# " + topic + " 思维导图\n\n- " + strings.Join(branches, "\n- "), + }, + } +} + +func buildLongformArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "长文主题") + sections := []gin.H{ + {"title": "背景与问题", "bullets": []string{"交代问题背景", "说明为什么现在要做这件事"}}, + {"title": "目标与原则", "bullets": []string{"写清楚目标边界", "说明采用的基本原则"}}, + {"title": "主体分析", "bullets": []string{"从现状、问题、机会三个角度展开", "补充关键证据与案例"}}, + {"title": "方案与建议", "bullets": []string{"提出可执行的方案路径", "给出必要的资源与配套要求"}}, + {"title": "结论与下一步", "bullets": []string{"收束核心结论", "明确下一步动作"}}, + } + outline := buildOfficeMarkdownOutline(topic+" 长文大纲", sections) + lines := []string{ + "# " + topic, + "", + "## 摘要", + fmt.Sprintf("%s 这项工作需要先把背景、目标、方案和执行动作组织清楚,再进一步扩写为完整正文。本稿先给出一版可继续加工的长文骨架。", topic), + "", + } + for _, section := range sections { + title := asString(section["title"]) + lines = append(lines, "## "+title) + lines = append(lines, fmt.Sprintf("%s部分应围绕“%s”展开,建议先给出清晰判断,再用材料、案例和拆解步骤支撑结论。", title, topic)) + for _, bullet := range anyToStrings(section["bullets"]) { + lines = append(lines, "- "+bullet) + } + lines = append(lines, "") + } + slug := officeSlug(topic, "longform") + return []gin.H{ + { + "key": "outline", + "title": "章节大纲", + "description": "先确认结构是否合理。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-outline.md", + "mimeType": "text/markdown;charset=utf-8", + "content": outline, + }, + { + "key": "draft", + "title": "正文草稿", + "description": "可继续细化的正文版本。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-draft.md", + "mimeType": "text/markdown;charset=utf-8", + "content": strings.TrimSpace(strings.Join(lines, "\n")), + }, + } +} + +func buildTextToolkitArtifacts(inputText string) []gin.H { + cleaned := stripMarkdownText(inputText) + stats := strings.Join([]string{ + "# 文本统计", + "", + fmt.Sprintf("- 字符数:%d", len([]rune(cleaned))), + fmt.Sprintf("- 行数:%d", len(splitMeaningfulLines(cleaned))), + fmt.Sprintf("- 原始长度:%d", len([]rune(strings.TrimSpace(inputText)))), + "", + "## 处理说明", + "- 已移除常见 Markdown 标记", + "- 已压缩多余空行", + "- 已保留正文可读性", + }, "\n") + slug := officeSlug(pickOfficeTopic(inputText, "cleaned-text"), "text-toolkit") + return []gin.H{ + { + "key": "cleaned", + "title": "清洗后文本", + "description": "适合直接粘贴到文档或聊天工具。", + "kind": "text", + "fileFormat": "txt", + "downloadName": slug + ".txt", + "mimeType": "text/plain;charset=utf-8", + "content": cleaned, + }, + { + "key": "stats", + "title": "文本统计", + "description": "记录本次清洗和统计结果。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-stats.md", + "mimeType": "text/markdown;charset=utf-8", + "content": stats, + }, + } +} + +func buildMeetingMinutesArtifacts(inputText string) []gin.H { + fields, bodyText := parseOfficeFields(inputText, map[string][]string{ + "topic": {"会议主题", "主题", "title", "topic"}, + "date": {"时间", "日期", "date"}, + "attendees": {"参会人", "参会人员", "attendees"}, + "objective": {"目标", "目的", "objective"}, + }) + topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "会议纪要")) + meetingDate := firstNonEmpty(fields["date"], "待补充") + attendees := firstNonEmpty(fields["attendees"], "待补充") + objective := firstNonEmpty(fields["objective"], "对齐进展、结论和行动项") + points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"确认本次会议目标和范围", "同步当前进展与关键问题", "明确下一步行动和负责人", "记录需要继续跟进的依赖项"}) + actions := parseMeetingActions(firstNonEmpty(bodyText, inputText), points) + minutesContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 会议摘要", + fmt.Sprintf("%s 已整理为正式纪要版本,建议先快速确认结论和行动项,再同步到群里或项目任务中。", topic), + "", + "## 会议信息", + "- 时间:" + meetingDate, + "- 参会人:" + attendees, + "- 会议目标:" + objective, + "", + "## 关键结论", + "- " + firstNonEmpty(pointAt(points, 0), "确认会议主题和目标"), + "- " + firstNonEmpty(pointAt(points, 1), "同步当前进展与问题"), + "- " + firstNonEmpty(pointAt(points, 2), "明确下一步行动"), + "- " + firstNonEmpty(pointAt(points, 3), "确认依赖和配合事项"), + "", + "## 待办事项", + formatMeetingActionLine(actions, 0), + formatMeetingActionLine(actions, 1), + formatMeetingActionLine(actions, 2), + }, "\n")) + actionLines := []string{ + "# 会后行动项", + "", + "| 序号 | 负责人 | 行动项 | 截止时间 | 状态 |", + "| --- | --- | --- | --- | --- |", + } + for index, item := range actions { + actionLines = append(actionLines, fmt.Sprintf("| %d | %s | %s | %s | 待开始 |", index+1, asString(item["owner"]), asString(item["task"]), asString(item["due"]))) + } + actionContent := strings.TrimSpace(strings.Join(actionLines, "\n")) + syncLines := []string{ + "【" + topic + "】", + "1. " + firstNonEmpty(pointAt(points, 0), "确认会议主题和目标"), + "2. " + firstNonEmpty(pointAt(points, 1), "同步当前进展与问题"), + "3. " + firstNonEmpty(pointAt(points, 2), "明确下一步行动"), + } + for _, item := range actions[:minInt(len(actions), 2)] { + syncLines = append(syncLines, fmt.Sprintf("- %s:%s(%s)", asString(item["owner"]), asString(item["task"]), asString(item["due"]))) + } + syncLines = append(syncLines, "请相关同事按行动项继续推进。") + syncContent := strings.TrimSpace(strings.Join(syncLines, "\n")) + slug := officeSlug(topic, "meeting-minutes") + return []gin.H{ + { + "key": "minutes", + "title": "会议纪要", + "description": "适合直接发送或继续细化的正式纪要。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": minutesContent, + }, + { + "key": "actions", + "title": "行动项清单", + "description": "会后跟进和任务分派用的待办清单。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-actions.md", + "mimeType": "text/markdown;charset=utf-8", + "content": actionContent, + }, + { + "key": "sync", + "title": "会后同步摘要", + "description": "适合发到群里或邮件里的简短同步版本。", + "kind": "text", + "fileFormat": "txt", + "downloadName": slug + "-sync.txt", + "mimeType": "text/plain;charset=utf-8", + "content": syncContent, + }, + } +} + +func buildProjectPlanningArtifacts(inputText string) []gin.H { + fields, bodyText := parseOfficeFields(inputText, map[string][]string{ + "topic": {"项目", "项目名称", "主题", "topic"}, + "deadline": {"时间要求", "截止时间", "deadline"}, + "owner": {"负责人", "owner"}, + "goal": {"目标", "goal"}, + "start": {"开始时间", "启动时间", "start"}, + }) + topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "项目规划")) + deadline := firstNonEmpty(fields["deadline"], "待确认") + owner := firstNonEmpty(fields["owner"], "待指定") + goal := firstNonEmpty(fields["goal"], "先明确目标、阶段、依赖和协同方式") + start := firstNonEmpty(fields["start"], "尽快启动") + points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"明确目标、范围和成功标准", "梳理关键里程碑与交付节点", "安排执行分工与协同节奏", "识别风险与外部依赖"}) + stageRows := buildProjectStageRows(points, owner, deadline) + planContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 项目目标", + fmt.Sprintf("围绕“%s”整理一版可执行的项目规划,先确保目标、阶段和依赖都清楚。", topic), + "- 负责人:" + owner, + "- 启动时间:" + start, + "- 时间要求:" + deadline, + "- 总体目标:" + goal, + "", + "## 阶段安排", + "### 阶段 1:启动与对齐", + "- " + firstNonEmpty(pointAt(points, 0), "明确目标和边界"), + "### 阶段 2:执行与推进", + "- " + firstNonEmpty(pointAt(points, 1), "推进关键里程碑和核心交付"), + "### 阶段 3:验收与收口", + "- " + firstNonEmpty(pointAt(points, 2), "完成验收、复盘和沉淀"), + }, "\n")) + milestoneContent := strings.TrimSpace(strings.Join([]string{ + "# 里程碑清单", + "", + "1. M1:" + firstNonEmpty(pointAt(points, 0), "明确目标和边界"), + "2. M2:" + firstNonEmpty(pointAt(points, 1), "推进关键里程碑和核心交付"), + "3. M3:" + firstNonEmpty(pointAt(points, 2), "完成验收、复盘和沉淀"), + "4. M4:" + firstNonEmpty(pointAt(points, 3), "确认依赖和外部配合事项"), + }, "\n")) + riskContent := strings.TrimSpace(strings.Join([]string{ + "# 风险与依赖", + "", + "- 风险 1:" + firstNonEmpty(pointAt(points, 3), "关键依赖尚未完全明确"), + "- 风险 2:跨团队协同节奏可能影响排期", + "- 建议:将关键依赖提前前置到周会或启动会确认", + }, "\n")) + scheduleLines := []string{ + "# 阶段排期表", + "", + "| 阶段 | 时间窗 | 负责人 | 目标 | 交付物 |", + "| --- | --- | --- | --- | --- |", + } + for _, item := range stageRows { + scheduleLines = append(scheduleLines, fmt.Sprintf("| %s | %s | %s | %s | %s |", asString(item["stage"]), asString(item["window"]), asString(item["owner"]), asString(item["goal"]), asString(item["output"]))) + } + scheduleContent := strings.TrimSpace(strings.Join(scheduleLines, "\n")) + slug := officeSlug(topic, "project-planning") + return []gin.H{ + { + "key": "project-plan", + "title": "项目计划", + "description": "适合直接评审和继续细化的主计划。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": planContent, + }, + { + "key": "milestones", + "title": "里程碑清单", + "description": "关键节点和交付节奏的简洁版列表。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-milestones.md", + "mimeType": "text/markdown;charset=utf-8", + "content": milestoneContent, + }, + { + "key": "risks", + "title": "风险清单", + "description": "项目推进前需要确认的风险与依赖。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-risks.md", + "mimeType": "text/markdown;charset=utf-8", + "content": riskContent, + }, + { + "key": "schedule", + "title": "阶段排期表", + "description": "更接近甘特视角的阶段、时间窗和交付物表。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-schedule.md", + "mimeType": "text/markdown;charset=utf-8", + "content": scheduleContent, + }, + } +} + +func buildEmailDraftingArtifacts(inputText string) []gin.H { + fields, bodyText := parseOfficeFields(inputText, map[string][]string{ + "recipient": {"收件人", "对象", "recipient", "to"}, + "cc": {"抄送", "抄送人", "cc"}, + "topic": {"主题", "标题", "topic", "subject"}, + "purpose": {"目的", "意图", "purpose"}, + "action": {"希望对方", "需要对方", "action"}, + "deadline": {"时间", "截止时间", "deadline"}, + "tone": {"语气", "风格", "tone", "style"}, + }) + topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "邮件主题")) + recipient := firstNonEmpty(fields["recipient"], "各位") + ccList := splitSimpleList(fields["cc"]) + purpose := firstNonEmpty(fields["purpose"], "同步背景和当前进展") + action := firstNonEmpty(fields["action"], "请协助确认并继续推进") + deadline := firstNonEmpty(fields["deadline"], "请尽快反馈") + tone := firstNonEmpty(fields["tone"], "正式、明确、礼貌") + points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"交代背景和当前情况", "说明希望对方配合或确认的事项", "给出时间点和下一步动作", "保持礼貌但结论清楚"}) + subjectVariants := buildEmailSubjectVariants(topic, purpose) + subject := subjectVariants[0] + emailContent := strings.TrimSpace(strings.Join([]string{ + "主题:" + subject, + "收件人:" + recipient, + "抄送:" + firstNonEmpty(strings.Join(ccList, "、"), "无"), + "", + recipient + "好,", + "", + purpose + "。" + firstNonEmpty(pointAt(points, 0), "这边同步一下当前事项的背景和进展。"), + action + "。" + firstNonEmpty(pointAt(points, 1), "目前需要大家协助确认相关事项。"), + deadline + "。" + firstNonEmpty(pointAt(points, 2), "如无问题,请按约定时间继续推进。"), + "", + "语气建议:" + tone + "。如有问题,请直接回复沟通。", + "", + "谢谢。", + }, "\n")) + shortContent := strings.TrimSpace(strings.Join([]string{ + "【" + topic + "】", + firstNonEmpty(pointAt(points, 1), "请协助确认相关事项。"), + firstNonEmpty(pointAt(points, 2), "如无问题请按时推进。"), + }, "\n")) + briefContent := strings.TrimSpace(strings.Join([]string{ + "# 邮件要点", + "", + "- 主题:" + subject, + "- " + firstNonEmpty(pointAt(points, 0), "交代背景和当前情况"), + "- " + firstNonEmpty(pointAt(points, 1), "说明希望对方配合或确认的事项"), + "- " + firstNonEmpty(pointAt(points, 2), "给出时间点和下一步动作"), + "- " + firstNonEmpty(pointAt(points, 3), "保持礼貌但结论清楚"), + }, "\n")) + subjectLines := []string{ + "# 主题与抄送建议", + "", + "- 收件人:" + recipient, + "- 抄送建议:" + firstNonEmpty(strings.Join(ccList, "、"), "建议抄送直属负责人 / 相关协同方"), + "", + "## 可选主题", + } + for index, item := range subjectVariants { + subjectLines = append(subjectLines, fmt.Sprintf("%d. %s", index+1, item)) + } + subjectContent := strings.TrimSpace(strings.Join(subjectLines, "\n")) + slug := officeSlug(topic, "email-drafting") + return []gin.H{ + { + "key": "email", + "title": "正式邮件", + "description": "可直接复制到邮箱发送的完整版本。", + "kind": "text", + "fileFormat": "txt", + "downloadName": slug + ".txt", + "mimeType": "text/plain;charset=utf-8", + "content": emailContent, + }, + { + "key": "short-followup", + "title": "简短跟进版", + "description": "适合发到 IM 或群里的短消息版本。", + "kind": "text", + "fileFormat": "txt", + "downloadName": slug + "-short.txt", + "mimeType": "text/plain;charset=utf-8", + "content": shortContent, + }, + { + "key": "brief", + "title": "邮件要点", + "description": "方便审批或复核邮件口径的要点版。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-brief.md", + "mimeType": "text/markdown;charset=utf-8", + "content": briefContent, + }, + { + "key": "subject-variants", + "title": "主题与抄送建议", + "description": "提供多版主题候选和抄送建议。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-subject.md", + "mimeType": "text/markdown;charset=utf-8", + "content": subjectContent, + }, + } +} + +func buildTableCleanupArtifacts(inputText string) []gin.H { + fields, bodyText := parseOfficeFields(inputText, map[string][]string{ + "topic": {"主题", "标题", "topic", "title"}, + "columns": {"列", "字段", "表头", "columns"}, + }) + topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "表格整理")) + points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"字段 1:主对象", "字段 2:当前状态", "字段 3:下一步动作", "字段 4:备注信息"}) + columns := parseTableColumns(fields["columns"], points) + tableRows := parseTableRows(firstNonEmpty(bodyText, inputText), columns, points) + rows := make([]string, 0, len(tableRows)) + csvRows := make([][]string, 0, len(tableRows)) + for index, row := range tableRows { + cells := make([]string, 0, len(columns)) + csvCellRow := []string{fmt.Sprintf("%d", index+1)} + for columnIndex := range columns { + value := "" + if columnIndex < len(row) { + value = row[columnIndex] + } + cells = append(cells, value) + csvCellRow = append(csvCellRow, value) + } + rows = append(rows, fmt.Sprintf("| %d | %s |", index+1, strings.Join(cells, " | "))) + csvRows = append(csvRows, csvCellRow) + } + csvContent := buildCSVText(append([]string{"序号"}, columns...), csvRows) + tableContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "| 序号 | " + strings.Join(columns, " | ") + " |", + "| " + strings.Join(append([]string{"---"}, repeatString("---", len(columns))...), " | ") + " |", + strings.Join(rows, "\n"), + }, "\n")) + fieldLines := []string{ + "# 字段说明", + "", + "- `序号`:用于标记记录顺序", + } + for _, column := range columns { + fieldLines = append(fieldLines, fmt.Sprintf("- `%s`:本次整理后的结构化字段", column)) + } + fieldsContent := strings.TrimSpace(strings.Join(fieldLines, "\n")) + notesContent := strings.TrimSpace(strings.Join([]string{ + "# 清洗备注", + "", + "- 已对原始内容做字段归并", + "- 已输出 CSV 主文件,便于直接导入 Excel", + "- 建议下一步补充负责人、日期或金额等关键字段", + }, "\n")) + slug := officeSlug(topic, "table-cleanup") + return []gin.H{ + { + "key": "csv", + "title": "CSV 主表", + "description": "适合直接导入 Excel 或系统的主文件。", + "kind": "text", + "fileFormat": "csv", + "downloadName": slug + ".csv", + "mimeType": "text/csv;charset=utf-8", + "content": csvContent, + }, + { + "key": "table", + "title": "表格预览", + "description": "适合先快速预览整理结果的 Markdown 版本。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": tableContent, + }, + { + "key": "fields", + "title": "字段说明", + "description": "记录各字段的含义和使用方式。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-fields.md", + "mimeType": "text/markdown;charset=utf-8", + "content": fieldsContent, + }, + { + "key": "notes", + "title": "清洗备注", + "description": "说明本次整理和清洗做了哪些处理。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-notes.md", + "mimeType": "text/markdown;charset=utf-8", + "content": notesContent, + }, + } +} + +func buildProposalSummaryArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "方案摘要") + points := extractOfficePoints(inputText, []string{"明确方案目标和解决的问题", "提炼核心价值与收益", "压缩关键实施路径", "明确需要决策的事项"}) + summaryContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 核心结论", + fmt.Sprintf("%s 已压缩为适合汇报和快速预读的摘要版本,重点保留目标、价值点和实施建议。", topic), + "", + "## 关键亮点", + "- " + firstNonEmpty(pointAt(points, 0), "明确方案目标和解决的问题"), + "- " + firstNonEmpty(pointAt(points, 1), "提炼核心价值与收益"), + "- " + firstNonEmpty(pointAt(points, 2), "压缩关键实施路径"), + "- " + firstNonEmpty(pointAt(points, 3), "明确需要决策的事项"), + "", + "## 实施建议", + "- 建议先确认资源与排期", + "- 建议围绕高优先级场景先落一版 MVP", + "- 建议同步关键负责人做进一步评审", + }, "\n")) + highlightsContent := strings.TrimSpace(strings.Join([]string{ + "# 亮点清单", + "", + "1. " + firstNonEmpty(pointAt(points, 0), "明确方案目标和解决的问题"), + "2. " + firstNonEmpty(pointAt(points, 1), "提炼核心价值与收益"), + "3. " + firstNonEmpty(pointAt(points, 2), "压缩关键实施路径"), + "4. " + firstNonEmpty(pointAt(points, 3), "明确需要决策的事项"), + }, "\n")) + briefingContent := strings.TrimSpace(strings.Join([]string{ + "【" + topic + " 摘要】", + "1. " + firstNonEmpty(pointAt(points, 0), "明确方案目标和解决的问题"), + "2. " + firstNonEmpty(pointAt(points, 1), "提炼核心价值与收益"), + "3. " + firstNonEmpty(pointAt(points, 2), "压缩关键实施路径"), + "建议按上述结论进入下一步决策或评审。", + }, "\n")) + slug := officeSlug(topic, "proposal-summary") + return []gin.H{ + { + "key": "summary", + "title": "方案摘要", + "description": "适合直接汇报或继续细化的主摘要。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": summaryContent, + }, + { + "key": "highlights", + "title": "亮点清单", + "description": "压缩提炼后的价值点列表。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-highlights.md", + "mimeType": "text/markdown;charset=utf-8", + "content": highlightsContent, + }, + { + "key": "briefing", + "title": "汇报短版", + "description": "适合发群或口头汇报前快速同步的短摘要。", + "kind": "text", + "fileFormat": "txt", + "downloadName": slug + "-briefing.txt", + "mimeType": "text/plain;charset=utf-8", + "content": briefingContent, + }, + } +} + +func buildProgressReportArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "进展汇报") + points := extractOfficePoints(inputText, []string{"本期完成的关键工作", "当前风险和阻塞项", "需要协同支持的事项", "下一步计划"}) + reportContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 本期进展", + "- " + firstNonEmpty(pointAt(points, 0), "已完成本期关键工作整理"), + "- " + firstNonEmpty(pointAt(points, 1), "已同步当前风险和问题"), + "", + "## 风险与问题", + "- " + firstNonEmpty(pointAt(points, 1), "当前仍有待继续确认的风险项"), + "- " + firstNonEmpty(pointAt(points, 2), "存在需要跨团队协同支持的事项"), + "", + "## 下步计划", + "- " + firstNonEmpty(pointAt(points, 3), "按既定节奏继续推进下一步工作"), + "- 根据反馈更新排期和优先级", + }, "\n")) + summaryContent := strings.TrimSpace(strings.Join([]string{ + "【" + topic + "】", + "1. " + firstNonEmpty(pointAt(points, 0), "已完成本期关键工作整理"), + "2. " + firstNonEmpty(pointAt(points, 1), "已同步当前风险和问题"), + "3. " + firstNonEmpty(pointAt(points, 3), "下一步按计划继续推进"), + }, "\n")) + checklistContent := strings.TrimSpace(strings.Join([]string{ + "# 跟进清单", + "", + "- [ ] " + firstNonEmpty(pointAt(points, 1), "继续跟进风险项"), + "- [ ] " + firstNonEmpty(pointAt(points, 2), "推动跨团队协同事项"), + "- [ ] " + firstNonEmpty(pointAt(points, 3), "执行下一步计划"), + }, "\n")) + slug := officeSlug(topic, "progress-report") + return []gin.H{ + { + "key": "report", + "title": "正式汇报", + "description": "适合直接发送或继续细化的正式版本。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": reportContent, + }, + { + "key": "summary", + "title": "简短摘要", + "description": "适合发群或站会快速同步的简洁版。", + "kind": "text", + "fileFormat": "txt", + "downloadName": slug + "-summary.txt", + "mimeType": "text/plain;charset=utf-8", + "content": summaryContent, + }, + { + "key": "checklist", + "title": "跟进清单", + "description": "需要继续推进的风险与事项列表。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-checklist.md", + "mimeType": "text/markdown;charset=utf-8", + "content": checklistContent, + }, + } +} + +func buildContractBriefArtifacts(inputText string) []gin.H { + fields, bodyText := parseOfficeFields(inputText, map[string][]string{ + "topic": {"合同名称", "主题", "topic"}, + "role": {"立场", "我方立场", "role"}, + "focus": {"重点条款", "重点关注", "focus"}, + "counterpart": {"对方", "合作方", "counterpart"}, + }) + topic := firstNonEmpty(fields["topic"], pickOfficeTopic(firstNonEmpty(bodyText, inputText), "合同要点")) + role := firstNonEmpty(fields["role"], "我方视角") + focus := firstNonEmpty(fields["focus"], "付款、违约、终止、争议解决") + counterpart := firstNonEmpty(fields["counterpart"], "对方主体待确认") + points := extractOfficePoints(firstNonEmpty(bodyText, inputText), []string{"合同目的与合作范围", "付款与结算安排", "违约责任与赔偿约定", "终止条件与争议处理"}) + focusKeywords := splitFocusKeywords(focus) + redlineItems := buildContractRedlineItems(points, focusKeywords) + summaryContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 审查视角", + "- 立场:" + role, + "- 重点条款:" + focus, + "- 对方主体:" + counterpart, + "", + "## 核心条款", + "- " + firstNonEmpty(pointAt(points, 0), "合同目的与合作范围"), + "- " + firstNonEmpty(pointAt(points, 1), "付款与结算安排"), + "- " + firstNonEmpty(pointAt(points, 2), "违约责任与赔偿约定"), + "- " + firstNonEmpty(pointAt(points, 3), "终止条件与争议处理"), + "", + "## 阅读建议", + "建议先快速确认付款、违约、终止三类关键条款,再进入逐条审阅。", + }, "\n")) + riskContent := strings.TrimSpace(strings.Join([]string{ + "# 风险提示", + "", + "- 风险 1:" + firstNonEmpty(pointAt(points, 2), "违约责任约定可能偏重单方义务"), + "- 风险 2:" + firstNonEmpty(pointAt(points, 3), "终止或争议处理条款仍需继续确认"), + "- 建议:重点核对责任边界、赔偿上限和解除条件", + }, "\n")) + confirmContent := strings.TrimSpace(strings.Join([]string{ + "# 待确认事项", + "", + "- [ ] 付款节点与验收口径是否一致", + "- [ ] 违约责任是否存在明显失衡", + "- [ ] 终止条件与争议解决地是否可接受", + }, "\n")) + redlineLines := []string{ + "# 红线建议", + "", + "| 条款 | 主要风险 | 建议红线 |", + "| --- | --- | --- |", + } + for _, item := range redlineItems { + redlineLines = append(redlineLines, fmt.Sprintf("| %s | %s | %s |", asString(item["clause"]), asString(item["risk"]), asString(item["suggestion"]))) + } + redlineContent := strings.TrimSpace(strings.Join(redlineLines, "\n")) + clauseReviewLines := []string{ + "# 逐条审查意见", + "", + "| 条款 | 审查结论 | 主要问题 | 建议动作 |", + "| --- | --- | --- | --- |", + } + for index, item := range redlineItems { + conclusion := "建议复核" + if index == 0 { + conclusion = "需重点关注" + } else if index == 1 { + conclusion = "建议修改" + } + clauseReviewLines = append(clauseReviewLines, fmt.Sprintf("| %s | %s | %s | %s |", asString(item["clause"]), conclusion, asString(item["risk"]), asString(item["suggestion"]))) + } + clauseReviewContent := strings.TrimSpace(strings.Join(clauseReviewLines, "\n")) + slug := officeSlug(topic, "contract-brief") + return []gin.H{ + { + "key": "summary", + "title": "合同摘要", + "description": "适合业务和负责人快速预读的核心条款摘要。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": summaryContent, + }, + { + "key": "risks", + "title": "风险提示", + "description": "提炼出的主要风险点和审查建议。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-risks.md", + "mimeType": "text/markdown;charset=utf-8", + "content": riskContent, + }, + { + "key": "confirm", + "title": "待确认事项", + "description": "需要继续确认或人工复核的关键事项。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-confirm.md", + "mimeType": "text/markdown;charset=utf-8", + "content": confirmContent, + }, + { + "key": "redline", + "title": "红线建议", + "description": "更接近合同审查稿的修改建议版本。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-redline.md", + "mimeType": "text/markdown;charset=utf-8", + "content": redlineContent, + }, + { + "key": "clause-review", + "title": "逐条审查意见", + "description": "更像法务审查单的逐条意见表。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-review.md", + "mimeType": "text/markdown;charset=utf-8", + "content": clauseReviewContent, + }, + } +} + +func buildInterviewSummaryArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "访谈整理") + points := extractOfficePoints(inputText, []string{"受访者关注的核心问题", "当前使用中的主要痛点", "对方案或产品的积极反馈", "后续建议和动作"}) + summaryContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 主题摘要", + "- " + firstNonEmpty(pointAt(points, 0), "受访者关注的核心问题"), + "- " + firstNonEmpty(pointAt(points, 1), "当前使用中的主要痛点"), + "- " + firstNonEmpty(pointAt(points, 2), "对方案或产品的积极反馈"), + "- " + firstNonEmpty(pointAt(points, 3), "后续建议和动作"), + "", + "## 访谈结论", + "访谈内容已压缩为主题和观点,适合继续沉淀为需求或汇报材料。", + }, "\n")) + insightContent := strings.TrimSpace(strings.Join([]string{ + "# 洞察结论", + "", + "- 洞察 1:" + firstNonEmpty(pointAt(points, 0), "受访者更关注核心目标是否真正落地"), + "- 洞察 2:" + firstNonEmpty(pointAt(points, 1), "当前流程中存在明显使用阻力或理解成本"), + "- 洞察 3:" + firstNonEmpty(pointAt(points, 2), "已有价值点需要进一步明确放大"), + }, "\n")) + actionsContent := strings.TrimSpace(strings.Join([]string{ + "# 后续建议", + "", + "- [ ] " + firstNonEmpty(pointAt(points, 3), "将访谈结论转成下一步行动项"), + "- [ ] 对重点洞察做进一步验证", + "- [ ] 同步相关负责人决定是否进入后续迭代", + }, "\n")) + slug := officeSlug(topic, "interview-summary") + return []gin.H{ + { + "key": "summary", + "title": "访谈摘要", + "description": "访谈主题和观点的整理版本。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": summaryContent, + }, + { + "key": "insights", + "title": "洞察结论", + "description": "从访谈中提炼出的关键洞察。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-insights.md", + "mimeType": "text/markdown;charset=utf-8", + "content": insightContent, + }, + { + "key": "actions", + "title": "后续建议", + "description": "建议继续推进或验证的动作。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-actions.md", + "mimeType": "text/markdown;charset=utf-8", + "content": actionsContent, + }, + } +} + +func buildPolicyRewriteArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "制度改写") + points := extractOfficePoints(inputText, []string{"明确制度适用范围", "统一执行口径和条件", "补齐例外和审批规则", "强调执行责任和留痕要求"}) + rewriteContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 制度正文", + fmt.Sprintf("%s 已重写为更清楚、统一的正式版本,适合继续评审或发布。", topic), + "", + "### 适用范围", + "- " + firstNonEmpty(pointAt(points, 0), "明确制度适用对象与范围"), + "### 执行要求", + "- " + firstNonEmpty(pointAt(points, 1), "统一执行口径和基本条件"), + "- " + firstNonEmpty(pointAt(points, 2), "补充例外处理与审批规则"), + "### 管理要求", + "- " + firstNonEmpty(pointAt(points, 3), "强调执行责任和记录留痕"), + }, "\n")) + revisionContent := strings.TrimSpace(strings.Join([]string{ + "# 修订说明", + "", + "- 已统一制度结构和表达口径", + "- 已补清含糊表达,增强执行可读性", + "- 建议结合实际流程再做一次法务或管理复核", + }, "\n")) + tipsContent := strings.TrimSpace(strings.Join([]string{ + "# 执行提示", + "", + "- 发布前建议确认适用范围与责任人", + "- 对例外情形建议补充审批路径", + "- 对留痕要求建议同步模板或系统入口", + }, "\n")) + slug := officeSlug(topic, "policy-rewrite") + return []gin.H{ + { + "key": "rewrite", + "title": "改写正文", + "description": "适合继续评审或发布的正式版本。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": rewriteContent, + }, + { + "key": "revision", + "title": "修订说明", + "description": "说明本次主要改写和统一了哪些内容。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-revision.md", + "mimeType": "text/markdown;charset=utf-8", + "content": revisionContent, + }, + { + "key": "tips", + "title": "执行提示", + "description": "制度落地前需要注意的执行建议。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-tips.md", + "mimeType": "text/markdown;charset=utf-8", + "content": tipsContent, + }, + } +} + +func buildSurveySummaryArtifacts(inputText string) []gin.H { + topic := pickOfficeTopic(inputText, "问卷汇总") + points := extractOfficePoints(inputText, []string{"参与者的主要反馈方向", "高频问题或不满意点", "值得保留的积极反馈", "后续改进建议"}) + reportContent := strings.TrimSpace(strings.Join([]string{ + "# " + topic, + "", + "## 汇总结论", + "- " + firstNonEmpty(pointAt(points, 0), "参与者的主要反馈方向"), + "- " + firstNonEmpty(pointAt(points, 1), "高频问题或不满意点"), + "- " + firstNonEmpty(pointAt(points, 2), "值得保留的积极反馈"), + "- " + firstNonEmpty(pointAt(points, 3), "后续改进建议"), + "", + "## 汇总说明", + "已把表单和问卷结果压缩成便于阅读和决策的汇总结论。", + }, "\n")) + findingsContent := strings.TrimSpace(strings.Join([]string{ + "# 重点发现", + "", + "- 发现 1:" + firstNonEmpty(pointAt(points, 0), "参与者主要关注体验和执行效果"), + "- 发现 2:" + firstNonEmpty(pointAt(points, 1), "部分问题集中在流程或内容安排上"), + "- 发现 3:" + firstNonEmpty(pointAt(points, 2), "已有若干积极反馈值得继续保留"), + }, "\n")) + actionsContent := strings.TrimSpace(strings.Join([]string{ + "# 改进建议", + "", + "- [ ] " + firstNonEmpty(pointAt(points, 3), "根据汇总结果制定下一步改进动作"), + "- [ ] 对高频问题安排专项优化", + "- [ ] 向负责人同步汇总结论和优先级建议", + }, "\n")) + slug := officeSlug(topic, "survey-summary") + return []gin.H{ + { + "key": "report", + "title": "汇总报告", + "description": "适合直接汇报或继续细化的主报告。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + ".md", + "mimeType": "text/markdown;charset=utf-8", + "content": reportContent, + }, + { + "key": "findings", + "title": "重点发现", + "description": "从表单结果中提炼出的关键发现。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-findings.md", + "mimeType": "text/markdown;charset=utf-8", + "content": findingsContent, + }, + { + "key": "actions", + "title": "改进建议", + "description": "建议后续继续推进的改进行动。", + "kind": "text", + "fileFormat": "md", + "downloadName": slug + "-actions.md", + "mimeType": "text/markdown;charset=utf-8", + "content": actionsContent, + }, + } +} + +func persistOfficeExecution(task model.WorkerTask, definition officeSkillDefinition, inputText string, result gin.H) (model.WorkerRun, []model.WorkerArtifact, error) { + now := time.Now() + inputJSON, _ := json.Marshal(gin.H{ + "skill_key": definition.Key, + "input_text": inputText, + }) + outputJSON, _ := json.Marshal(result) + logsJSON, _ := json.Marshal([]string{ + fmt.Sprintf("已执行技能:%s", definition.Label), + "已生成结果并写入任务产物", + }) + run := model.WorkerRun{} + artifacts := make([]model.WorkerArtifact, 0) + + err := store.DB.Transaction(func(tx *gorm.DB) error { + run = model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: definition.Key, + ActionTitle: definition.Label, + ActionType: definition.ActionType, + Status: "done", + InputJSON: string(inputJSON), + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := tx.Create(&run).Error; err != nil { + return err + } + + for _, item := range toArtifactMaps(result["artifacts"]) { + contentJSON, _ := json.Marshal(item) + contentText := firstNonEmpty(asString(item["content"]), asString(result["summary"])) + artifact := model.WorkerArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: firstNonEmpty(asString(item["title"]), definition.ResultTitle), + ArtifactType: firstNonEmpty(asString(item["kind"]), definition.ActionType, "text"), + Status: workerArtifactStatusDraft, + ContentText: contentText, + ContentJSON: string(contentJSON), + SourceRefsJSON: "[]", + CreatedByRunID: &run.ID, + } + if err := tx.Create(&artifact).Error; err != nil { + return err + } + artifacts = append(artifacts, artifact) + } + + task.CurrentRunID = &run.ID + task.CurrentResult = firstNonEmpty(asString(result["summary"]), definition.Summary) + task.Status = workerTaskStatusDraft + task.LastTriggeredAt = &now + return tx.Save(&task).Error + }) + return run, artifacts, err +} + +func loadMyOwnedTask(c *gin.Context, user *model.User, taskID uint) (model.WorkerTask, bool) { + var task model.WorkerTask + if err := myTaskQuery(user).Where("id = ?", taskID).First(&task).Error; err != nil { + web.Fail(c, web.NewNotFoundError("任务不存在")) + return task, false + } + return task, true +} + +func reloadWorkerTask(taskID uint) (model.WorkerTask, error) { + var task model.WorkerTask + err := store.DB.First(&task, taskID).Error + return task, err +} + +func buildOfficePlanSteps(steps []string) []gin.H { + items := make([]gin.H, 0, len(steps)) + for index, step := range steps { + items = append(items, gin.H{ + "id": fmt.Sprintf("office-step-%d", index+1), + "order": index + 1, + "title": step, + "desc": "生成可继续加工的中间结果。", + }) + } + return items +} + +func pickOfficeTopic(text, fallback string) string { + lines := splitMeaningfulLines(text) + if len(lines) == 0 { + return fallback + } + line := officeLineTrimPattern.ReplaceAllString(lines[0], "") + line = strings.TrimSpace(strings.TrimRight(line, "。!?!?.")) + if line == "" { + return fallback + } + return line +} + +func extractOfficePoints(text string, fallback []string) []string { + lines := splitMeaningfulLines(text) + points := make([]string, 0, 6) + for _, line := range lines { + normalized := strings.TrimSpace(regexp.MustCompile(`^[•\-*+\d.)、\s]+`).ReplaceAllString(line, "")) + if normalized != "" { + points = append(points, normalized) + } + if len(points) >= 6 { + return points + } + } + if len(points) >= 3 { + return points + } + parts := regexp.MustCompile(`[。!?!?\n;;,,、]`).Split(strings.TrimSpace(text), -1) + points = points[:0] + for _, part := range parts { + part = strings.TrimSpace(part) + if len([]rune(part)) > 3 { + points = append(points, part) + } + if len(points) >= 6 { + return points + } + } + if len(points) >= 3 { + return points + } + return fallback +} + +func parseOfficeFields(text string, aliasMap map[string][]string) (map[string]string, string) { + fields := make(map[string]string) + restLines := make([]string, 0) + for _, line := range splitMeaningfulLines(text) { + matched := false + for field, aliases := range aliasMap { + pattern := regexp.MustCompile(`^(?:` + strings.Join(escapeRegexpList(aliases), "|") + `)\s*[::]\s*(.+)$`) + match := pattern.FindStringSubmatch(line) + if len(match) < 2 { + continue + } + fields[field] = strings.TrimSpace(match[1]) + matched = true + break + } + if !matched { + restLines = append(restLines, line) + } + } + return fields, strings.TrimSpace(strings.Join(restLines, "\n")) +} + +func escapeRegexpList(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, regexp.QuoteMeta(value)) + } + return result +} + +func parsePositiveInt(value string, fallback int) int { + match := regexp.MustCompile(`\d+`).FindString(value) + if match == "" { + return fallback + } + var parsed int + _, err := fmt.Sscanf(match, "%d", &parsed) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func firstNonEmptyStringSlice(items []string, fallback []string) []string { + if len(items) == 0 { + return fallback + } + result := make([]string, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item) != "" { + result = append(result, item) + } + } + if len(result) == 0 { + return fallback + } + return result +} + +func parseDueText(value string) string { + match := regexp.MustCompile(`(\d{1,2}[月/-]\d{1,2}[日号]?(?:前)?|本周[一二三四五六日天](?:前)?|下周[一二三四五六日天](?:前)?|周[一二三四五六日天](?:前)?|今天|明天|后天|本周内|下周内)`).FindString(strings.TrimSpace(value)) + if match == "" { + return "待定" + } + return match +} + +func normalizeActionText(value string) string { + value = regexp.MustCompile(`^(action\s*\d*[::]?|跟进[::]?|负责[::]?)`).ReplaceAllString(strings.TrimSpace(value), "") + return strings.Join(strings.Fields(value), " ") +} + +func parseMeetingActions(text string, fallback []string) []gin.H { + lines := splitMeaningfulLines(text) + actions := make([]gin.H, 0, len(lines)) + for _, line := range lines { + cleaned := strings.TrimSpace(regexp.MustCompile(`^[•\-*+\d.)、\s]+`).ReplaceAllString(line, "")) + if cleaned == "" { + continue + } + owner := "" + task := "" + if match := regexp.MustCompile(`^([^::]{1,12})[::]\s*(.+)$`).FindStringSubmatch(cleaned); len(match) >= 3 { + owner = strings.TrimSpace(match[1]) + task = strings.TrimSpace(match[2]) + } else if match := regexp.MustCompile(`^(\S{1,12})\s*(负责|跟进|推进|确认|完成|提交)\s*(.+)$`).FindStringSubmatch(cleaned); len(match) >= 4 { + owner = strings.TrimSpace(match[1]) + task = strings.TrimSpace(match[2] + " " + match[3]) + } + due := parseDueText(cleaned) + normalizedTask := normalizeActionText(firstNonEmpty(task, cleaned)) + if normalizedTask == "" { + continue + } + if owner != "" || regexp.MustCompile(`负责|跟进|推进|确认|完成|提交`).MatchString(cleaned) { + actions = append(actions, gin.H{ + "owner": firstNonEmpty(owner, "待指定"), + "task": normalizedTask, + "due": due, + }) + } + } + if len(actions) > 0 { + if len(actions) > 6 { + return actions[:6] + } + return actions + } + out := make([]gin.H, 0, minInt(len(fallback), 4)) + for index, item := range fallback { + out = append(out, gin.H{ + "owner": fmt.Sprintf("Action %d", index+1), + "task": item, + "due": "待定", + }) + if len(out) >= 4 { + break + } + } + return out +} + +func parseTableColumns(rawColumns string, points []string) []string { + parts := regexp.MustCompile(`[,,|/、\t]+`).Split(strings.TrimSpace(rawColumns), -1) + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + if len(result) >= 2 { + if len(result) > 8 { + return result[:8] + } + return result + } + return []string{"项目", "状态", "负责人", "备注"} +} + +func parseTableRows(text string, columns, fallback []string) [][]string { + lines := splitMeaningfulLines(text) + rows := make([][]string, 0, len(lines)) + for _, line := range lines { + if regexp.MustCompile(`^(主题|标题|topic|title|列|字段|表头|columns)\s*[::]`).MatchString(line) { + continue + } + normalized := strings.TrimSpace(regexp.MustCompile(`^[•\-*+\d.)、\s]+`).ReplaceAllString(line, "")) + values := regexp.MustCompile(`\t+|[||,,;;]+`).Split(normalized, -1) + row := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + row = append(row, value) + } + } + if len(row) >= 2 { + if len(row) > len(columns) { + row = row[:len(columns)] + } + rows = append(rows, row) + } + } + if len(rows) > 0 { + if len(rows) > 12 { + return rows[:12] + } + return rows + } + out := make([][]string, 0, minInt(len(fallback), 4)) + for index, item := range fallback { + row := []string{item, "待核对", "待指定", "待确认"} + if len(row) > len(columns) { + row = row[:len(columns)] + } + out = append(out, row) + if index >= 3 { + break + } + } + return out +} + +func escapeCSVCell(value string) string { + value = strings.ReplaceAll(value, `"`, `""`) + if strings.ContainsAny(value, "\",\n") { + return `"` + value + `"` + } + return value +} + +func buildCSVText(columns []string, rows [][]string) string { + lines := make([]string, 0, len(rows)+1) + lines = append(lines, strings.Join(columns, ",")) + for _, row := range rows { + values := make([]string, 0, len(row)) + for _, cell := range row { + values = append(values, escapeCSVCell(cell)) + } + lines = append(lines, strings.Join(values, ",")) + } + return strings.Join(lines, "\n") +} + +func splitSimpleList(value string) []string { + parts := regexp.MustCompile(`[,,、/;;\n]+`).Split(strings.TrimSpace(value), -1) + out := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + out = append(out, part) + } + return out +} + +func buildProjectStageRows(points []string, owner, deadline string) []gin.H { + labels := []string{"阶段 1", "阶段 2", "阶段 3", "阶段 4"} + windows := []string{"第 1 周", "第 2 周", "第 3 周", firstNonEmpty(deadline, "收口前")} + rows := make([]gin.H, 0, len(labels)) + for index, label := range labels { + output := "收口结论" + switch index { + case 0: + output = "目标确认" + case 1: + output = "阶段交付" + case 2: + output = "验收材料" + } + rows = append(rows, gin.H{ + "stage": label, + "window": windows[index], + "goal": firstNonEmpty(pointAt(points, index), "补充"+label+"目标"), + "owner": firstNonEmpty(owner, "待指定"), + "output": output, + }) + } + return rows +} + +func buildEmailSubjectVariants(topic, purpose string) []string { + base := firstNonEmpty(topic, "事项") + context := strings.TrimSpace(purpose) + items := []string{ + base + " - 请协助确认", + base + " - 需要回复", + base + " - 进展同步", + } + if context != "" { + runes := []rune(context) + if len(runes) > 12 { + context = string(runes[:12]) + } + items = append(items, base+" - "+context) + } else { + items = append(items, base+" - 待处理提醒") + } + return items +} + +func repeatString(value string, count int) []string { + items := make([]string, 0, count) + for index := 0; index < count; index++ { + items = append(items, value) + } + return items +} + +func formatMeetingActionLine(actions []gin.H, index int) string { + if index < 0 || index >= len(actions) { + return fmt.Sprintf("%d. 待补充行动项", index+1) + } + item := actions[index] + return fmt.Sprintf("%d. %s - %s(截止:%s)", index+1, asString(item["owner"]), asString(item["task"]), asString(item["due"])) +} + +func splitFocusKeywords(value string) []string { + parts := regexp.MustCompile(`[,,、/;;]+`).Split(strings.TrimSpace(value), -1) + out := make([]string, 0, len(parts)) + seen := make(map[string]struct{}, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if _, ok := seen[part]; ok { + continue + } + seen[part] = struct{}{} + out = append(out, part) + } + return out +} + +func buildContractRedlineItems(points, focusKeywords []string) []gin.H { + items := make([]gin.H, 0, len(focusKeywords)+3) + for _, keyword := range focusKeywords { + items = append(items, gin.H{ + "clause": keyword + "条款", + "risk": fmt.Sprintf("%s相关约定需要进一步核对责任边界和执行条件。", keyword), + "suggestion": fmt.Sprintf("建议对%s条款补齐触发条件、流程口径和我方保护性表述。", keyword), + }) + } + items = append(items, + gin.H{"clause": "付款条款", "risk": firstNonEmpty(pointAt(points, 1), "付款节点、验收条件或回款安排不够清楚。"), "suggestion": "建议补齐付款条件、验收口径与逾期责任,并明确发票与付款前置条件。"}, + gin.H{"clause": "违约责任", "risk": firstNonEmpty(pointAt(points, 2), "违约责任和赔偿边界可能偏向单方。"), "suggestion": "建议增加责任上限、免责情形与纠正期限,避免无限责任或单方失衡。"}, + gin.H{"clause": "终止与争议", "risk": firstNonEmpty(pointAt(points, 3), "终止条件或争议解决安排仍不够友好。"), "suggestion": "建议明确解除条件、提前通知期、争议解决地及继续履行安排。"}, + ) + out := make([]gin.H, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + clause := asString(item["clause"]) + if clause == "" { + continue + } + if _, ok := seen[clause]; ok { + continue + } + seen[clause] = struct{}{} + out = append(out, item) + if len(out) >= 5 { + break + } + } + return out +} + +func buildOfficeMarkdownOutline(title string, sections []gin.H) string { + lines := []string{"# " + title, ""} + for _, section := range sections { + lines = append(lines, "## "+asString(section["title"])) + for _, bullet := range anyToStrings(section["bullets"]) { + lines = append(lines, "- "+bullet) + } + lines = append(lines, "") + } + return strings.TrimSpace(strings.Join(lines, "\n")) +} + +func buildMindMapSVG(rootTopic string, branches []string) string { + width := 1080 + laneHeight := 120 + height := maxInt(320, len(branches)*laneHeight+80) + leftX, rootX, rightX := 180, 540, 900 + rootY := height / 2 + nodes := make([]string, 0, len(branches)) + for index, branch := range branches { + y := 80 + index*laneHeight + isLeft := index%2 == 0 + x := rightX + lineStartX := rootX + 120 + lineEndX := x - 90 + if isLeft { + x = leftX + lineStartX = rootX - 120 + lineEndX = x + 90 + } + nodes = append(nodes, + fmt.Sprintf(``, lineStartX, rootY, lineEndX, y), + fmt.Sprintf(``, x-90, y-28), + fmt.Sprintf(`%s`, x, y+6, escapeSVGText(branch)), + ) + } + return strings.TrimSpace(fmt.Sprintf(` + + + + %s + %s +`, width, height, width, height, rootX-130, rootY-36, rootX, rootY+8, escapeSVGText(rootTopic), strings.Join(nodes, "\n "))) +} + +func stripMarkdownText(text string) string { + text = strings.ReplaceAll(text, "\r", "\n") + blockCode := regexp.MustCompile("```[\\s\\S]*?```") + text = blockCode.ReplaceAllStringFunc(text, func(value string) string { + return strings.TrimSpace(strings.ReplaceAll(value, "```", "")) + }) + replacers := []struct { + pattern string + value string + }{ + {"`([^`]+)`", "$1"}, + {"^#{1,6}\\s+", ""}, + {"^\\s*[-*+]\\s+", ""}, + {"^\\s*\\d+[.)]\\s+", ""}, + {"\\*\\*(.*?)\\*\\*", "$1"}, + {"\\*(.*?)\\*", "$1"}, + {"!\\[[^\\]]*\\]\\([^)]*\\)", ""}, + {"\\[([^\\]]+)\\]\\(([^)]*)\\)", "$1"}, + } + for _, item := range replacers { + text = regexp.MustCompile(item.pattern).ReplaceAllString(text, item.value) + } + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + return strings.TrimSpace(text) +} + +func splitMeaningfulLines(text string) []string { + items := strings.Split(strings.ReplaceAll(strings.TrimSpace(text), "\r", "\n"), "\n") + lines := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + lines = append(lines, item) + } + } + return lines +} + +func officeSlug(text, fallback string) string { + text = strings.ToLower(strings.TrimSpace(text)) + text = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(text, "-") + text = strings.Trim(text, "-") + if text == "" { + return fallback + } + return text +} + +func sliceStrings(items []string, start, end int) []string { + if start < 0 { + start = 0 + } + if end > len(items) { + end = len(items) + } + if start >= end { + return []string{} + } + return items[start:end] +} + +func pointAt(items []string, index int) string { + if index < 0 || index >= len(items) { + return "" + } + return strings.TrimSpace(items[index]) +} + +func anyToStrings(value any) []string { + switch items := value.(type) { + case []string: + return items + case []any: + out := make([]string, 0, len(items)) + for _, item := range items { + text := asString(item) + if text != "" { + out = append(out, text) + } + } + return out + default: + return nil + } +} + +func asString(value any) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + default: + text := strings.TrimSpace(fmt.Sprint(value)) + if text == "" { + return "" + } + return text + } +} + +func toArtifactMaps(value any) []gin.H { + items, ok := value.([]gin.H) + if ok { + return items + } + raw, ok := value.([]any) + if !ok { + return nil + } + out := make([]gin.H, 0, len(raw)) + for _, item := range raw { + if mapped, ok := item.(map[string]any); ok { + out = append(out, gin.H(mapped)) + } + } + return out +} + +func appendUploadItem(value any, item gin.H) []any { + list, ok := value.([]any) + if !ok { + list = []any{} + } + return append(list, item) +} + +func mustMarshalJSON(value any) string { + data, _ := json.Marshal(value) + return string(data) +} + +func parseUintFromForm(value string) (uint, bool) { + value = strings.TrimSpace(value) + if value == "" { + return 0, false + } + var parsed uint64 + if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil { + return 0, false + } + return uint(parsed), true +} + +func looksLikeImage(name string) bool { + ext := strings.ToLower(filepath.Ext(strings.TrimSpace(name))) + switch ext { + case ".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff": + return true + default: + return false + } +} + +func runPaddleOCR(imagePath string) (*paddleOCRPayload, error) { + scriptPath, err := resolvePaddleOCRScriptPath() + if err != nil { + return nil, fmt.Errorf("未找到 PaddleOCR 脚本:%w", err) + } + cmd := exec.Command("python3", scriptPath, imagePath) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("PaddleOCR 执行失败:%s", firstNonEmpty(strings.TrimSpace(stderr.String()), err.Error())) + } + payload := &paddleOCRPayload{} + if err := json.Unmarshal(stdout.Bytes(), payload); err != nil { + return nil, fmt.Errorf("PaddleOCR 返回结果解析失败") + } + if payload.Error != "" { + return nil, fmt.Errorf("PaddleOCR 返回错误:%s", payload.Error) + } + return payload, nil +} + +func resolvePaddleOCRScriptPath() (string, error) { + candidates := []string{ + filepath.Join("scripts", "paddle_ocr_extract.py"), + filepath.Join("backend-go", "scripts", "paddle_ocr_extract.py"), + } + for _, candidate := range candidates { + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + return "", os.ErrNotExist +} + +func escapeSVGText(value string) string { + replacer := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """) + return replacer.Replace(value) +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/eai_agentplatform/backend-go/internal/api/official_account_delivery.go b/eai_agentplatform/backend-go/internal/api/official_account_delivery.go index 63655fa..9745b98 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_delivery.go +++ b/eai_agentplatform/backend-go/internal/api/official_account_delivery.go @@ -1,618 +1,618 @@ package api import ( - "bytes" - "encoding/json" - "fmt" - "html" - "io" - "net/http" - "net/url" - "strings" - "time" + "bytes" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/url" + "strings" + "time" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" - "eai_agentplatform/backend/internal/ai" - "eai_agentplatform/backend/internal/config" - "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/web" + "eai_agentplatform/backend/internal/ai" + "eai_agentplatform/backend/internal/config" + "eai_agentplatform/backend/internal/middleware" + "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/web" ) type officialAccountSection struct { - Title string - Content []string + Title string + Content []string } func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - prompts := buildOfficialAccountImagePrompts(workflow) - contentWithPrompts := buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts) - workflow.Shared.ContentWithPrompts = contentWithPrompts - workflow.Shared.ImagePrompts = prompts + prompts := buildOfficialAccountImagePrompts(workflow) + contentWithPrompts := buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts) + workflow.Shared.ContentWithPrompts = contentWithPrompts + workflow.Shared.ImagePrompts = prompts - output := map[string]any{ - "content_with_prompts": contentWithPrompts, - "image_prompts": prompts, - "count": len(prompts), - } - return officialAccountStepExecution{ - Summary: fmt.Sprintf("已生成 %d 条配图提示词。", len(prompts)), - TaskStatus: workerTaskStatusDraft, - ArtifactTitle: "公众号配图提示词", - ArtifactType: "image_prompts", - ArtifactStatus: workerArtifactStatusDraft, - ArtifactContent: contentWithPrompts, - Output: output, - Logs: []string{ - fmt.Sprintf("%s 已基于正文提炼配图提示词。", now.Format("15:04:05")), - fmt.Sprintf("%s 已写回带提示词标记的正文稿。", now.Format("15:04:05")), - }, - } + output := map[string]any{ + "content_with_prompts": contentWithPrompts, + "image_prompts": prompts, + "count": len(prompts), + } + return officialAccountStepExecution{ + Summary: fmt.Sprintf("已生成 %d 条配图提示词。", len(prompts)), + TaskStatus: workerTaskStatusDraft, + ArtifactTitle: "公众号配图提示词", + ArtifactType: "image_prompts", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: contentWithPrompts, + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已基于正文提炼配图提示词。", now.Format("15:04:05")), + fmt.Sprintf("%s 已写回带提示词标记的正文稿。", now.Format("15:04:05")), + }, + } } func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { - prompts := workflow.Shared.ImagePrompts - if len(prompts) == 0 { - prompts = buildOfficialAccountImagePrompts(workflow) - workflow.Shared.ImagePrompts = prompts - workflow.Shared.ContentWithPrompts = buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts) - } - route, refs, appErr := generateOfficialAccountImages(article.TaskID, workflow, prompts, user) - if appErr != nil { - return officialAccountStepExecution{}, appErr - } - workflow.Shared.ImageRefs = refs + prompts := workflow.Shared.ImagePrompts + if len(prompts) == 0 { + prompts = buildOfficialAccountImagePrompts(workflow) + workflow.Shared.ImagePrompts = prompts + workflow.Shared.ContentWithPrompts = buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts) + } + aiRoute, refs, appErr := generateOfficialAccountImages(article.TaskID, workflow, prompts, user) + if appErr != nil { + return officialAccountStepExecution{}, appErr + } + workflow.Shared.ImageRefs = refs - output := map[string]any{ - "image_refs": refs, - "count": len(refs), - "route_id": route.RouteID, - "route_model": route.Model, - "route_provider": route.Provider, - } - return officialAccountStepExecution{ - Summary: fmt.Sprintf("已生成 %d 张图片结果。", len(refs)), - TaskStatus: workerTaskStatusDraft, - ArtifactTitle: "公众号配图结果", - ArtifactType: "images", - ArtifactStatus: workerArtifactStatusDraft, - ArtifactContent: buildOfficialAccountImageArtifact(refs), - Output: output, - Logs: []string{ - fmt.Sprintf("%s 已通过 %s 生成图片结果。", now.Format("15:04:05"), route.Model), - fmt.Sprintf("%s 图片已可直接用于预览与导出。", now.Format("15:04:05")), - }, - }, nil + output := map[string]any{ + "image_refs": refs, + "count": len(refs), + "ai_route_id": aiRoute.RouteID, + "ai_route_model": aiRoute.Model, + "ai_route_provider": aiRoute.Provider, + } + return officialAccountStepExecution{ + Summary: fmt.Sprintf("已生成 %d 张图片结果。", len(refs)), + TaskStatus: workerTaskStatusDraft, + ArtifactTitle: "公众号配图结果", + ArtifactType: "images", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: buildOfficialAccountImageArtifact(refs), + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已通过 %s 生成图片结果。", now.Format("15:04:05"), aiRoute.Model), + fmt.Sprintf("%s 图片已可直接用于预览与导出。", now.Format("15:04:05")), + }, + }, nil } func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - previewMarkdown := buildOfficialAccountPreviewMarkdown(workflow) - previewHTML := renderOfficialAccountHTML(previewMarkdown) - workflow.Shared.PreviewMarkdown = previewMarkdown - workflow.Shared.PreviewHTML = previewHTML + previewMarkdown := buildOfficialAccountPreviewMarkdown(workflow) + previewHTML := renderOfficialAccountHTML(previewMarkdown) + workflow.Shared.PreviewMarkdown = previewMarkdown + workflow.Shared.PreviewHTML = previewHTML - output := map[string]any{ - "preview_markdown": previewMarkdown, - "preview_html": previewHTML, - "image_count": len(workflow.Shared.ImageRefs), - } - return officialAccountStepExecution{ - Summary: "预览稿已生成,可直接检查排版并导出。", - TaskStatus: workerTaskStatusDraft, - ArtifactTitle: "公众号预览稿", - ArtifactType: "preview", - ArtifactStatus: workerArtifactStatusDraft, - ArtifactContent: previewMarkdown, - Output: output, - Logs: []string{ - fmt.Sprintf("%s 已整合正文与配图生成预览稿。", now.Format("15:04:05")), - fmt.Sprintf("%s 已同步产出预览 HTML。", now.Format("15:04:05")), - }, - } + output := map[string]any{ + "preview_markdown": previewMarkdown, + "preview_html": previewHTML, + "image_count": len(workflow.Shared.ImageRefs), + } + return officialAccountStepExecution{ + Summary: "预览稿已生成,可直接检查排版并导出。", + TaskStatus: workerTaskStatusDraft, + ArtifactTitle: "公众号预览稿", + ArtifactType: "preview", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: previewMarkdown, + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已整合正文与配图生成预览稿。", now.Format("15:04:05")), + fmt.Sprintf("%s 已同步产出预览 HTML。", now.Format("15:04:05")), + }, + } } func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - pages := buildOfficialAccountPages(workflow) - pageMarkdown := buildOfficialAccountPageMarkdown(pages) - workflow.Shared.Pages = pages - workflow.Shared.PageMarkdown = pageMarkdown - if workflow.Shared.PreviewMarkdown == "" { - workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow) - } - if workflow.Shared.PreviewHTML == "" { - workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown) - } + pages := buildOfficialAccountPages(workflow) + pageMarkdown := buildOfficialAccountPageMarkdown(pages) + workflow.Shared.Pages = pages + workflow.Shared.PageMarkdown = pageMarkdown + if workflow.Shared.PreviewMarkdown == "" { + workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow) + } + if workflow.Shared.PreviewHTML == "" { + workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown) + } - output := map[string]any{ - "page_markdown": pageMarkdown, - "pages": pages, - "count": len(pages), - } - return officialAccountStepExecution{ - Summary: fmt.Sprintf("分页 MD 已生成,共 %d 页。", len(pages)), - TaskStatus: workerTaskStatusDraft, - ArtifactTitle: "公众号分页 MD", - ArtifactType: "page_markdown", - ArtifactStatus: workerArtifactStatusDraft, - ArtifactContent: pageMarkdown, - Output: output, - Logs: []string{ - fmt.Sprintf("%s 已按段落结构生成分页 MD。", now.Format("15:04:05")), - fmt.Sprintf("%s 分页结果可直接导出。", now.Format("15:04:05")), - }, - } + output := map[string]any{ + "page_markdown": pageMarkdown, + "pages": pages, + "count": len(pages), + } + return officialAccountStepExecution{ + Summary: fmt.Sprintf("分页 MD 已生成,共 %d 页。", len(pages)), + TaskStatus: workerTaskStatusDraft, + ArtifactTitle: "公众号分页 MD", + ArtifactType: "page_markdown", + ArtifactStatus: workerArtifactStatusDraft, + ArtifactContent: pageMarkdown, + Output: output, + Logs: []string{ + fmt.Sprintf("%s 已按段落结构生成分页 MD。", now.Format("15:04:05")), + fmt.Sprintf("%s 分页结果可直接导出。", now.Format("15:04:05")), + }, + } } func buildOfficialAccountImagePrompts(workflow *officialAccountWorkflowState) []officialAccountImagePrompt { - title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章") - sections := extractOfficialAccountSections(workflow.Shared.Content) - maxContentImages := normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages) - prompts := []officialAccountImagePrompt{ - { - Key: "cover", - Section: "封面配图", - Prompt: buildOfficialAccountVisualPrompt(workflow, title, "公众号封面主视觉"), - }, - } - for idx, item := range sections { - if maxContentImages > 0 && idx >= maxContentImages { - break - } - prompts = append(prompts, officialAccountImagePrompt{ - Key: fmt.Sprintf("section_%02d", idx+1), - Section: item.Title, - Prompt: buildOfficialAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")), - }) - } - return prompts + title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章") + sections := extractOfficialAccountSections(workflow.Shared.Content) + maxContentImages := normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages) + prompts := []officialAccountImagePrompt{ + { + Key: "cover", + Section: "封面配图", + Prompt: buildOfficialAccountVisualPrompt(workflow, title, "公众号封面主视觉"), + }, + } + for idx, item := range sections { + if maxContentImages > 0 && idx >= maxContentImages { + break + } + prompts = append(prompts, officialAccountImagePrompt{ + Key: fmt.Sprintf("section_%02d", idx+1), + Section: item.Title, + Prompt: buildOfficialAccountVisualPrompt(workflow, item.Title, strings.Join(item.Content, " ")), + }) + } + return prompts } func buildOfficialAccountContentWithPrompts(content string, prompts []officialAccountImagePrompt) string { - content = strings.TrimSpace(content) - if content == "" { - return "" - } - blocks := []string{content, "", "## 配图提示词"} - for idx, item := range prompts { - blocks = append(blocks, fmt.Sprintf("- IMAGE_PROMPT_%02d [%s] %s", idx+1, firstNonEmpty(item.Section, "段落配图"), item.Prompt)) - } - return strings.Join(blocks, "\n") + content = strings.TrimSpace(content) + if content == "" { + return "" + } + blocks := []string{content, "", "## 配图提示词"} + for idx, item := range prompts { + blocks = append(blocks, fmt.Sprintf("- IMAGE_PROMPT_%02d [%s] %s", idx+1, firstNonEmpty(item.Section, "段落配图"), item.Prompt)) + } + return strings.Join(blocks, "\n") } func buildOfficialAccountVisualPrompt(workflow *officialAccountWorkflowState, sectionTitle, context string) string { - keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题") - tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂") - domain := officialAccountBusinessDomainLabel(workflow.Form.BusinessDomain) - return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark", domain, firstNonEmpty(sectionTitle, keyword), firstNonEmpty(context, tone)) + keyword := firstNonEmpty(workflow.Form.Keyword, "业务主题") + tone := firstNonEmpty(workflow.Form.Tone, "专业但好懂") + domain := officialAccountBusinessDomainLabel(workflow.Form.BusinessDomain) + return fmt.Sprintf("realistic editorial illustration, Chinese business article cover, %s, focus on %s, %s, clean composition, natural lighting, brand-safe, no text, no watermark", domain, firstNonEmpty(sectionTitle, keyword), firstNonEmpty(context, tone)) } func buildOfficialAccountImageArtifact(refs []officialAccountImageRef) string { - lines := []string{"# 图片结果", ""} - for idx, item := range refs { - lines = append(lines, - fmt.Sprintf("%d. %s", idx+1, firstNonEmpty(item.Section, item.Key)), - fmt.Sprintf("- 提示词:%s", item.Prompt), - fmt.Sprintf("- 图片地址:%s", item.URL), - fmt.Sprintf("- 生成模型:%s / %s", firstNonEmpty(item.RouteProvider, "unknown"), firstNonEmpty(item.RouteModel, "unknown")), - "", - ) - } - return strings.Join(lines, "\n") + lines := []string{"# 图片结果", ""} + for idx, item := range refs { + lines = append(lines, + fmt.Sprintf("%d. %s", idx+1, firstNonEmpty(item.Section, item.Key)), + fmt.Sprintf("- 提示词:%s", item.Prompt), + fmt.Sprintf("- 图片地址:%s", item.URL), + fmt.Sprintf("- 生成模型:%s / %s", firstNonEmpty(item.AIRouteProvider, "unknown"), firstNonEmpty(item.AIRouteModel, "unknown")), + "", + ) + } + return strings.Join(lines, "\n") } func generateOfficialAccountImages(taskID uint, workflow *officialAccountWorkflowState, prompts []officialAccountImagePrompt, user *model.User) (*config.RouteConfig, []officialAccountImageRef, *web.AppError) { - route, err := config.GetRoute("image_gen") - if err != nil { - return nil, nil, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置") - } - if route.Category != "image" || strings.TrimSpace(route.FullURL) == "" || strings.TrimSpace(route.Model) == "" { - return nil, nil, web.NewLLMNotConfigured("图片生成路由配置不完整") - } + aiRoute, err := config.GetRoute("image_gen") + if err != nil { + return nil, nil, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置") + } + if aiRoute.Category != "image" || strings.TrimSpace(aiRoute.FullURL) == "" || strings.TrimSpace(aiRoute.Model) == "" { + return nil, nil, web.NewLLMNotConfigured("图片生成路由配置不完整") + } - client := &http.Client{Timeout: 240 * time.Second} - refs := make([]officialAccountImageRef, 0, len(prompts)) - for idx, item := range prompts { - ref, appErr := generateOfficialAccountImageRefWithRoute(client, route, taskID, item, idx, user) - if appErr != nil { - return nil, nil, appErr - } - refs = append(refs, ref) - } - return route, refs, nil + client := &http.Client{Timeout: 240 * time.Second} + refs := make([]officialAccountImageRef, 0, len(prompts)) + for idx, item := range prompts { + ref, appErr := generateOfficialAccountImageRefWithRoute(client, aiRoute, taskID, item, idx, user) + if appErr != nil { + return nil, nil, appErr + } + refs = append(refs, ref) + } + return aiRoute, refs, nil } func generateOfficialAccountImageRef(taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (*config.RouteConfig, officialAccountImageRef, *web.AppError) { - route, err := config.GetRoute("image_gen") - if err != nil { - return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置") - } - if route.Category != "image" || strings.TrimSpace(route.FullURL) == "" || strings.TrimSpace(route.Model) == "" { - return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整") - } - client := &http.Client{Timeout: 240 * time.Second} - ref, appErr := generateOfficialAccountImageRefWithRoute(client, route, taskID, prompt, index, user) - if appErr != nil { - return nil, officialAccountImageRef{}, appErr - } - return route, ref, nil + aiRoute, err := config.GetRoute("image_gen") + if err != nil { + return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("未找到可用的图片生成路由,请先检查 ai_config.json 中的 image_gen 配置") + } + if aiRoute.Category != "image" || strings.TrimSpace(aiRoute.FullURL) == "" || strings.TrimSpace(aiRoute.Model) == "" { + return nil, officialAccountImageRef{}, web.NewLLMNotConfigured("图片生成路由配置不完整") + } + client := &http.Client{Timeout: 240 * time.Second} + ref, appErr := generateOfficialAccountImageRefWithRoute(client, aiRoute, taskID, prompt, index, user) + if appErr != nil { + return nil, officialAccountImageRef{}, appErr + } + return aiRoute, ref, nil } -func generateOfficialAccountImageRefWithRoute(client *http.Client, route *config.RouteConfig, taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (officialAccountImageRef, *web.AppError) { - imageSize := officialAccountImageSlotSize(index) - requestSize := officialAccountImageRequestSize(imageSize) - started := time.Now() - imageURL, reqErr := requestOfficialAccountImage(client, route, prompt.Prompt, requestSize) - latencyMs := int(time.Since(started) / time.Millisecond) - ai.LogCall(ai.LogEntry{ - UserID: user.ID, - Capability: ai.CapabilityImageGen, - Provider: route.Provider, - RouteID: route.RouteID, - Model: route.Model, - Success: reqErr == nil, - ErrorMessage: errorMessage(reqErr), - LatencyMs: latencyMs, - }) - if reqErr != nil { - return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error())) - } - if strings.HasPrefix(strings.TrimSpace(imageURL), "data:image/") { - imageURL, reqErr = persistOfficialAccountDataURL(taskID, prompt.Key, imageURL) - if reqErr != nil { - return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error())) - } - } - return officialAccountImageRef{ - Key: prompt.Key, - Section: prompt.Section, - Prompt: prompt.Prompt, - URL: imageURL, - ImageSize: imageSize, - RouteID: route.RouteID, - RouteModel: route.Model, - RouteProvider: route.Provider, - }, nil +func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *config.RouteConfig, taskID uint, prompt officialAccountImagePrompt, index int, user *model.User) (officialAccountImageRef, *web.AppError) { + imageSize := officialAccountImageSlotSize(index) + requestSize := officialAccountImageRequestSize(imageSize) + started := time.Now() + imageURL, reqErr := requestOfficialAccountImage(client, aiRoute, prompt.Prompt, requestSize) + latencyMs := int(time.Since(started) / time.Millisecond) + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityImageGen, + Provider: aiRoute.Provider, + AIRouteID: aiRoute.RouteID, + Model: aiRoute.Model, + Success: reqErr == nil, + ErrorMessage: errorMessage(reqErr), + LatencyMs: latencyMs, + }) + if reqErr != nil { + return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片生成失败:%s", reqErr.Error())) + } + if strings.HasPrefix(strings.TrimSpace(imageURL), "data:image/") { + imageURL, reqErr = persistOfficialAccountDataURL(taskID, prompt.Key, imageURL) + if reqErr != nil { + return officialAccountImageRef{}, web.NewLLMError(fmt.Sprintf("图片保存失败:%s", reqErr.Error())) + } + } + return officialAccountImageRef{ + Key: prompt.Key, + Section: prompt.Section, + Prompt: prompt.Prompt, + URL: imageURL, + ImageSize: imageSize, + AIRouteID: aiRoute.RouteID, + AIRouteModel: aiRoute.Model, + AIRouteProvider: aiRoute.Provider, + }, nil } -func requestOfficialAccountImage(client *http.Client, route *config.RouteConfig, prompt, size string) (string, error) { - payload := map[string]any{ - "model": route.Model, - "prompt": strings.TrimSpace(prompt), - "n": 1, - "size": size, - } - imageURL, err := postOfficialAccountImageRequest(client, route, payload) - if err == nil { - return imageURL, nil - } +func requestOfficialAccountImage(client *http.Client, aiRoute *config.RouteConfig, prompt, size string) (string, error) { + payload := map[string]any{ + "model": aiRoute.Model, + "prompt": strings.TrimSpace(prompt), + "n": 1, + "size": size, + } + imageURL, err := postOfficialAccountImageRequest(client, aiRoute, payload) + if err == nil { + return imageURL, nil + } - payload["response_format"] = "b64_json" - return postOfficialAccountImageRequest(client, route, payload) + payload["response_format"] = "b64_json" + return postOfficialAccountImageRequest(client, aiRoute, payload) } -func postOfficialAccountImageRequest(client *http.Client, route *config.RouteConfig, payload map[string]any) (string, error) { - body, err := json.Marshal(payload) - if err != nil { - return "", err - } - req, err := http.NewRequest(http.MethodPost, route.FullURL, bytes.NewReader(body)) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - if strings.TrimSpace(route.APIKey) != "" { - req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(route.APIKey)) - } +func postOfficialAccountImageRequest(client *http.Client, aiRoute *config.RouteConfig, payload map[string]any) (string, error) { + body, err := json.Marshal(payload) + if err != nil { + return "", err + } + req, err := http.NewRequest(http.MethodPost, aiRoute.FullURL, bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + if strings.TrimSpace(aiRoute.APIKey) != "" { + req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(aiRoute.APIKey)) + } - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() - raw, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateOfficialAccountErrorBody(string(raw))) - } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("图片接口返回 %d:%s", resp.StatusCode, truncateOfficialAccountErrorBody(string(raw))) + } - var out struct { - Data []struct { - URL string `json:"url"` - B64JSON string `json:"b64_json"` - } `json:"data"` - } - if err := json.Unmarshal(raw, &out); err != nil { - return "", fmt.Errorf("图片结果解析失败:%w", err) - } - if len(out.Data) == 0 { - return "", fmt.Errorf("图片接口未返回 data") - } - first := out.Data[0] - if strings.TrimSpace(first.URL) != "" { - return strings.TrimSpace(first.URL), nil - } - if strings.TrimSpace(first.B64JSON) != "" { - return "data:image/png;base64," + strings.TrimSpace(first.B64JSON), nil - } - return "", fmt.Errorf("图片接口未返回 url 或 b64_json") + var out struct { + Data []struct { + URL string `json:"url"` + B64JSON string `json:"b64_json"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &out); err != nil { + return "", fmt.Errorf("图片结果解析失败:%w", err) + } + if len(out.Data) == 0 { + return "", fmt.Errorf("图片接口未返回 data") + } + first := out.Data[0] + if strings.TrimSpace(first.URL) != "" { + return strings.TrimSpace(first.URL), nil + } + if strings.TrimSpace(first.B64JSON) != "" { + return "data:image/png;base64," + strings.TrimSpace(first.B64JSON), nil + } + return "", fmt.Errorf("图片接口未返回 url 或 b64_json") } func officialAccountImageSlotSize(index int) string { - if index <= 0 { - return "landscape_16_9" - } - return "landscape_4_3" + if index <= 0 { + return "landscape_16_9" + } + return "landscape_4_3" } func officialAccountImageRequestSize(size string) string { - switch strings.TrimSpace(size) { - case "landscape_16_9": - return "1536x864" - case "landscape_4_3": - return "1536x1152" - case "portrait_16_9": - return "864x1536" - case "portrait_4_3": - return "1152x1536" - case "square_hd": - return "1536x1536" - default: - return "1024x1024" - } + switch strings.TrimSpace(size) { + case "landscape_16_9": + return "1536x864" + case "landscape_4_3": + return "1536x1152" + case "portrait_16_9": + return "864x1536" + case "portrait_4_3": + return "1152x1536" + case "square_hd": + return "1536x1536" + default: + return "1024x1024" + } } func truncateOfficialAccountErrorBody(raw string) string { - raw = strings.TrimSpace(raw) - if len(raw) <= 240 { - return raw - } - return raw[:240] + "..." + raw = strings.TrimSpace(raw) + if len(raw) <= 240 { + return raw + } + return raw[:240] + "..." } func errorMessage(err error) string { - if err == nil { - return "" - } - return err.Error() + if err == nil { + return "" + } + return err.Error() } func buildOfficialAccountPreviewMarkdown(workflow *officialAccountWorkflowState) string { - title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章") - sections := extractOfficialAccountSections(workflow.Shared.Content) - refs := workflow.Shared.ImageRefs - lines := []string{fmt.Sprintf("# %s", title), ""} - if len(refs) > 0 { - lines = append(lines, fmt.Sprintf("![封面配图](%s)", refs[0].URL), "") - } - for idx, item := range sections { - lines = append(lines, fmt.Sprintf("## %s", item.Title)) - lines = append(lines, item.Content...) - lines = append(lines, "") - if idx+1 < len(refs) { - lines = append(lines, fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL), "") - } - } - if len(sections) == 0 { - lines = append(lines, workflow.Shared.Content) - } - return strings.TrimSpace(strings.Join(lines, "\n")) + title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章") + sections := extractOfficialAccountSections(workflow.Shared.Content) + refs := workflow.Shared.ImageRefs + lines := []string{fmt.Sprintf("# %s", title), ""} + if len(refs) > 0 { + lines = append(lines, fmt.Sprintf("![封面配图](%s)", refs[0].URL), "") + } + for idx, item := range sections { + lines = append(lines, fmt.Sprintf("## %s", item.Title)) + lines = append(lines, item.Content...) + lines = append(lines, "") + if idx+1 < len(refs) { + lines = append(lines, fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL), "") + } + } + if len(sections) == 0 { + lines = append(lines, workflow.Shared.Content) + } + return strings.TrimSpace(strings.Join(lines, "\n")) } func buildOfficialAccountPages(workflow *officialAccountWorkflowState) []officialAccountPageChunk { - title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章") - sections := extractOfficialAccountSections(workflow.Shared.Content) - refs := workflow.Shared.ImageRefs - pages := make([]officialAccountPageChunk, 0, len(sections)+1) - intro := []string{fmt.Sprintf("# %s", title)} - if len(refs) > 0 { - intro = append(intro, "", fmt.Sprintf("![封面配图](%s)", refs[0].URL)) - } - pages = append(pages, officialAccountPageChunk{ - Index: 1, - Title: title, - Content: strings.Join(intro, "\n"), - }) - for idx, item := range sections { - lines := []string{fmt.Sprintf("## %s", item.Title)} - lines = append(lines, item.Content...) - if idx+1 < len(refs) { - lines = append(lines, "", fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL)) - } - pages = append(pages, officialAccountPageChunk{ - Index: idx + 2, - Title: item.Title, - Content: strings.TrimSpace(strings.Join(lines, "\n")), - }) - } - return pages + title := firstNonEmpty(workflow.Shared.SelectedTitle, workflow.Form.Keyword, "公众号文章") + sections := extractOfficialAccountSections(workflow.Shared.Content) + refs := workflow.Shared.ImageRefs + pages := make([]officialAccountPageChunk, 0, len(sections)+1) + intro := []string{fmt.Sprintf("# %s", title)} + if len(refs) > 0 { + intro = append(intro, "", fmt.Sprintf("![封面配图](%s)", refs[0].URL)) + } + pages = append(pages, officialAccountPageChunk{ + Index: 1, + Title: title, + Content: strings.Join(intro, "\n"), + }) + for idx, item := range sections { + lines := []string{fmt.Sprintf("## %s", item.Title)} + lines = append(lines, item.Content...) + if idx+1 < len(refs) { + lines = append(lines, "", fmt.Sprintf("![段落配图](%s)", refs[idx+1].URL)) + } + pages = append(pages, officialAccountPageChunk{ + Index: idx + 2, + Title: item.Title, + Content: strings.TrimSpace(strings.Join(lines, "\n")), + }) + } + return pages } func buildOfficialAccountPageMarkdown(pages []officialAccountPageChunk) string { - chunks := make([]string, 0, len(pages)) - for _, item := range pages { - chunks = append(chunks, fmt.Sprintf("\n%s", item.Index, item.Title, item.Content)) - } - return strings.Join(chunks, "\n\n---\n\n") + chunks := make([]string, 0, len(pages)) + for _, item := range pages { + chunks = append(chunks, fmt.Sprintf("\n%s", item.Index, item.Title, item.Content)) + } + return strings.Join(chunks, "\n\n---\n\n") } func extractOfficialAccountSections(content string) []officialAccountSection { - lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") - sections := make([]officialAccountSection, 0, 6) - current := officialAccountSection{} - for _, raw := range lines { - line := strings.TrimSpace(raw) - if strings.HasPrefix(line, "# ") { - continue - } - if strings.HasPrefix(line, "## ") { - if current.Title != "" || len(current.Content) > 0 { - sections = append(sections, current) - } - current = officialAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))} - continue - } - if current.Title == "" && line == "" { - continue - } - current.Content = append(current.Content, raw) - } - if current.Title != "" || len(current.Content) > 0 { - sections = append(sections, current) - } - for idx := range sections { - if sections[idx].Title == "" { - sections[idx].Title = fmt.Sprintf("内容页 %d", idx+1) - } - sections[idx].Content = trimEmptyLines(sections[idx].Content) - } - return sections + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") + sections := make([]officialAccountSection, 0, 6) + current := officialAccountSection{} + for _, raw := range lines { + line := strings.TrimSpace(raw) + if strings.HasPrefix(line, "# ") { + continue + } + if strings.HasPrefix(line, "## ") { + if current.Title != "" || len(current.Content) > 0 { + sections = append(sections, current) + } + current = officialAccountSection{Title: strings.TrimSpace(strings.TrimPrefix(line, "## "))} + continue + } + if current.Title == "" && line == "" { + continue + } + current.Content = append(current.Content, raw) + } + if current.Title != "" || len(current.Content) > 0 { + sections = append(sections, current) + } + for idx := range sections { + if sections[idx].Title == "" { + sections[idx].Title = fmt.Sprintf("内容页 %d", idx+1) + } + sections[idx].Content = trimEmptyLines(sections[idx].Content) + } + return sections } func trimEmptyLines(lines []string) []string { - start := 0 - for start < len(lines) && strings.TrimSpace(lines[start]) == "" { - start++ - } - end := len(lines) - for end > start && strings.TrimSpace(lines[end-1]) == "" { - end-- - } - return append([]string(nil), lines[start:end]...) + start := 0 + for start < len(lines) && strings.TrimSpace(lines[start]) == "" { + start++ + } + end := len(lines) + for end > start && strings.TrimSpace(lines[end-1]) == "" { + end-- + } + return append([]string(nil), lines[start:end]...) } func renderOfficialAccountHTML(markdown string) string { - var builder strings.Builder - builder.WriteString(`
`) - listOpen := false - lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n") - for _, raw := range lines { - line := strings.TrimSpace(raw) - if line == "" { - if listOpen { - builder.WriteString(``) - listOpen = false - } - continue - } - if strings.HasPrefix(line, "![") && strings.Contains(line, "](") && strings.HasSuffix(line, ")") { - if listOpen { - builder.WriteString(``) - listOpen = false - } - alt, src := parseOfficialAccountImageMarkdown(line) - builder.WriteString(fmt.Sprintf(`
%s
%s
`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(alt))) - continue - } - if strings.HasPrefix(line, "# ") { - if listOpen { - builder.WriteString(``) - listOpen = false - } - builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "# "))))) - continue - } - if strings.HasPrefix(line, "## ") { - if listOpen { - builder.WriteString(``) - listOpen = false - } - builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "## "))))) - continue - } - if strings.HasPrefix(line, "> ") { - if listOpen { - builder.WriteString(``) - listOpen = false - } - builder.WriteString(fmt.Sprintf(`
%s
`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "> "))))) - continue - } - if strings.HasPrefix(line, "- ") { - if !listOpen { - builder.WriteString(`
    `) - listOpen = true - } - builder.WriteString(fmt.Sprintf(`
  • %s
  • `, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "- "))))) - continue - } - if listOpen { - builder.WriteString(`
`) - listOpen = false - } - builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(line))) - } - if listOpen { - builder.WriteString(``) - } - builder.WriteString(`
`) - return builder.String() + var builder strings.Builder + builder.WriteString(`
`) + listOpen := false + lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n") + for _, raw := range lines { + line := strings.TrimSpace(raw) + if line == "" { + if listOpen { + builder.WriteString(``) + listOpen = false + } + continue + } + if strings.HasPrefix(line, "![") && strings.Contains(line, "](") && strings.HasSuffix(line, ")") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + alt, src := parseOfficialAccountImageMarkdown(line) + builder.WriteString(fmt.Sprintf(`
%s
%s
`, html.EscapeString(src), html.EscapeString(alt), html.EscapeString(alt))) + continue + } + if strings.HasPrefix(line, "# ") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "# "))))) + continue + } + if strings.HasPrefix(line, "## ") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "## "))))) + continue + } + if strings.HasPrefix(line, "> ") { + if listOpen { + builder.WriteString(``) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`
%s
`, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "> "))))) + continue + } + if strings.HasPrefix(line, "- ") { + if !listOpen { + builder.WriteString(`
    `) + listOpen = true + } + builder.WriteString(fmt.Sprintf(`
  • %s
  • `, html.EscapeString(strings.TrimSpace(strings.TrimPrefix(line, "- "))))) + continue + } + if listOpen { + builder.WriteString(`
`) + listOpen = false + } + builder.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(line))) + } + if listOpen { + builder.WriteString(``) + } + builder.WriteString(`
`) + return builder.String() } func parseOfficialAccountImageMarkdown(line string) (string, string) { - startAlt := strings.Index(line, "![") - mid := strings.Index(line, "](") - end := strings.LastIndex(line, ")") - if startAlt < 0 || mid < 0 || end < 0 || end <= mid+2 { - return "配图", "" - } - alt := line[startAlt+2 : mid] - src := line[mid+2 : end] - return firstNonEmpty(strings.TrimSpace(alt), "配图"), strings.TrimSpace(src) + startAlt := strings.Index(line, "![") + mid := strings.Index(line, "](") + end := strings.LastIndex(line, ")") + if startAlt < 0 || mid < 0 || end < 0 || end <= mid+2 { + return "配图", "" + } + alt := line[startAlt+2 : mid] + src := line[mid+2 : end] + return firstNonEmpty(strings.TrimSpace(alt), "配图"), strings.TrimSpace(src) } func ExportOfficialAccountDocument(c *gin.Context) { - user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) - if !ok { - return - } - if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) - return - } + user := middleware.CurrentUser(c) + task, ok := loadAccessibleWorkerTask(c, user) + if !ok { + return + } + if task.SpecialistKey != officialAccountSpecialistKey { + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) + return + } - workflow := parseOfficialAccountWorkflow(task.ContextJSON) - article, err := ensureOfficialAccountArticle(task, workflow) - if err != nil { - web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) - return - } - syncWorkflowFromOfficialAccountArticle(&workflow, article) + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, workflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&workflow, article) - format := strings.TrimSpace(strings.ToLower(c.DefaultQuery("format", "preview_md"))) - filename := "official-account-export.md" - contentType := "text/markdown; charset=utf-8" - body := workflow.Shared.PreviewMarkdown + format := strings.TrimSpace(strings.ToLower(c.DefaultQuery("format", "preview_md"))) + filename := "official-account-export.md" + contentType := "text/markdown; charset=utf-8" + body := workflow.Shared.PreviewMarkdown - switch format { - case "preview_md": - if strings.TrimSpace(body) == "" { - body = buildOfficialAccountPreviewMarkdown(&workflow) - } - filename = "official-account-preview.md" - case "preview_html": - contentType = "text/html; charset=utf-8" - body = workflow.Shared.PreviewHTML - if strings.TrimSpace(body) == "" { - body = renderOfficialAccountHTML(buildOfficialAccountPreviewMarkdown(&workflow)) - } - filename = "official-account-preview.html" - case "pagemd": - body = workflow.Shared.PageMarkdown - if strings.TrimSpace(body) == "" { - body = buildOfficialAccountPageMarkdown(buildOfficialAccountPages(&workflow)) - } - filename = "official-account-pagemd.md" - default: - web.Fail(c, web.NewBadRequest("导出格式不支持")) - return - } + switch format { + case "preview_md": + if strings.TrimSpace(body) == "" { + body = buildOfficialAccountPreviewMarkdown(&workflow) + } + filename = "official-account-preview.md" + case "preview_html": + contentType = "text/html; charset=utf-8" + body = workflow.Shared.PreviewHTML + if strings.TrimSpace(body) == "" { + body = renderOfficialAccountHTML(buildOfficialAccountPreviewMarkdown(&workflow)) + } + filename = "official-account-preview.html" + case "pagemd": + body = workflow.Shared.PageMarkdown + if strings.TrimSpace(body) == "" { + body = buildOfficialAccountPageMarkdown(buildOfficialAccountPages(&workflow)) + } + filename = "official-account-pagemd.md" + default: + web.Fail(c, web.NewBadRequest("导出格式不支持")) + return + } - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename))) - c.Data(200, contentType, []byte(body)) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(filename))) + c.Data(200, contentType, []byte(body)) } diff --git a/eai_agentplatform/backend-go/internal/api/official_account_workflow.go b/eai_agentplatform/backend-go/internal/api/official_account_workflow.go index 8d84ddf..92fe7de 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_workflow.go +++ b/eai_agentplatform/backend-go/internal/api/official_account_workflow.go @@ -20,15 +20,15 @@ import ( const ( officialAccountSpecialistKey = "wechat-official-account" - officialAccountStepKeyConfig = "task_configuration" - officialAccountStepKeyTopic = "topic_recommendation" - officialAccountStepKeyTitle = "title_generation" - officialAccountStepKeyOutline = "outline_generation" - officialAccountStepKeyContent = "content_creation" - officialAccountStepKeyImagePrompts = "image_prompt_generation" - officialAccountStepKeyImages = "image_generation" - officialAccountStepKeyPreview = "preview_export" - officialAccountStepKeyPageMD = "page_markdown" + officialAccountStepKeyConfig = "task_configuration" + officialAccountStepKeyTopic = "topic_recommendation" + officialAccountStepKeyTitle = "title_generation" + officialAccountStepKeyOutline = "outline_generation" + officialAccountStepKeyContent = "content_creation" + officialAccountStepKeyImagePrompts = "image_prompt_generation" + officialAccountStepKeyImages = "image_generation" + officialAccountStepKeyPreview = "preview_export" + officialAccountStepKeyPageMD = "page_markdown" officialAccountStepStatusPending = "pending" officialAccountStepStatusRunning = "running" @@ -37,38 +37,38 @@ const ( ) type officialAccountTaskCreateReq struct { - BusinessDomain string `json:"business_domain"` - Keyword string `json:"keyword"` - Audience string `json:"audience"` - Goal string `json:"goal"` - Tone string `json:"tone"` - Requirements string `json:"requirements"` - OutlineStyle string `json:"outline_style"` - OutlineWords int `json:"outline_words"` - ContentTargetWords int `json:"content_target_words"` - MaxContentImages int `json:"max_content_images"` + BusinessDomain string `json:"business_domain"` + Keyword string `json:"keyword"` + Audience string `json:"audience"` + Goal string `json:"goal"` + Tone string `json:"tone"` + Requirements string `json:"requirements"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` + MaxContentImages int `json:"max_content_images"` } type officialAccountStepReq struct { - Topic string `json:"topic"` - Title string `json:"title"` - Outline string `json:"outline"` - Content string `json:"content"` - ContentWithPrompts string `json:"content_with_prompts"` - Requirements string `json:"requirements"` + Topic string `json:"topic"` + Title string `json:"title"` + Outline string `json:"outline"` + Content string `json:"content"` + ContentWithPrompts string `json:"content_with_prompts"` + Requirements string `json:"requirements"` } type officialAccountTaskUpdateReq struct { - BusinessDomain string `json:"business_domain"` - Keyword string `json:"keyword"` - Audience string `json:"audience"` - Goal string `json:"goal"` - Tone string `json:"tone"` - Requirements string `json:"requirements"` - OutlineStyle string `json:"outline_style"` - OutlineWords int `json:"outline_words"` - ContentTargetWords int `json:"content_target_words"` - MaxContentImages int `json:"max_content_images"` + BusinessDomain string `json:"business_domain"` + Keyword string `json:"keyword"` + Audience string `json:"audience"` + Goal string `json:"goal"` + Tone string `json:"tone"` + Requirements string `json:"requirements"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` + MaxContentImages int `json:"max_content_images"` } type officialAccountWorkflowState struct { @@ -80,32 +80,32 @@ type officialAccountWorkflowState struct { } type officialAccountForm struct { - BusinessDomain string `json:"business_domain"` - Keyword string `json:"keyword"` - Audience string `json:"audience"` - Goal string `json:"goal"` - Tone string `json:"tone"` - Requirements string `json:"requirements"` - OutlineStyle string `json:"outline_style"` - OutlineWords int `json:"outline_words"` - ContentTargetWords int `json:"content_target_words"` - MaxContentImages int `json:"max_content_images"` + BusinessDomain string `json:"business_domain"` + Keyword string `json:"keyword"` + Audience string `json:"audience"` + Goal string `json:"goal"` + Tone string `json:"tone"` + Requirements string `json:"requirements"` + OutlineStyle string `json:"outline_style"` + OutlineWords int `json:"outline_words"` + ContentTargetWords int `json:"content_target_words"` + MaxContentImages int `json:"max_content_images"` } type officialAccountSharedState struct { - TopicCandidates []officialAccountTopicCandidate `json:"topic_candidates"` - SelectedTopic string `json:"selected_topic"` - TitleCandidates []string `json:"title_candidates"` - SelectedTitle string `json:"selected_title"` - Outline string `json:"outline"` - Content string `json:"content"` - ContentWithPrompts string `json:"content_with_prompts"` - ImagePrompts []officialAccountImagePrompt `json:"image_prompts"` - ImageRefs []officialAccountImageRef `json:"image_refs"` - PreviewMarkdown string `json:"preview_markdown"` - PreviewHTML string `json:"preview_html"` - PageMarkdown string `json:"page_markdown"` - Pages []officialAccountPageChunk `json:"pages"` + TopicCandidates []officialAccountTopicCandidate `json:"topic_candidates"` + SelectedTopic string `json:"selected_topic"` + TitleCandidates []string `json:"title_candidates"` + SelectedTitle string `json:"selected_title"` + Outline string `json:"outline"` + Content string `json:"content"` + ContentWithPrompts string `json:"content_with_prompts"` + ImagePrompts []officialAccountImagePrompt `json:"image_prompts"` + ImageRefs []officialAccountImageRef `json:"image_refs"` + PreviewMarkdown string `json:"preview_markdown"` + PreviewHTML string `json:"preview_html"` + PageMarkdown string `json:"page_markdown"` + Pages []officialAccountPageChunk `json:"pages"` } type officialAccountStepState struct { @@ -128,26 +128,26 @@ type officialAccountTopicCandidate struct { } type officialAccountImagePrompt struct { - Key string `json:"key"` - Section string `json:"section"` - Prompt string `json:"prompt"` + Key string `json:"key"` + Section string `json:"section"` + Prompt string `json:"prompt"` } type officialAccountImageRef struct { - Key string `json:"key"` - Section string `json:"section"` - Prompt string `json:"prompt"` - URL string `json:"url"` - ImageSize string `json:"image_size"` - RouteID string `json:"route_id,omitempty"` - RouteModel string `json:"route_model,omitempty"` - RouteProvider string `json:"route_provider,omitempty"` + Key string `json:"key"` + Section string `json:"section"` + Prompt string `json:"prompt"` + URL string `json:"url"` + ImageSize string `json:"image_size"` + AIRouteID string `json:"ai_route_id,omitempty"` + AIRouteModel string `json:"ai_route_model,omitempty"` + AIRouteProvider string `json:"ai_route_provider,omitempty"` } type officialAccountPageChunk struct { - Index int `json:"index"` - Title string `json:"title"` - Content string `json:"content"` + Index int `json:"index"` + Title string `json:"title"` + Content string `json:"content"` } type officialAccountStepExecution struct { @@ -178,7 +178,7 @@ func CreateOfficialAccountTask(c *gin.Context) { req.Goal = strings.TrimSpace(req.Goal) req.Tone = strings.TrimSpace(req.Tone) req.Requirements = strings.TrimSpace(req.Requirements) - req.OutlineStyle = strings.TrimSpace(req.OutlineStyle) + req.OutlineStyle = strings.TrimSpace(req.OutlineStyle) if req.Keyword == "" { web.Fail(c, web.NewBadRequest("请先填写选题关键词")) return @@ -210,11 +210,11 @@ func CreateOfficialAccountTask(c *gin.Context) { web.Fail(c, web.NewBadRequest("创建公众号任务失败")) return } - if _, err := ensureOfficialAccountArticle(task, workflow); err != nil { - _ = store.DB.Delete(&task).Error - web.Fail(c, web.NewBadRequest("初始化公众号文章状态失败")) - return - } + if _, err := ensureOfficialAccountArticle(task, workflow); err != nil { + _ = store.DB.Delete(&task).Error + web.Fail(c, web.NewBadRequest("初始化公众号文章状态失败")) + return + } web.OK(c, gin.H{ "task": task, @@ -236,84 +236,84 @@ func GetOfficialAccountWorkflow(c *gin.Context) { } func UpdateOfficialAccountTask(c *gin.Context) { - user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) - if !ok { - return - } - if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) - return - } + user := middleware.CurrentUser(c) + task, ok := loadAccessibleWorkerTask(c, user) + if !ok { + return + } + if task.SpecialistKey != officialAccountSpecialistKey { + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) + return + } - var req officialAccountTaskUpdateReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } + var req officialAccountTaskUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } - req.Keyword = strings.TrimSpace(req.Keyword) - req.Audience = strings.TrimSpace(req.Audience) - req.Goal = strings.TrimSpace(req.Goal) - req.Tone = strings.TrimSpace(req.Tone) - req.Requirements = strings.TrimSpace(req.Requirements) - req.OutlineStyle = strings.TrimSpace(req.OutlineStyle) - if req.Keyword == "" { - web.Fail(c, web.NewBadRequest("关键词不能为空")) - return - } + req.Keyword = strings.TrimSpace(req.Keyword) + req.Audience = strings.TrimSpace(req.Audience) + req.Goal = strings.TrimSpace(req.Goal) + req.Tone = strings.TrimSpace(req.Tone) + req.Requirements = strings.TrimSpace(req.Requirements) + req.OutlineStyle = strings.TrimSpace(req.OutlineStyle) + if req.Keyword == "" { + web.Fail(c, web.NewBadRequest("关键词不能为空")) + return + } - oldWorkflow := parseOfficialAccountWorkflow(task.ContextJSON) - article, err := ensureOfficialAccountArticle(task, oldWorkflow) - if err != nil { - web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) - return - } - syncWorkflowFromOfficialAccountArticle(&oldWorkflow, article) + oldWorkflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, oldWorkflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&oldWorkflow, article) changed := oldWorkflow.Form.Keyword != req.Keyword || - oldWorkflow.Form.BusinessDomain != resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) || + oldWorkflow.Form.BusinessDomain != resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) || oldWorkflow.Form.Audience != firstNonEmpty(req.Audience, "公众号读者") || oldWorkflow.Form.Goal != firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") || oldWorkflow.Form.Tone != firstNonEmpty(req.Tone, "专业但好懂") || - oldWorkflow.Form.Requirements != req.Requirements || - oldWorkflow.Form.OutlineStyle != normalizeOfficialAccountOutlineStyle(req.OutlineStyle) || - oldWorkflow.Form.OutlineWords != normalizeOfficialAccountOutlineWords(req.OutlineWords) || - oldWorkflow.Form.ContentTargetWords != normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) || - oldWorkflow.Form.MaxContentImages != normalizeOfficialAccountMaxContentImages(req.MaxContentImages) + oldWorkflow.Form.Requirements != req.Requirements || + oldWorkflow.Form.OutlineStyle != normalizeOfficialAccountOutlineStyle(req.OutlineStyle) || + oldWorkflow.Form.OutlineWords != normalizeOfficialAccountOutlineWords(req.OutlineWords) || + oldWorkflow.Form.ContentTargetWords != normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) || + oldWorkflow.Form.MaxContentImages != normalizeOfficialAccountMaxContentImages(req.MaxContentImages) workflow := oldWorkflow if changed { workflow = newOfficialAccountWorkflow(officialAccountTaskCreateReq{ - BusinessDomain: req.BusinessDomain, - Keyword: req.Keyword, - Audience: req.Audience, - Goal: req.Goal, - Tone: req.Tone, - Requirements: req.Requirements, - OutlineStyle: req.OutlineStyle, - OutlineWords: req.OutlineWords, - ContentTargetWords: req.ContentTargetWords, - MaxContentImages: req.MaxContentImages, + BusinessDomain: req.BusinessDomain, + Keyword: req.Keyword, + Audience: req.Audience, + Goal: req.Goal, + Tone: req.Tone, + Requirements: req.Requirements, + OutlineStyle: req.OutlineStyle, + OutlineWords: req.OutlineWords, + ContentTargetWords: req.ContentTargetWords, + MaxContentImages: req.MaxContentImages, }) } else { - workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) + workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword) workflow.Form.Keyword = req.Keyword workflow.Form.Audience = firstNonEmpty(req.Audience, "公众号读者") workflow.Form.Goal = firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章") workflow.Form.Tone = firstNonEmpty(req.Tone, "专业但好懂") workflow.Form.Requirements = req.Requirements - workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle) - workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords) - workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) - workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages) + workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(req.OutlineStyle) + workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords) + workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) + workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages) } if changed { task.Title = buildOfficialAccountTaskTitle(req.Keyword) task.Summary = buildOfficialAccountTaskSummary(officialAccountTaskCreateReq{ - BusinessDomain: req.BusinessDomain, - Keyword: req.Keyword, - Audience: req.Audience, + BusinessDomain: req.BusinessDomain, + Keyword: req.Keyword, + Audience: req.Audience, }) task.Status = workerTaskStatusPending task.CurrentResult = "" @@ -327,38 +327,38 @@ func UpdateOfficialAccountTask(c *gin.Context) { web.Fail(c, web.NewBadRequest("清理旧产物失败")) return } - resetOfficialAccountArticle(article, req) + resetOfficialAccountArticle(article, req) } else { task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) - syncOfficialAccountArticleFromWorkflow(article, workflow) + syncOfficialAccountArticleFromWorkflow(article, workflow) } task.ContextJSON = marshalOfficialAccountWorkflow(workflow) - if err := store.DB.Save(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新任务配置失败")) - return - } - if err := store.DB.Save(article).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) - return - } + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新任务配置失败")) + return + } + if err := store.DB.Save(article).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) + return + } if changed { now := time.Now() configSummary := fmt.Sprintf("任务配置已更新为「%s」,流程已重置,等待重新执行。", req.Keyword) outputJSON, _ := json.Marshal(gin.H{ - "summary": configSummary, - "business_domain": resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword), - "keyword": req.Keyword, - "audience": firstNonEmpty(req.Audience, "公众号读者"), - "goal": firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"), - "tone": firstNonEmpty(req.Tone, "专业但好懂"), - "requirements": req.Requirements, - "outline_style": normalizeOfficialAccountOutlineStyle(req.OutlineStyle), - "outline_words": normalizeOfficialAccountOutlineWords(req.OutlineWords), - "content_target_words": normalizeOfficialAccountContentTargetWords(req.ContentTargetWords), - "max_content_images": normalizeOfficialAccountMaxContentImages(req.MaxContentImages), + "summary": configSummary, + "business_domain": resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword), + "keyword": req.Keyword, + "audience": firstNonEmpty(req.Audience, "公众号读者"), + "goal": firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"), + "tone": firstNonEmpty(req.Tone, "专业但好懂"), + "requirements": req.Requirements, + "outline_style": normalizeOfficialAccountOutlineStyle(req.OutlineStyle), + "outline_words": normalizeOfficialAccountOutlineWords(req.OutlineWords), + "content_target_words": normalizeOfficialAccountContentTargetWords(req.ContentTargetWords), + "max_content_images": normalizeOfficialAccountMaxContentImages(req.MaxContentImages), }) logsJSON, _ := json.Marshal([]string{ fmt.Sprintf("%s 已切换主题并重置流程", now.Format("15:04:05")), @@ -382,7 +382,7 @@ func UpdateOfficialAccountTask(c *gin.Context) { } } - respondOfficialAccountWorkflow(c, task) + respondOfficialAccountWorkflow(c, task) } func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { @@ -410,24 +410,24 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { req.Topic = strings.TrimSpace(req.Topic) req.Title = strings.TrimSpace(req.Title) req.Outline = strings.TrimSpace(req.Outline) - req.Content = strings.TrimSpace(req.Content) - req.ContentWithPrompts = strings.TrimSpace(req.ContentWithPrompts) + req.Content = strings.TrimSpace(req.Content) + req.ContentWithPrompts = strings.TrimSpace(req.ContentWithPrompts) req.Requirements = strings.TrimSpace(req.Requirements) - workflow := parseOfficialAccountWorkflow(task.ContextJSON) - article, err := ensureOfficialAccountArticle(task, workflow) - if err != nil { - web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) - return - } - syncWorkflowFromOfficialAccountArticle(&workflow, article) + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, workflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&workflow, article) if req.Requirements != "" { workflow.Form.Requirements = req.Requirements } now := time.Now() setOfficialAccountStepRunning(&workflow, stepKey, now) - execution, appErr := runOfficialAccountStep(stepKey, &workflow, article, req, user, now) + execution, appErr := runOfficialAccountStep(stepKey, &workflow, article, req, user, now) if appErr != nil { setOfficialAccountStepError(&workflow, stepKey, appErr.Message, now) task.ContextJSON = marshalOfficialAccountWorkflow(workflow) @@ -443,17 +443,17 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { task.ContextJSON = marshalOfficialAccountWorkflow(workflow) task.CurrentResult = execution.Summary task.LastTriggeredAt = &now - syncOfficialAccountArticleFromWorkflow(article, workflow) + syncOfficialAccountArticleFromWorkflow(article, workflow) inputJSON, _ := json.Marshal(gin.H{ - "step_key": stepKey, - "step_title": officialAccountStepLabel(stepKey), - "topic": req.Topic, - "title": req.Title, - "outline": req.Outline, - "content": req.Content, - "content_with_prompts": req.ContentWithPrompts, - "requirements": req.Requirements, + "step_key": stepKey, + "step_title": officialAccountStepLabel(stepKey), + "topic": req.Topic, + "title": req.Title, + "outline": req.Outline, + "content": req.Content, + "content_with_prompts": req.ContentWithPrompts, + "requirements": req.Requirements, }) outputPayload := cloneOutputMap(execution.Output) outputPayload["summary"] = execution.Summary @@ -499,11 +499,11 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { } } - if err := store.DB.Save(article).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) - return - } - if err := store.DB.Save(&task).Error; err != nil { + if err := store.DB.Save(article).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) + return + } + if err := store.DB.Save(&task).Error; err != nil { web.Fail(c, web.NewBadRequest("更新公众号任务失败")) return } @@ -517,140 +517,140 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { } func RegenerateOfficialAccountImage(c *gin.Context) { - user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) - if !ok { - return - } - if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) - return - } + user := middleware.CurrentUser(c) + task, ok := loadAccessibleWorkerTask(c, user) + if !ok { + return + } + if task.SpecialistKey != officialAccountSpecialistKey { + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号助手")) + return + } - imageKey := strings.TrimSpace(c.Param("imageKey")) - if imageKey == "" { - web.Fail(c, web.NewBadRequest("缺少图片标识")) - return - } + imageKey := strings.TrimSpace(c.Param("imageKey")) + if imageKey == "" { + web.Fail(c, web.NewBadRequest("缺少图片标识")) + return + } - workflow := parseOfficialAccountWorkflow(task.ContextJSON) - article, err := ensureOfficialAccountArticle(task, workflow) - if err != nil { - web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) - return - } - syncWorkflowFromOfficialAccountArticle(&workflow, article) + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, err := ensureOfficialAccountArticle(task, workflow) + if err != nil { + web.Fail(c, web.NewBadRequest("读取公众号文章状态失败")) + return + } + syncWorkflowFromOfficialAccountArticle(&workflow, article) - prompt, index, ok := findOfficialAccountImagePrompt(workflow, imageKey) - if !ok { - web.Fail(c, web.NewBadRequest("没有找到要重生成的图片")) - return - } + prompt, index, ok := findOfficialAccountImagePrompt(workflow, imageKey) + if !ok { + web.Fail(c, web.NewBadRequest("没有找到要重生成的图片")) + return + } - now := time.Now() - route, ref, appErr := generateOfficialAccountImageRef(article.TaskID, prompt, index, user) - if appErr != nil { - web.Fail(c, appErr) - return - } + now := time.Now() + aiRoute, ref, appErr := generateOfficialAccountImageRef(article.TaskID, prompt, index, user) + if appErr != nil { + web.Fail(c, appErr) + return + } - workflow.Shared.ImageRefs = upsertOfficialAccountImageRef(workflow.Shared.ImageRefs, workflow.Shared.ImagePrompts, ref) - refreshOfficialAccountDerivedAssets(&workflow) + workflow.Shared.ImageRefs = upsertOfficialAccountImageRef(workflow.Shared.ImageRefs, workflow.Shared.ImagePrompts, ref) + refreshOfficialAccountDerivedAssets(&workflow) - output := map[string]any{ - "image_refs": workflow.Shared.ImageRefs, - "count": len(workflow.Shared.ImageRefs), - "regenerated_key": ref.Key, - "regenerated_ref": ref, - "route_id": route.RouteID, - "route_model": route.Model, - "route_provider": route.Provider, - } - logs := []string{ - fmt.Sprintf("%s 已重新生成图片「%s」。", now.Format("15:04:05"), firstNonEmpty(ref.Section, ref.Key)), - fmt.Sprintf("%s 预览稿与分页稿中的图片地址已同步刷新。", now.Format("15:04:05")), - } - setOfficialAccountStepCompleted(&workflow, officialAccountStepKeyImages, output, logs, now) + output := map[string]any{ + "image_refs": workflow.Shared.ImageRefs, + "count": len(workflow.Shared.ImageRefs), + "regenerated_key": ref.Key, + "regenerated_ref": ref, + "ai_route_id": aiRoute.RouteID, + "ai_route_model": aiRoute.Model, + "ai_route_provider": aiRoute.Provider, + } + logs := []string{ + fmt.Sprintf("%s 已重新生成图片「%s」。", now.Format("15:04:05"), firstNonEmpty(ref.Section, ref.Key)), + fmt.Sprintf("%s 预览稿与分页稿中的图片地址已同步刷新。", now.Format("15:04:05")), + } + setOfficialAccountStepCompleted(&workflow, officialAccountStepKeyImages, output, logs, now) - task.Status = workerTaskStatusDraft - task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) - task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) - task.ContextJSON = marshalOfficialAccountWorkflow(workflow) - task.CurrentResult = fmt.Sprintf("已重新生成图片「%s」。", firstNonEmpty(ref.Section, ref.Key)) - task.LastTriggeredAt = &now - syncOfficialAccountArticleFromWorkflow(article, workflow) + task.Status = workerTaskStatusDraft + task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) + task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) + task.ContextJSON = marshalOfficialAccountWorkflow(workflow) + task.CurrentResult = fmt.Sprintf("已重新生成图片「%s」。", firstNonEmpty(ref.Section, ref.Key)) + task.LastTriggeredAt = &now + syncOfficialAccountArticleFromWorkflow(article, workflow) - inputJSON, _ := json.Marshal(gin.H{ - "image_key": imageKey, - "section": prompt.Section, - "prompt": prompt.Prompt, - }) - outputPayload := cloneOutputMap(output) - outputPayload["summary"] = task.CurrentResult - outputPayload["keyword"] = workflow.Form.Keyword - outputJSON, _ := json.Marshal(outputPayload) - logsJSON, _ := json.Marshal(logs) - run := model.WorkerRun{ - TaskID: task.ID, - SpecialistKey: task.SpecialistKey, - ActionKey: "image_regeneration", - ActionTitle: "单张图片重生成", - ActionType: "workflow_step", - Status: "done", - InputJSON: string(inputJSON), - OutputJSON: string(outputJSON), - LogsJSON: string(logsJSON), - StartedAt: now, - FinishedAt: &now, - } - if err := store.DB.Create(&run).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存单张图片重生成记录失败")) - return - } - task.CurrentRunID = &run.ID + inputJSON, _ := json.Marshal(gin.H{ + "image_key": imageKey, + "section": prompt.Section, + "prompt": prompt.Prompt, + }) + outputPayload := cloneOutputMap(output) + outputPayload["summary"] = task.CurrentResult + outputPayload["keyword"] = workflow.Form.Keyword + outputJSON, _ := json.Marshal(outputPayload) + logsJSON, _ := json.Marshal(logs) + run := model.WorkerRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: "image_regeneration", + ActionTitle: "单张图片重生成", + ActionType: "workflow_step", + Status: "done", + InputJSON: string(inputJSON), + OutputJSON: string(outputJSON), + LogsJSON: string(logsJSON), + StartedAt: now, + FinishedAt: &now, + } + if err := store.DB.Create(&run).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存单张图片重生成记录失败")) + return + } + task.CurrentRunID = &run.ID - artifact := &model.WorkerArtifact{ - TaskID: task.ID, - SpecialistKey: task.SpecialistKey, - Title: fmt.Sprintf("单张重生成图片 - %s", firstNonEmpty(ref.Section, ref.Key)), - ArtifactType: "images", - Status: workerArtifactStatusDraft, - ContentText: buildOfficialAccountImageArtifact([]officialAccountImageRef{ref}), - ContentJSON: string(outputJSON), - SourceRefsJSON: "[]", - CreatedByRunID: &run.ID, - } - if err := store.DB.Create(artifact).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存单张图片结果失败")) - return - } + artifact := &model.WorkerArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: fmt.Sprintf("单张重生成图片 - %s", firstNonEmpty(ref.Section, ref.Key)), + ArtifactType: "images", + Status: workerArtifactStatusDraft, + ContentText: buildOfficialAccountImageArtifact([]officialAccountImageRef{ref}), + ContentJSON: string(outputJSON), + SourceRefsJSON: "[]", + CreatedByRunID: &run.ID, + } + if err := store.DB.Create(artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存单张图片结果失败")) + return + } - if err := store.DB.Save(article).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) - return - } - if err := store.DB.Save(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新公众号任务失败")) - return - } + if err := store.DB.Save(article).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存公众号文章状态失败")) + return + } + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新公众号任务失败")) + return + } - web.OK(c, gin.H{ - "task": task, - "workflow": workflow, - "run": run, - "artifact": artifact, - "image_ref": ref, - }) + web.OK(c, gin.H{ + "task": task, + "workflow": workflow, + "run": run, + "artifact": artifact, + "image_ref": ref, + }) } func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { - workflow := parseOfficialAccountWorkflow(task.ContextJSON) - article, _ := ensureOfficialAccountArticle(task, workflow) - syncWorkflowFromOfficialAccountArticle(&workflow, article) - if err := normalizeOfficialAccountMediaForResponse(&task, &workflow, article); err != nil { - web.Fail(c, web.NewBadRequest("整理公众号图片结果失败")) - return - } + workflow := parseOfficialAccountWorkflow(task.ContextJSON) + article, _ := ensureOfficialAccountArticle(task, workflow) + syncWorkflowFromOfficialAccountArticle(&workflow, article) + if err := normalizeOfficialAccountMediaForResponse(&task, &workflow, article); err != nil { + web.Fail(c, web.NewBadRequest("整理公众号图片结果失败")) + return + } var artifacts []model.WorkerArtifact if err := store.DB.Where("task_id = ?", task.ID).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil { @@ -663,13 +663,13 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { web.Fail(c, web.NewBadRequest("查询公众号运行记录失败")) return } - artifacts = compactOfficialAccountArtifactsForResponse(artifacts) - runs = compactOfficialAccountRunsForResponse(runs) + artifacts = compactOfficialAccountArtifactsForResponse(artifacts) + runs = compactOfficialAccountRunsForResponse(runs) web.OK(c, gin.H{ "task": task, "workflow": workflow, - "article": article, + "article": article, "artifacts": artifacts, "runs": runs, "bottom_panel": buildBottomPanel(runs), @@ -677,84 +677,84 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { } func findOfficialAccountImagePrompt(workflow officialAccountWorkflowState, imageKey string) (officialAccountImagePrompt, int, bool) { - prompts := workflow.Shared.ImagePrompts - if len(prompts) == 0 { - prompts = buildOfficialAccountImagePrompts(&workflow) - workflow.Shared.ImagePrompts = prompts - } - for idx, item := range prompts { - if strings.EqualFold(strings.TrimSpace(item.Key), imageKey) { - return item, idx, true - } - } - for idx, item := range workflow.Shared.ImageRefs { - if strings.EqualFold(strings.TrimSpace(item.Key), imageKey) { - return officialAccountImagePrompt{ - Key: item.Key, - Section: item.Section, - Prompt: item.Prompt, - }, idx, true - } - } - return officialAccountImagePrompt{}, -1, false + prompts := workflow.Shared.ImagePrompts + if len(prompts) == 0 { + prompts = buildOfficialAccountImagePrompts(&workflow) + workflow.Shared.ImagePrompts = prompts + } + for idx, item := range prompts { + if strings.EqualFold(strings.TrimSpace(item.Key), imageKey) { + return item, idx, true + } + } + for idx, item := range workflow.Shared.ImageRefs { + if strings.EqualFold(strings.TrimSpace(item.Key), imageKey) { + return officialAccountImagePrompt{ + Key: item.Key, + Section: item.Section, + Prompt: item.Prompt, + }, idx, true + } + } + return officialAccountImagePrompt{}, -1, false } func upsertOfficialAccountImageRef(existing []officialAccountImageRef, prompts []officialAccountImagePrompt, next officialAccountImageRef) []officialAccountImageRef { - byKey := make(map[string]officialAccountImageRef, len(existing)+1) - for _, item := range existing { - key := strings.TrimSpace(item.Key) - if key == "" { - continue - } - byKey[key] = item - } - byKey[strings.TrimSpace(next.Key)] = next + byKey := make(map[string]officialAccountImageRef, len(existing)+1) + for _, item := range existing { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + byKey[key] = item + } + byKey[strings.TrimSpace(next.Key)] = next - result := make([]officialAccountImageRef, 0, len(byKey)) - used := map[string]struct{}{} - for _, prompt := range prompts { - key := strings.TrimSpace(prompt.Key) - if key == "" { - continue - } - item, ok := byKey[key] - if !ok { - continue - } - result = append(result, item) - used[key] = struct{}{} - } - for _, item := range existing { - key := strings.TrimSpace(item.Key) - if key == "" { - continue - } - if _, ok := used[key]; ok { - continue - } - if updated, exists := byKey[key]; exists { - result = append(result, updated) - used[key] = struct{}{} - } - } - if _, ok := used[strings.TrimSpace(next.Key)]; !ok { - result = append(result, next) - } - return result + result := make([]officialAccountImageRef, 0, len(byKey)) + used := map[string]struct{}{} + for _, prompt := range prompts { + key := strings.TrimSpace(prompt.Key) + if key == "" { + continue + } + item, ok := byKey[key] + if !ok { + continue + } + result = append(result, item) + used[key] = struct{}{} + } + for _, item := range existing { + key := strings.TrimSpace(item.Key) + if key == "" { + continue + } + if _, ok := used[key]; ok { + continue + } + if updated, exists := byKey[key]; exists { + result = append(result, updated) + used[key] = struct{}{} + } + } + if _, ok := used[strings.TrimSpace(next.Key)]; !ok { + result = append(result, next) + } + return result } func refreshOfficialAccountDerivedAssets(workflow *officialAccountWorkflowState) { - if workflow == nil { - return - } - if strings.TrimSpace(workflow.Shared.PreviewMarkdown) != "" || strings.TrimSpace(workflow.Shared.PreviewHTML) != "" || strings.TrimSpace(workflow.Shared.PageMarkdown) != "" || len(workflow.Shared.Pages) > 0 { - workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow) - workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown) - } - if strings.TrimSpace(workflow.Shared.PageMarkdown) != "" || len(workflow.Shared.Pages) > 0 { - workflow.Shared.Pages = buildOfficialAccountPages(workflow) - workflow.Shared.PageMarkdown = buildOfficialAccountPageMarkdown(workflow.Shared.Pages) - } + if workflow == nil { + return + } + if strings.TrimSpace(workflow.Shared.PreviewMarkdown) != "" || strings.TrimSpace(workflow.Shared.PreviewHTML) != "" || strings.TrimSpace(workflow.Shared.PageMarkdown) != "" || len(workflow.Shared.Pages) > 0 { + workflow.Shared.PreviewMarkdown = buildOfficialAccountPreviewMarkdown(workflow) + workflow.Shared.PreviewHTML = renderOfficialAccountHTML(workflow.Shared.PreviewMarkdown) + } + if strings.TrimSpace(workflow.Shared.PageMarkdown) != "" || len(workflow.Shared.Pages) > 0 { + workflow.Shared.Pages = buildOfficialAccountPages(workflow) + workflow.Shared.PageMarkdown = buildOfficialAccountPageMarkdown(workflow.Shared.Pages) + } } func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccountWorkflowState { @@ -762,24 +762,24 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun WorkflowType: "wechat_official_account", CurrentStep: officialAccountStepKeyTopic, Form: officialAccountForm{ - BusinessDomain: resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword), - Keyword: req.Keyword, - Audience: firstNonEmpty(req.Audience, "公众号读者"), - Goal: firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"), - Tone: firstNonEmpty(req.Tone, "专业但好懂"), - Requirements: req.Requirements, - OutlineStyle: normalizeOfficialAccountOutlineStyle(req.OutlineStyle), - OutlineWords: normalizeOfficialAccountOutlineWords(req.OutlineWords), - ContentTargetWords: normalizeOfficialAccountContentTargetWords(req.ContentTargetWords), - MaxContentImages: normalizeOfficialAccountMaxContentImages(req.MaxContentImages), + BusinessDomain: resolveOfficialAccountBusinessDomain(req.BusinessDomain, req.Keyword), + Keyword: req.Keyword, + Audience: firstNonEmpty(req.Audience, "公众号读者"), + Goal: firstNonEmpty(req.Goal, "输出一篇可发布的公众号文章"), + Tone: firstNonEmpty(req.Tone, "专业但好懂"), + Requirements: req.Requirements, + OutlineStyle: normalizeOfficialAccountOutlineStyle(req.OutlineStyle), + OutlineWords: normalizeOfficialAccountOutlineWords(req.OutlineWords), + ContentTargetWords: normalizeOfficialAccountContentTargetWords(req.ContentTargetWords), + MaxContentImages: normalizeOfficialAccountMaxContentImages(req.MaxContentImages), }, Steps: map[string]*officialAccountStepState{}, Shared: officialAccountSharedState{ TopicCandidates: []officialAccountTopicCandidate{}, TitleCandidates: []string{}, - ImagePrompts: []officialAccountImagePrompt{}, - ImageRefs: []officialAccountImageRef{}, - Pages: []officialAccountPageChunk{}, + ImagePrompts: []officialAccountImagePrompt{}, + ImageRefs: []officialAccountImageRef{}, + Pages: []officialAccountPageChunk{}, }, } for _, item := range []struct { @@ -791,10 +791,10 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun {officialAccountStepKeyTitle, officialAccountStepLabel(officialAccountStepKeyTitle), "等待生成标题"}, {officialAccountStepKeyOutline, officialAccountStepLabel(officialAccountStepKeyOutline), "等待生成提纲"}, {officialAccountStepKeyContent, officialAccountStepLabel(officialAccountStepKeyContent), "等待创作正文"}, - {officialAccountStepKeyImagePrompts, officialAccountStepLabel(officialAccountStepKeyImagePrompts), "等待生成配图提示词"}, - {officialAccountStepKeyImages, officialAccountStepLabel(officialAccountStepKeyImages), "等待生成图片"}, - {officialAccountStepKeyPreview, officialAccountStepLabel(officialAccountStepKeyPreview), "等待生成预览稿"}, - {officialAccountStepKeyPageMD, officialAccountStepLabel(officialAccountStepKeyPageMD), "等待生成分页 MD"}, + {officialAccountStepKeyImagePrompts, officialAccountStepLabel(officialAccountStepKeyImagePrompts), "等待生成配图提示词"}, + {officialAccountStepKeyImages, officialAccountStepLabel(officialAccountStepKeyImages), "等待生成图片"}, + {officialAccountStepKeyPreview, officialAccountStepLabel(officialAccountStepKeyPreview), "等待生成预览稿"}, + {officialAccountStepKeyPageMD, officialAccountStepLabel(officialAccountStepKeyPageMD), "等待生成分页 MD"}, } { workflow.Steps[item.key] = &officialAccountStepState{ Key: item.key, @@ -810,31 +810,31 @@ func newOfficialAccountWorkflow(req officialAccountTaskCreateReq) officialAccoun } func normalizeOfficialAccountOutlineStyle(value string) string { - return firstNonEmpty(strings.TrimSpace(value), "问题拆解型") + return firstNonEmpty(strings.TrimSpace(value), "问题拆解型") } func normalizeOfficialAccountOutlineWords(value int) int { - if value <= 0 { - return 600 - } - return value + if value <= 0 { + return 600 + } + return value } func normalizeOfficialAccountContentTargetWords(value int) int { - if value <= 0 { - return 1400 - } - return value + if value <= 0 { + return 1400 + } + return value } func normalizeOfficialAccountMaxContentImages(value int) int { - if value <= 0 { - return 0 - } - if value > 20 { - return 20 - } - return value + if value <= 0 { + return 0 + } + if value > 20 { + return 20 + } + return value } func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState { @@ -851,16 +851,16 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState { if workflow.Form.Audience == "" { workflow.Form.Audience = "公众号读者" } - if workflow.Form.BusinessDomain == "" { - workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain("", workflow.Form.Keyword) - } + if workflow.Form.BusinessDomain == "" { + workflow.Form.BusinessDomain = resolveOfficialAccountBusinessDomain("", workflow.Form.Keyword) + } if workflow.Form.Goal == "" { workflow.Form.Goal = "输出一篇可发布的公众号文章" } if workflow.Form.Tone == "" { workflow.Form.Tone = "专业但好懂" } - workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages) + workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages) if workflow.Steps == nil { workflow.Steps = map[string]*officialAccountStepState{} } @@ -892,15 +892,15 @@ func parseOfficialAccountWorkflow(value string) officialAccountWorkflowState { if workflow.Shared.TitleCandidates == nil { workflow.Shared.TitleCandidates = []string{} } - if workflow.Shared.ImagePrompts == nil { - workflow.Shared.ImagePrompts = []officialAccountImagePrompt{} - } - if workflow.Shared.ImageRefs == nil { - workflow.Shared.ImageRefs = []officialAccountImageRef{} - } - if workflow.Shared.Pages == nil { - workflow.Shared.Pages = []officialAccountPageChunk{} - } + if workflow.Shared.ImagePrompts == nil { + workflow.Shared.ImagePrompts = []officialAccountImagePrompt{} + } + if workflow.Shared.ImageRefs == nil { + workflow.Shared.ImageRefs = []officialAccountImageRef{} + } + if workflow.Shared.Pages == nil { + workflow.Shared.Pages = []officialAccountPageChunk{} + } if workflow.CurrentStep == "" { workflow.CurrentStep = officialAccountStepKeyTopic } @@ -917,7 +917,7 @@ func marshalOfficialAccountWorkflow(workflow officialAccountWorkflowState) strin func isOfficialAccountStep(stepKey string) bool { switch stepKey { - case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent, officialAccountStepKeyImagePrompts, officialAccountStepKeyImages, officialAccountStepKeyPreview, officialAccountStepKeyPageMD: + case officialAccountStepKeyTopic, officialAccountStepKeyTitle, officialAccountStepKeyOutline, officialAccountStepKeyContent, officialAccountStepKeyImagePrompts, officialAccountStepKeyImages, officialAccountStepKeyPreview, officialAccountStepKeyPageMD: return true default: return false @@ -934,14 +934,14 @@ func officialAccountStepLabel(stepKey string) string { return "提纲生成" case officialAccountStepKeyContent: return "正文创作" - case officialAccountStepKeyImagePrompts: - return "配图提示词" - case officialAccountStepKeyImages: - return "图片生成" - case officialAccountStepKeyPreview: - return "预览与导出" - case officialAccountStepKeyPageMD: - return "分页 MD" + case officialAccountStepKeyImagePrompts: + return "配图提示词" + case officialAccountStepKeyImages: + return "图片生成" + case officialAccountStepKeyPreview: + return "预览与导出" + case officialAccountStepKeyPageMD: + return "分页 MD" default: return "工作流步骤" } @@ -955,16 +955,16 @@ func officialAccountNextStep(stepKey string) string { return officialAccountStepKeyOutline case officialAccountStepKeyOutline: return officialAccountStepKeyContent - case officialAccountStepKeyContent: - return officialAccountStepKeyImagePrompts - case officialAccountStepKeyImagePrompts: - return officialAccountStepKeyImages - case officialAccountStepKeyImages: - return officialAccountStepKeyPreview - case officialAccountStepKeyPreview: - return officialAccountStepKeyPageMD + case officialAccountStepKeyContent: + return officialAccountStepKeyImagePrompts + case officialAccountStepKeyImagePrompts: + return officialAccountStepKeyImages + case officialAccountStepKeyImages: + return officialAccountStepKeyPreview + case officialAccountStepKeyPreview: + return officialAccountStepKeyPageMD default: - return officialAccountStepKeyPageMD + return officialAccountStepKeyPageMD } } @@ -1010,7 +1010,7 @@ func setOfficialAccountStepError(workflow *officialAccountWorkflowState, stepKey func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, req officialAccountStepReq, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { switch stepKey { case officialAccountStepKeyTopic: - return executeOfficialAccountTopicStep(workflow, article, user, now), nil + return executeOfficialAccountTopicStep(workflow, article, user, now), nil case officialAccountStepKeyTitle: if req.Topic != "" { workflow.Shared.SelectedTopic = req.Topic @@ -1018,10 +1018,10 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta if workflow.Shared.SelectedTopic == "" { return officialAccountStepExecution{}, web.NewBadRequest("请先完成选题推荐,或手动选择一个选题") } - if article != nil { - article.SelectedTopic = workflow.Shared.SelectedTopic - } - return executeOfficialAccountTitleStep(workflow, article, user, now), nil + if article != nil { + article.SelectedTopic = workflow.Shared.SelectedTopic + } + return executeOfficialAccountTitleStep(workflow, article, user, now), nil case officialAccountStepKeyOutline: if req.Title != "" { workflow.Shared.SelectedTitle = req.Title @@ -1029,10 +1029,10 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta if workflow.Shared.SelectedTitle == "" { return officialAccountStepExecution{}, web.NewBadRequest("请先生成标题,或手动输入标题") } - if article != nil { - article.SelectedTitle = workflow.Shared.SelectedTitle - } - return executeOfficialAccountOutlineStep(workflow, article, user, now), nil + if article != nil { + article.SelectedTitle = workflow.Shared.SelectedTitle + } + return executeOfficialAccountOutlineStep(workflow, article, user, now), nil case officialAccountStepKeyContent: if req.Outline != "" { workflow.Shared.Outline = req.Outline @@ -1040,61 +1040,61 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta if workflow.Shared.Outline == "" { return officialAccountStepExecution{}, web.NewBadRequest("请先生成提纲,或手动输入提纲") } - return executeOfficialAccountContentStep(workflow, article, user, now), nil - case officialAccountStepKeyImagePrompts: - if req.Content != "" { - workflow.Shared.Content = req.Content - } - if workflow.Shared.Content == "" { - return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成配图提示词") - } - return executeOfficialAccountImagePromptStep(workflow, article, user, now), nil - case officialAccountStepKeyImages: - if req.ContentWithPrompts != "" { - workflow.Shared.ContentWithPrompts = req.ContentWithPrompts - } - if len(workflow.Shared.ImagePrompts) == 0 && workflow.Shared.ContentWithPrompts == "" { - return officialAccountStepExecution{}, web.NewBadRequest("请先生成配图提示词") - } - execution, appErr := executeOfficialAccountImageGenerationStep(workflow, article, user, now) - if appErr != nil { - return officialAccountStepExecution{}, appErr - } - return execution, nil - case officialAccountStepKeyPreview: - if workflow.Shared.Content == "" { - return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成预览稿") - } - return executeOfficialAccountPreviewStep(workflow, article, user, now), nil - case officialAccountStepKeyPageMD: - if workflow.Shared.Content == "" { - return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成分页 MD") - } - return executeOfficialAccountPageMarkdownStep(workflow, article, user, now), nil + return executeOfficialAccountContentStep(workflow, article, user, now), nil + case officialAccountStepKeyImagePrompts: + if req.Content != "" { + workflow.Shared.Content = req.Content + } + if workflow.Shared.Content == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成配图提示词") + } + return executeOfficialAccountImagePromptStep(workflow, article, user, now), nil + case officialAccountStepKeyImages: + if req.ContentWithPrompts != "" { + workflow.Shared.ContentWithPrompts = req.ContentWithPrompts + } + if len(workflow.Shared.ImagePrompts) == 0 && workflow.Shared.ContentWithPrompts == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先生成配图提示词") + } + execution, appErr := executeOfficialAccountImageGenerationStep(workflow, article, user, now) + if appErr != nil { + return officialAccountStepExecution{}, appErr + } + return execution, nil + case officialAccountStepKeyPreview: + if workflow.Shared.Content == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成预览稿") + } + return executeOfficialAccountPreviewStep(workflow, article, user, now), nil + case officialAccountStepKeyPageMD: + if workflow.Shared.Content == "" { + return officialAccountStepExecution{}, web.NewBadRequest("请先生成正文,再生成分页 MD") + } + return executeOfficialAccountPageMarkdownStep(workflow, article, user, now), nil default: return officialAccountStepExecution{}, web.NewBadRequest("工作流步骤不存在") } } func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(workflow.Form, false) - hotspots = sortOfficialAccountHotspots(hotspots) - candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots) - _ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates) - evaluatedCount := evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates) + hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(workflow.Form, false) + hotspots = sortOfficialAccountHotspots(hotspots) + candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots) + _ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates) + evaluatedCount := evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates) if workflow.Shared.SelectedTopic == "" && len(candidates) > 0 { workflow.Shared.SelectedTopic = candidates[0].Topic } workflow.Shared.TopicCandidates = candidates - if article != nil { - article.HotspotsJSON = marshalOfficialAccountHotspots(hotspots) - article.TopicHeat = findOfficialAccountTopicHeat(candidates, workflow.Shared.SelectedTopic) - } + if article != nil { + article.HotspotsJSON = marshalOfficialAccountHotspots(hotspots) + article.TopicHeat = findOfficialAccountTopicHeat(candidates, workflow.Shared.SelectedTopic) + } output := map[string]any{ "candidates": candidates, "selected_topic": workflow.Shared.SelectedTopic, - "hotspots": hotspots, + "hotspots": hotspots, } return officialAccountStepExecution{ Summary: fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic), @@ -1104,11 +1104,11 @@ func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, art ArtifactStatus: workerArtifactStatusDraft, ArtifactContent: buildTopicArtifactContent(candidates, workflow.Shared.SelectedTopic), Output: output, - Logs: append([]string{ + Logs: append([]string{ fmt.Sprintf("%s 已基于关键词「%s」生成候选选题", now.Format("15:04:05"), workflow.Form.Keyword), - fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount), + fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount), fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTopic), - }, hotspotLogs...), + }, hotspotLogs...), } } @@ -1119,12 +1119,12 @@ func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, art workflow.Shared.SelectedTitle = titles[0] } workflow.Shared.TitleCandidates = titles - if article != nil && workflow.Shared.SelectedTitle != article.SelectedTitle { - workflow.Shared.Outline = "" - workflow.Shared.Content = "" - article.Outline = "" - article.Content = "" - } + if article != nil && workflow.Shared.SelectedTitle != article.SelectedTitle { + workflow.Shared.Outline = "" + workflow.Shared.Content = "" + article.Outline = "" + article.Content = "" + } output := map[string]any{ "titles": titles, @@ -1139,25 +1139,25 @@ func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, art ArtifactStatus: workerArtifactStatusDraft, ArtifactContent: buildTitleArtifactContent(titles, workflow.Shared.SelectedTitle), Output: output, - Logs: []string{ + Logs: []string{ fmt.Sprintf("%s 已基于选题「%s」生成标题候选", now.Format("15:04:05"), workflow.Shared.SelectedTopic), fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTitle), - fmt.Sprintf("%s 若标题发生变化,将自动清空旧提纲和旧正文。", now.Format("15:04:05")), + fmt.Sprintf("%s 若标题发生变化,将自动清空旧提纲和旧正文。", now.Format("15:04:05")), }, } } func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - outline := buildOfficialAccountOutline(workflow.Form, workflow.Shared.SelectedTitle) - aiOutline, ok := tryGenerateOfficialAccountOutlineWithAI(workflow.Form, workflow.Shared.SelectedTitle, user) + outline := buildOfficialAccountOutline(workflow.Form, workflow.Shared.SelectedTitle) + aiOutline, ok := tryGenerateOfficialAccountOutlineWithAI(workflow.Form, workflow.Shared.SelectedTitle, user) if ok { outline = aiOutline } workflow.Shared.Outline = outline - workflow.Shared.Content = "" - if article != nil { - article.Content = "" - } + workflow.Shared.Content = "" + if article != nil { + article.Content = "" + } output := map[string]any{ "outline": outline, @@ -1171,24 +1171,24 @@ func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, a ArtifactStatus: workerArtifactStatusDraft, ArtifactContent: outline, Output: output, - Logs: []string{ + Logs: []string{ fmt.Sprintf("%s 已基于标题「%s」生成文章提纲", now.Format("15:04:05"), workflow.Shared.SelectedTitle), - fmt.Sprintf("%s 提纲风格:%s,目标字数:%d 字。", now.Format("15:04:05"), normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle), normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)), - fmt.Sprintf("%s 提纲更新后,旧正文已自动失效,需要重新创作。", now.Format("15:04:05")), + fmt.Sprintf("%s 提纲风格:%s,目标字数:%d 字。", now.Format("15:04:05"), normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle), normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords)), + fmt.Sprintf("%s 提纲更新后,旧正文已自动失效,需要重新创作。", now.Format("15:04:05")), }, } } func executeOfficialAccountContentStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - content := buildOfficialAccountContent(workflow.Form, workflow.Shared.SelectedTitle, workflow.Shared.Outline) - aiContent, ok := tryGenerateOfficialAccountContentWithAI(workflow.Form, workflow.Shared.SelectedTitle, workflow.Shared.Outline, user) + content := buildOfficialAccountContent(workflow.Form, workflow.Shared.SelectedTitle, workflow.Shared.Outline) + aiContent, ok := tryGenerateOfficialAccountContentWithAI(workflow.Form, workflow.Shared.SelectedTitle, workflow.Shared.Outline, user) if ok { content = aiContent } workflow.Shared.Content = content - if article != nil { - article.Content = content - } + if article != nil { + article.Content = content + } output := map[string]any{ "content": content, @@ -1205,7 +1205,7 @@ func executeOfficialAccountContentStep(workflow *officialAccountWorkflowState, a Output: output, Logs: []string{ fmt.Sprintf("%s 已完成正文创作草稿", now.Format("15:04:05")), - fmt.Sprintf("%s 正文目标字数:%d 字。", now.Format("15:04:05"), normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)), + fmt.Sprintf("%s 正文目标字数:%d 字。", now.Format("15:04:05"), normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords)), }, } } @@ -1254,36 +1254,36 @@ func buildOfficialAccountTitles(form officialAccountForm, topic string) []string func buildOfficialAccountOutline(form officialAccountForm, title string) string { keyword := firstNonEmpty(form.Keyword, "这个主题") - outlineStyle := normalizeOfficialAccountOutlineStyle(form.OutlineStyle) - outlineWords := normalizeOfficialAccountOutlineWords(form.OutlineWords) + outlineStyle := normalizeOfficialAccountOutlineStyle(form.OutlineStyle) + outlineWords := normalizeOfficialAccountOutlineWords(form.OutlineWords) return strings.TrimSpace(fmt.Sprintf( - "# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n", + "# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n", title, - outlineStyle, - outlineWords, + outlineStyle, + outlineWords, keyword, )) } func buildOfficialAccountContent(form officialAccountForm, title, outline string) string { - keyword := firstNonEmpty(form.Keyword, "这个主题") - audience := firstNonEmpty(form.Audience, "公众号读者") - tone := firstNonEmpty(form.Tone, "专业但好懂") - targetWords := normalizeOfficialAccountContentTargetWords(form.ContentTargetWords) - return strings.TrimSpace(fmt.Sprintf( - "# %s\n\n> 正文字数目标:约 %d 字\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n", - firstNonEmpty(title, keyword), - targetWords, - audience, - keyword, - tone, - keyword, - )) + keyword := firstNonEmpty(form.Keyword, "这个主题") + audience := firstNonEmpty(form.Audience, "公众号读者") + tone := firstNonEmpty(form.Tone, "专业但好懂") + targetWords := normalizeOfficialAccountContentTargetWords(form.ContentTargetWords) + return strings.TrimSpace(fmt.Sprintf( + "# %s\n\n> 正文字数目标:约 %d 字\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n", + firstNonEmpty(title, keyword), + targetWords, + audience, + keyword, + tone, + keyword, + )) } func tryFillOfficialAccountTopicsWithAI(form officialAccountForm, hotspots []model.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) bool { var items []officialAccountTopicCandidate - prompt := buildOfficialAccountHotspotPrompt(form, hotspots) + prompt := buildOfficialAccountHotspotPrompt(form, hotspots) if !generateOfficialAccountJSON(prompt, user, &items) || len(items) == 0 { return false } @@ -1309,138 +1309,138 @@ func tryFillOfficialAccountTitlesWithAI(form officialAccountForm, topic string, func tryGenerateOfficialAccountOutlineWithAI(form officialAccountForm, title string, user *model.User) (string, bool) { prompt := fmt.Sprintf( - "请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。", + "请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。", title, form.Tone, form.Audience, - normalizeOfficialAccountOutlineStyle(form.OutlineStyle), - normalizeOfficialAccountOutlineWords(form.OutlineWords), + normalizeOfficialAccountOutlineStyle(form.OutlineStyle), + normalizeOfficialAccountOutlineWords(form.OutlineWords), ) return generateOfficialAccountText(prompt, user) } func tryGenerateOfficialAccountContentWithAI(form officialAccountForm, title, outline string, user *model.User) (string, bool) { prompt := fmt.Sprintf( - "请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。", + "请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。", title, form.Audience, form.Goal, form.Tone, outline, - normalizeOfficialAccountContentTargetWords(form.ContentTargetWords), + normalizeOfficialAccountContentTargetWords(form.ContentTargetWords), ) return generateOfficialAccountText(prompt, user) } type officialAccountTopicEvalResult struct { - Heat string `json:"heat"` - Reason string `json:"reason"` - Score int `json:"score"` + Heat string `json:"heat"` + Reason string `json:"reason"` + Score int `json:"score"` } func evaluateOfficialAccountTopics(form officialAccountForm, hotspots []model.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) int { - if target == nil || len(*target) == 0 { - return 0 - } - evaluated := 0 - items := append([]officialAccountTopicCandidate(nil), (*target)...) - for idx := range items { - result, ok := tryEvaluateOfficialAccountTopicWithAI(form, items[idx].Topic, hotspots, user) - if !ok { - continue - } - if strings.TrimSpace(result.Heat) != "" { - items[idx].Heat = result.Heat - } - if strings.TrimSpace(result.Reason) != "" { - items[idx].Reason = result.Reason - } - evaluated++ - } - sortOfficialAccountTopicCandidates(items) - *target = dedupeOfficialAccountTopicCandidates(items) - return evaluated + if target == nil || len(*target) == 0 { + return 0 + } + evaluated := 0 + items := append([]officialAccountTopicCandidate(nil), (*target)...) + for idx := range items { + result, ok := tryEvaluateOfficialAccountTopicWithAI(form, items[idx].Topic, hotspots, user) + if !ok { + continue + } + if strings.TrimSpace(result.Heat) != "" { + items[idx].Heat = result.Heat + } + if strings.TrimSpace(result.Reason) != "" { + items[idx].Reason = result.Reason + } + evaluated++ + } + sortOfficialAccountTopicCandidates(items) + *target = dedupeOfficialAccountTopicCandidates(items) + return evaluated } func tryEvaluateOfficialAccountTopicWithAI(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot, user *model.User) (officialAccountTopicEvalResult, bool) { - var result officialAccountTopicEvalResult - prompt := buildOfficialAccountTopicEvalPrompt(form, topic, hotspots) - if !generateOfficialAccountJSON(prompt, user, &result) { - return officialAccountTopicEvalResult{}, false - } - return result, true + var result officialAccountTopicEvalResult + prompt := buildOfficialAccountTopicEvalPrompt(form, topic, hotspots) + if !generateOfficialAccountJSON(prompt, user, &result) { + return officialAccountTopicEvalResult{}, false + } + return result, true } func sortOfficialAccountTopicCandidates(items []officialAccountTopicCandidate) { - order := map[string]int{ - "爆热": 4, - "高": 3, - "中高": 2, - "中": 1, - } - sort.SliceStable(items, func(i, j int) bool { - left := order[strings.TrimSpace(items[i].Heat)] - right := order[strings.TrimSpace(items[j].Heat)] - if left == right { - return len([]rune(items[i].Reason)) > len([]rune(items[j].Reason)) - } - return left > right - }) + order := map[string]int{ + "爆热": 4, + "高": 3, + "中高": 2, + "中": 1, + } + sort.SliceStable(items, func(i, j int) bool { + left := order[strings.TrimSpace(items[i].Heat)] + right := order[strings.TrimSpace(items[j].Heat)] + if left == right { + return len([]rune(items[i].Reason)) > len([]rune(items[j].Reason)) + } + return left > right + }) } func findOfficialAccountTopicHeat(items []officialAccountTopicCandidate, selected string) string { - for _, item := range items { - if strings.TrimSpace(item.Topic) == strings.TrimSpace(selected) { - return item.Heat - } - } - if len(items) > 0 { - return items[0].Heat - } - return "" + for _, item := range items { + if strings.TrimSpace(item.Topic) == strings.TrimSpace(selected) { + return item.Heat + } + } + if len(items) > 0 { + return items[0].Heat + } + return "" } func generateOfficialAccountText(prompt string, user *model.User) (string, bool) { if user == nil || user.AiPoints <= 0 { return "", false } - route, err := config.GetRoute("title_gen") + aiRoute, err := config.GetRoute("title_gen") if err != nil { return "", false } - start := time.Now() - result, usedRoute, err := ai.GenerateFullWithFallback(route, []ai.Message{ + start := time.Now() + result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, []ai.Message{ {Role: "system", Content: "你是一个擅长公众号内容策划与创作的中文编辑助手,请直接给出结果,不要输出多余说明。"}, {Role: "user", Content: prompt}, }) - if err != nil || result == nil || strings.TrimSpace(result.Content) == "" { - ai.LogCall(ai.LogEntry{ - UserID: user.ID, - Capability: ai.CapabilityTextGen, - Provider: route.Provider, - RouteID: route.RouteID, - Model: route.Model, - Success: false, - ErrorMessage: firstNonEmpty(strings.TrimSpace(fmt.Sprint(err)), "empty_result"), - LatencyMs: int(time.Since(start).Milliseconds()), - }) + if err != nil || result == nil || strings.TrimSpace(result.Content) == "" { + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityTextGen, + Provider: aiRoute.Provider, + AIRouteID: aiRoute.RouteID, + Model: aiRoute.Model, + Success: false, + ErrorMessage: firstNonEmpty(strings.TrimSpace(fmt.Sprint(err)), "empty_result"), + LatencyMs: int(time.Since(start).Milliseconds()), + }) return "", false } - finalRoute := route - if usedRoute != nil { - finalRoute = usedRoute - } - ai.LogCall(ai.LogEntry{ - UserID: user.ID, - Capability: ai.CapabilityTextGen, - Provider: finalRoute.Provider, - RouteID: finalRoute.RouteID, - Model: finalRoute.Model, - TokensInput: result.Usage.PromptTokens, - TokensOutput: result.Usage.CompletionTokens, - Success: true, - LatencyMs: int(time.Since(start).Milliseconds()), - }) + finalAiRoute := aiRoute + if usedAiRoute != nil { + finalAiRoute = usedAiRoute + } + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + Capability: ai.CapabilityTextGen, + Provider: finalAiRoute.Provider, + AIRouteID: finalAiRoute.RouteID, + Model: finalAiRoute.Model, + TokensInput: result.Usage.PromptTokens, + TokensOutput: result.Usage.CompletionTokens, + Success: true, + LatencyMs: int(time.Since(start).Milliseconds()), + }) return strings.TrimSpace(stripJSONCodeFence(result.Content)), true } @@ -1470,20 +1470,20 @@ func buildOfficialAccountTaskSummary(req officialAccountTaskCreateReq) string { } func buildOfficialAccountTaskSummaryFromWorkflow(workflow officialAccountWorkflowState) string { - if workflow.Shared.PageMarkdown != "" { - return "分页 MD 已生成,可直接预览与导出。" - } - if workflow.Shared.PreviewHTML != "" { - return "图文预览稿已生成,可检查排版并导出。" - } - if len(workflow.Shared.ImageRefs) > 0 { - return fmt.Sprintf("已生成 %d 张配图,可继续预览与导出。", len(workflow.Shared.ImageRefs)) - } - if len(workflow.Shared.ImagePrompts) > 0 || workflow.Shared.ContentWithPrompts != "" { - return "配图提示词已生成,可继续生成图片。" - } - if workflow.Shared.Content != "" { - return "正文草稿已生成,可继续补齐配图与预览。" + if workflow.Shared.PageMarkdown != "" { + return "分页 MD 已生成,可直接预览与导出。" + } + if workflow.Shared.PreviewHTML != "" { + return "图文预览稿已生成,可检查排版并导出。" + } + if len(workflow.Shared.ImageRefs) > 0 { + return fmt.Sprintf("已生成 %d 张配图,可继续预览与导出。", len(workflow.Shared.ImageRefs)) + } + if len(workflow.Shared.ImagePrompts) > 0 || workflow.Shared.ContentWithPrompts != "" { + return "配图提示词已生成,可继续生成图片。" + } + if workflow.Shared.Content != "" { + return "正文草稿已生成,可继续补齐配图与预览。" } if workflow.Shared.Outline != "" { return "提纲已生成,可继续创作正文。" diff --git a/eai_agentplatform/backend-go/internal/api/project.go b/eai_agentplatform/backend-go/internal/api/project.go new file mode 100644 index 0000000..9d503bc --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/project.go @@ -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 +} diff --git a/eai_agentplatform/backend-go/internal/api/report_gen.go b/eai_agentplatform/backend-go/internal/api/report_gen.go index 15e7ca0..5107cf9 100644 --- a/eai_agentplatform/backend-go/internal/api/report_gen.go +++ b/eai_agentplatform/backend-go/internal/api/report_gen.go @@ -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 格式解析报告为章节数组 diff --git a/eai_agentplatform/backend-go/internal/api/router.go b/eai_agentplatform/backend-go/internal/api/router.go index 3608add..5d3125c 100644 --- a/eai_agentplatform/backend-go/internal/api/router.go +++ b/eai_agentplatform/backend-go/internal/api/router.go @@ -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) diff --git a/eai_agentplatform/backend-go/internal/api/workbench_overview.go b/eai_agentplatform/backend-go/internal/api/workbench_overview.go index 6a3230b..89677f2 100644 --- a/eai_agentplatform/backend-go/internal/api/workbench_overview.go +++ b/eai_agentplatform/backend-go/internal/api/workbench_overview.go @@ -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, }) diff --git a/eai_agentplatform/backend-go/internal/api/worker_task.go b/eai_agentplatform/backend-go/internal/api/worker_task.go index 8289824..0db5e76 100644 --- a/eai_agentplatform/backend-go/internal/api/worker_task.go +++ b/eai_agentplatform/backend-go/internal/api/worker_task.go @@ -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 "" } diff --git a/eai_agentplatform/backend-go/internal/config/config.go b/eai_agentplatform/backend-go/internal/config/config.go index 058be85..add905d 100644 --- a/eai_agentplatform/backend-go/internal/config/config.go +++ b/eai_agentplatform/backend-go/internal/config/config.go @@ -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")), diff --git a/eai_agentplatform/backend-go/internal/config/json_loader.go b/eai_agentplatform/backend-go/internal/config/json_loader.go index 72cac43..0ebf67a 100644 --- a/eai_agentplatform/backend-go/internal/config/json_loader.go +++ b/eai_agentplatform/backend-go/internal/config/json_loader.go @@ -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 diff --git a/eai_agentplatform/backend-go/internal/config/route_health.go b/eai_agentplatform/backend-go/internal/config/route_health.go new file mode 100644 index 0000000..b810c63 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/config/route_health.go @@ -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] + "..." +} diff --git a/eai_agentplatform/backend-go/internal/middleware/auth.go b/eai_agentplatform/backend-go/internal/middleware/auth.go index 4c036e1..82d0723 100644 --- a/eai_agentplatform/backend-go/internal/middleware/auth.go +++ b/eai_agentplatform/backend-go/internal/middleware/auth.go @@ -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 -} diff --git a/eai_agentplatform/backend-go/internal/model/action_definition.go b/eai_agentplatform/backend-go/internal/model/action_definition.go new file mode 100644 index 0000000..eae475e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/action_definition.go @@ -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" } diff --git a/eai_agentplatform/backend-go/internal/model/project.go b/eai_agentplatform/backend-go/internal/model/project.go new file mode 100644 index 0000000..00f5230 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/project.go @@ -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" } diff --git a/eai_agentplatform/backend-go/internal/model/skill_definition.go b/eai_agentplatform/backend-go/internal/model/skill_definition.go new file mode 100644 index 0000000..6712fcd --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/skill_definition.go @@ -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" } diff --git a/eai_agentplatform/backend-go/internal/model/user_app_center.go b/eai_agentplatform/backend-go/internal/model/user_app_center.go new file mode 100644 index 0000000..62c5af4 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/user_app_center.go @@ -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" } diff --git a/eai_agentplatform/backend-go/internal/model/worker_task.go b/eai_agentplatform/backend-go/internal/model/worker_task.go index c016b32..4d7336c 100644 --- a/eai_agentplatform/backend-go/internal/model/worker_task.go +++ b/eai_agentplatform/backend-go/internal/model/worker_task.go @@ -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"` diff --git a/eai_agentplatform/backend-go/internal/store/db.go b/eai_agentplatform/backend-go/internal/store/db.go index c768a2e..efa5146 100644 --- a/eai_agentplatform/backend-go/internal/store/db.go +++ b/eai_agentplatform/backend-go/internal/store/db.go @@ -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 +} diff --git a/eai_agentplatform/backend-go/internal/store/db_migration_test.go b/eai_agentplatform/backend-go/internal/store/db_migration_test.go new file mode 100644 index 0000000..ca8f9a4 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/db_migration_test.go @@ -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") + } +} diff --git a/eai_agentplatform/backend-go/internal/store/specialist_records.go b/eai_agentplatform/backend-go/internal/store/specialist_records.go index 255f395..12e2d8d 100644 --- a/eai_agentplatform/backend-go/internal/store/specialist_records.go +++ b/eai_agentplatform/backend-go/internal/store/specialist_records.go @@ -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 { diff --git a/eai_agentplatform/backend-go/knowledge_source/README.md b/eai_agentplatform/backend-go/knowledge_source/README.md index 4c5c909..68ac03e 100644 --- a/eai_agentplatform/backend-go/knowledge_source/README.md +++ b/eai_agentplatform/backend-go/knowledge_source/README.md @@ -35,7 +35,7 @@ 2. **三个固定 section**(按 `## ` 二级标题分块): - `## 结构化产品数据` → 摄入 `product` 表 - - `## AI 检索知识` → 摄入 `knowledge_chunk` 表(自然语言段落,供 MySQL FULLTEXT + LLM) + - `## AI 检索知识` → 摄入 `knowledge_chunk` 表(自然语言段落,供 Go 原生向量/关键词检索 + LLM) - `## 考试题目` → 摄入 `question` 表 ## 3. 题目答案契约(统一 list 形式) diff --git a/eai_agentplatform/backend-go/scripts/paddle_ocr_extract.py b/eai_agentplatform/backend-go/scripts/paddle_ocr_extract.py new file mode 100644 index 0000000..ed2b4de --- /dev/null +++ b/eai_agentplatform/backend-go/scripts/paddle_ocr_extract.py @@ -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() diff --git a/eai_agentplatform/frontend/package-lock.json b/eai_agentplatform/frontend/package-lock.json index 92d4cc6..9181138 100644 --- a/eai_agentplatform/frontend/package-lock.json +++ b/eai_agentplatform/frontend/package-lock.json @@ -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, diff --git a/eai_agentplatform/frontend/package.json b/eai_agentplatform/frontend/package.json index fd04eb8..5626fe4 100644 --- a/eai_agentplatform/frontend/package.json +++ b/eai_agentplatform/frontend/package.json @@ -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", diff --git a/eai_agentplatform/frontend/src/_frozen/FROZEN.md b/eai_agentplatform/frontend/src/_frozen/FROZEN.md deleted file mode 100644 index de6fcc1..0000000 --- a/eai_agentplatform/frontend/src/_frozen/FROZEN.md +++ /dev/null @@ -1,38 +0,0 @@ -# 冻结件登记表(FROZEN) - -本目录存放**已停用但完整保留**的代码:不参与构建、不被任何存活代码引用,但随时可回收。 - -冻结不是删除。删掉的东西要靠记忆和 git log 才能找回来;冻结的东西保持可读、可搜索、 -并在此处登记它的来龙去脉。**「解冻条件」这一列是核心** —— 没有它,这批代码半年后没人 -敢动,就从资产变成了负债。 - -## 约定 - -- 目录 `src/_frozen/`,所有冻结文件统一加 `.frozen` 后缀 -- 冻结件**不 import 存活代码以外的路径**;冻结件之间用相对引用,自成封闭单元 -- 冻结件保留原有注释与代码原貌,只在文件头加一段「原位置 / 冻结日期」说明 -- 冻结件内引用的 store / api **未必也冻结**(见下表「外部依赖」列),解冻前先核对 - -## 登记表 - -| 冻结物 | 原位置 | 为什么冻 | 外部依赖 | 解冻条件 | 解冻步骤 | -|---|---|---|---|---|---| -| `WorkspaceTabs.frozen.vue`
工作区胶囊条 + 通知铃铛 | `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` 内、`` 之前挂 `` 并 import。组件已自包含,无需改动内部代码 | -| `SectionTabs.frozen.vue`
旧二级导航(`el-tabs` 形态) | `src/layout/SectionTabs.vue` | 被 `src/components/SectionTabs.vue` 取代(新的由 `config/nav.js` 驱动,改为原生 button + 下划线)。**注意与存活的那个同名,勿混淆** | ✅ 存活:`@/store/auth`、`@/store/notification`
🔒 冻结:`./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`
旧侧边栏 | `src/layout/SideNav.vue` | 被 `MainLayout.vue` 内联的新侧边栏取代 | ✅ 存活:`@/store/auth`、`@/store/notification`
🔒 冻结:`./navigation.frozen.js` | 想把侧边栏从 `MainLayout.vue` 里拆成独立组件时 —— 旧版结构上比现在的内联写法更清晰 | 同 `SectionTabs.frozen.vue`:放回 `src/layout/`,import 改回 `@/config/navigation` 并还原 `navigation.frozen.js` | -| `navigation.frozen.js`
旧导航配置 | `src/config/navigation.js` | 被 `src/config/nav.js` 取代(`nav.js` 收敛了原先 `sidebarGroups` / `LibraryTabs` / `ExamTabs` 三份重复清单) | 无 | 随上面两个组件一起解冻 | 放回 `src/config/` 并去掉文件名里的 `.frozen` | -| `Notification.frozen.vue`
站内通知页面(175 行,实现完整) | `src/views/Notification.vue` | `router/index.js` 里没有 `/notifications` 路由,页面无入口,已成孤儿 | ✅ 存活:`@/api/notification`、`@/store/notification`
✅ 后端四个接口仍在(`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`) | diff --git a/eai_agentplatform/frontend/src/_frozen/Notification.frozen.vue b/eai_agentplatform/frontend/src/_frozen/Notification.frozen.vue deleted file mode 100644 index 6b21719..0000000 --- a/eai_agentplatform/frontend/src/_frozen/Notification.frozen.vue +++ /dev/null @@ -1,175 +0,0 @@ - - - - - diff --git a/eai_agentplatform/frontend/src/_frozen/SectionTabs.frozen.vue b/eai_agentplatform/frontend/src/_frozen/SectionTabs.frozen.vue deleted file mode 100644 index a6e2a2f..0000000 --- a/eai_agentplatform/frontend/src/_frozen/SectionTabs.frozen.vue +++ /dev/null @@ -1,120 +0,0 @@ - - - - - diff --git a/eai_agentplatform/frontend/src/_frozen/SideNav.frozen.vue b/eai_agentplatform/frontend/src/_frozen/SideNav.frozen.vue deleted file mode 100644 index b66c087..0000000 --- a/eai_agentplatform/frontend/src/_frozen/SideNav.frozen.vue +++ /dev/null @@ -1,229 +0,0 @@ - - - - - diff --git a/eai_agentplatform/frontend/src/_frozen/WorkspaceTabs.frozen.vue b/eai_agentplatform/frontend/src/_frozen/WorkspaceTabs.frozen.vue deleted file mode 100644 index 831cf62..0000000 --- a/eai_agentplatform/frontend/src/_frozen/WorkspaceTabs.frozen.vue +++ /dev/null @@ -1,179 +0,0 @@ - - - - - - diff --git a/eai_agentplatform/frontend/src/_frozen/navigation.frozen.js b/eai_agentplatform/frontend/src/_frozen/navigation.frozen.js deleted file mode 100644 index 0126c7e..0000000 --- a/eai_agentplatform/frontend/src/_frozen/navigation.frozen.js +++ /dev/null @@ -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 -} diff --git a/eai_agentplatform/frontend/src/api/ai.js b/eai_agentplatform/frontend/src/api/ai.js index 3478a2f..5b4bcf6 100644 --- a/eai_agentplatform/frontend/src/api/ai.js +++ b/eai_agentplatform/frontend/src/api/ai.js @@ -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) } diff --git a/eai_agentplatform/frontend/src/api/appCenter.js b/eai_agentplatform/frontend/src/api/appCenter.js new file mode 100644 index 0000000..d6ef1e3 --- /dev/null +++ b/eai_agentplatform/frontend/src/api/appCenter.js @@ -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) +} diff --git a/eai_agentplatform/frontend/src/api/capability.js b/eai_agentplatform/frontend/src/api/capability.js new file mode 100644 index 0000000..1e1faab --- /dev/null +++ b/eai_agentplatform/frontend/src/api/capability.js @@ -0,0 +1,5 @@ +import http from './http' + +export function getSkillDefinitionByKey(key) { + return http.get(`/skills/by-key/${key}`) +} diff --git a/eai_agentplatform/frontend/src/api/http.js b/eai_agentplatform/frontend/src/api/http.js index 97d9d4d..3ea25d2 100644 --- a/eai_agentplatform/frontend/src/api/http.js +++ b/eai_agentplatform/frontend/src/api/http.js @@ -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) diff --git a/eai_agentplatform/frontend/src/api/media.js b/eai_agentplatform/frontend/src/api/media.js index 5dcb278..69d0650 100644 --- a/eai_agentplatform/frontend/src/api/media.js +++ b/eai_agentplatform/frontend/src/api/media.js @@ -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', + }, + }) +} diff --git a/eai_agentplatform/frontend/src/api/officeSkill.js b/eai_agentplatform/frontend/src/api/officeSkill.js new file mode 100644 index 0000000..0077d0e --- /dev/null +++ b/eai_agentplatform/frontend/src/api/officeSkill.js @@ -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', + }, + }) +} diff --git a/eai_agentplatform/frontend/src/api/project.js b/eai_agentplatform/frontend/src/api/project.js new file mode 100644 index 0000000..0a8e617 --- /dev/null +++ b/eai_agentplatform/frontend/src/api/project.js @@ -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`) +} diff --git a/eai_agentplatform/frontend/src/api/system.js b/eai_agentplatform/frontend/src/api/system.js index 1c6d0e2..ade1947 100644 --- a/eai_agentplatform/frontend/src/api/system.js +++ b/eai_agentplatform/frontend/src/api/system.js @@ -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') } diff --git a/eai_agentplatform/frontend/src/components/NavSearch.vue b/eai_agentplatform/frontend/src/components/NavSearch.vue index 9b4040f..3ca4404 100644 --- a/eai_agentplatform/frontend/src/components/NavSearch.vue +++ b/eai_agentplatform/frontend/src/components/NavSearch.vue @@ -23,7 +23,7 @@