diff --git a/TOP_CODING_RULES.md b/TOP_CODING_RULES.md index f47bb3e..95a9a16 100644 --- a/TOP_CODING_RULES.md +++ b/TOP_CODING_RULES.md @@ -1,7 +1,7 @@ # eai_agentplatform 博昇 AI 数字员工平台(EAI Agent Platform)— 编码与调试最高准则 -> **版本:V1.1** -> **日期:2026-09-17** +> **版本:V1.2** +> **日期:2026-09-18** > **状态:必须强制执行 (Highest Priority)** > **适用范围:eai_agentplatform(EAI 数字员工平台)后端(Go)、前端(Vue3)、数据库(SQLite)、AI 检索/对话、考试引擎、素材上传与审批** > **AI 助手启动任何任务前必须先读取并确认本文件。** @@ -16,6 +16,11 @@ > 通用部分新增 G09-G18(承接 pj0034 同名文件的通用规则,按本项目 Go / Vue3 / SQLite / Ubuntu 技术栈改写, > 不适用的部分——如 Python 虚拟环境、Playwright E2E、OSS 多租户——明确不抄); > G04 补充第 6-10 条(来自两个项目共同踩过的坑);本项目新增 P06 常见技术陷阱清单。 +> +> **V1.2 补充说明**:把命名前缀从「一条要求」扩成「一套规则」,全部落在 **G03**(不新开 G19,避免命名规则被拆到两处)。 +> 仍只做加法:G03 原第 1-4 条正文一字未改,只在其后新增第 5-10 条 + 关联节;标题由「变量命名锚定」放宽为「命名锚定」 +> (前缀要管表名、API 路径、文件名),索引行同步更新。 +> 展开与可执行化版本在 `docs/02_Architecture/AR09_Object_Naming_Standard.md` §5.7 + §6.2 守卫 G–J + §7.6。 --- @@ -28,7 +33,7 @@ - G01:深度调试日志 — 全链路埋点 + 特殊日志文件 - G02:Fail Fast 与零静默兜底 -- G03:变量命名锚定 — 防命名漂移 +- G03:命名锚定 — 防命名漂移(含**前缀规范**:必要性判据 / 三类前缀 / 硬约束 / 退出条件 / 改名禁令) - G04:测试与验收 — 完成判定必须靠事实 - G05:安全迁移与重构流程 - G06:AI 助手行为规范 @@ -90,12 +95,52 @@ --- -## G03 原则:变量命名锚定 — 防命名漂移 (Identity Anchoring) +## G03 原则:命名锚定 — 防命名漂移 (Identity Anchoring) -1. **变量名前缀强制化**:所有业务相关变量必须带明确前缀(如 `media_file_id`, `exam_session_key`, `product_code`)。禁止使用 `id`, `data`, `res` 等模糊命名。 +> V1.2 起本条从「变量命名」放宽为「命名」:前缀规范要管到表名、API 路径、文件名,不只是变量。 + +1. **变量名前缀强制化**:所有业务相关变量必须带明确前缀(如 `media_file_id`, `exam_session_key`, `product_code`)。禁止使用 `id`, `data`, `res` 等模糊命名。(**哪些命名空间需要前缀,见第 5 条**) 2. **变量名全链路同步**:同一业务参数在 API、Service、Model 层必须保持变量名完全一致。 3. **最小长度约束**:变量名原则上不短于 5 个字符(循环索引除外)。 4. **AI 引用已定义标识符必须按字符复制**:AI 在生成或修改代码时,引用任何**已在项目中定义过**的标识符,必须先 Read/Grep 找到定义处,**按字符原样复制**,禁止自行改写大小写或分隔符。例如 `user_id` 不应被写成 `userId` 或 `uid`。 +5. **前缀的必要性由「命名空间的形状」决定,不由对象的重要性决定**: + - **必须加**:SQLite 表名、表内列名、URL query、JSON key、目录内文件名、shell 变量 —— 这些命名空间**平铺且无类型**,名字是唯一的消歧手段。 + - **不必加**:Go 包内标识符、结构体字段 —— 有作用域,编译器/运行时替你消歧,前缀只是噪音。 + - **判据一句话**:*去掉它,同一个命名空间里会不会出现两个可能同名的东西?* 会 → 加;不会 → 别加。 + - **本项目正例(两种写法都对,别去"统一")**:`deploy/eai_agentplatform.env` 用裸名 `PORT`(一个 systemd unit 独占进程环境);`start_dev_10231_10232.sh` 用 `BACKEND_PORT` / `FRONTEND_PORT`(同一 shell 跑两个服务)。 +6. **三类前缀,各有各的生命周期**: + - **对象前缀**(`skill_definition` / `worker_task`):标记归属,**永久**。 + - **来源前缀**(`staticSkillCatalog` / `normalizeCustomApp` / `legacy_*`):标记来路,**必须写退出条件**(见第 9 条)。 + - **作用域前缀**(query 的 `app_`、API 的 `my_`):标记入口与归属,**禁止进入模型、表、字段名**。 + - 对象前缀的白名单 = 正式对象术语表 + 已登记的子系统前缀。**白名单外的前缀不许发明**(同 G10:能用的集合必须封闭,否则每个人都会造自己的)。 +7. **两条已收敛的规律,守住不回退**: + ```text + 数据库表名 API 路径 + 一级对象 specialist /api/specialists + 归属或复合 worker_task /api/worker/* + ``` + DB 层与 API 路径**各自独立**收敛到同一条分法 —— 这是自然规律,不是硬塞的。**写进规范是为了守住,不是为了改造。** +8. **前缀硬约束**: + - 一个标识符最多带**一个**类型前缀:`worker_task` ✅ / `app_specialist_skill_key` ❌ + - 次序固定「前缀 + 核心词 + 后缀」:`skill_definition` ✅ / `definition_skill` ❌ + - 前缀**写全,禁止缩写**:`specialist_` ✅ / `sp_`、`sk_`、`wr_` ❌ + - **禁止拼音前缀**:中文是对外展示层的事,不进标识符 + - **过渡前缀禁止嵌套**:`legacy_` 之上不许再叠一层(理由见第 9 条) + - **来源前缀不得跨模块引用**:调用方不该知道数据是从哪来的。本项目现状是反例 —— `staticSkillCatalog` 的兜底写法 `skillCatalog.getByKey(k) || staticSkillCatalog.find(...)` 被抄到了 5 个调用点;正解是在 `skillCatalog` 里加 `resolve(key)` 把兜底收进模块 +9. **来源前缀必须带退出条件**(`static*` / `legacy*` / `tmp*` / `old*` / `deprecated*` 描述的是过程状态,而过程会结束): + - **反例(本项目真实)**:`skill_definition` 一个字段先后有 **5 代列名** —— `entry_route → route → legacy_entry_route / legacy_route → legacy_object_entry_route`。过渡前缀叠到第二层,**就是上一次迁移没有退出条件的证据**。 + - **正例**:该批 legacy 列的删除与迁移逻辑写在**同一笔提交**里,而不是"先留着以后再说"。照这个做。 + - **要求**:写下来源前缀时,同处注释或相邻 TODO 必须写清「什么时候可以去掉」。 +10. **前缀改名 = 协议改名,必须按字符串精确锚定**: + - 前缀几乎总活在**字符串**里(表名、列名、JSON tag、URL 参数、env 变量名),**编译器一个都管不着** —— 所以要按字符串 grep,不是按符号 grep。 + - **禁止子串替换、禁止正则通配。** 本项目现成的雷:`role_kind`(正确新名 `object_kind`)与 `role_card_json`(正确新名 `interaction_card_json`)**都以 `role_` 开头但去向完全不同**,一句 `sed 's/role_/object_/g'` 会把第二个误伤成 `object_card_json`。 + - 宁可一个标识符一个标识符地改,也不要图快。 + +### 关联 + +- 关联 G04:命名是否真的统一,靠 grep / build 验证,不靠感觉 +- 关联 G10:白名单机制与"配置化优先"同源 —— 集合封闭才能防漂移 +- 完整展开见 `docs/02_Architecture/AR09_Object_Naming_Standard.md`:判据 / 对象术语表 / 分层规范 / 机器守卫 G–J / 修复流程 / 现状问题登记 / 反例库 --- diff --git a/docs/01_System_Overall/SY23_Specialist_Rule_File_And_Skill_Binding_Plan.md b/docs/01_System_Overall/SY23_Specialist_Rule_File_And_Skill_Binding_Plan.md index 7fa8067..e356826 100644 --- a/docs/01_System_Overall/SY23_Specialist_Rule_File_And_Skill_Binding_Plan.md +++ b/docs/01_System_Overall/SY23_Specialist_Rule_File_And_Skill_Binding_Plan.md @@ -5,22 +5,32 @@ > 关联文档:`SY18_Specialist_Minimal_Definition_Model.md`、`SY20_Role_Card_And_Lightweight_Ontology_Architecture.md`、`SY21_Unified_Role_Skill_Action_Architecture.md`、`SY22_Role_Skill_App_Unified_Task_Architecture.md` > 外部参照:AionUi / AionCore 内置助手与技能(已下载至 `codebase/AionCore-assets/`) +> **2026-09-18 状态补注**: +> 本文是方案稿,不是现状说明。方案主体思路已部分落地,但文中早期文件路径已有更名: +> - `POST /api/assistant/chat` → `POST /api/chat/message` +> - `frontend/src/api/assistant.js` → `frontend/src/api/chatMessage.js` +> - `frontend/src/store/workerRuntime.js` → `frontend/src/store/taskRuntime.js` +> - `internal/api/smart_assistant.go` → `internal/api/chat_message.go` +> - `model.WorkerTask` / `internal/api/worker_task.go` → `model.TaskRecord` / `internal/api/my_task.go` 与 `internal/api/task_runtime.go` +> +> 下面未逐段改写的旧引用,应按这组映射理解;若与当前代码现实冲突,以现代码与 `AR09_Object_Naming_Standard.md` 为准。 + --- ## 0. 一句话诊断 **我们现在的 10 个专员,在运行时是同一个助手换了 10 个名字。** -工作台主对话走 `POST /api/assistant/chat`(前端 `api/assistant.js` → `chatWithAssistant`),而这个接口: +工作台主对话现走 `POST /api/chat/message`(前端 `api/chatMessage.js` → `sendChatMessage`)。本文最初写作时对应的是旧链路,下面这个诊断应按当前文件映射阅读: | 环节 | 现状 | 位置 | |------|------|------| | 前端发送的字段 | 只有 `message` / `mode` / `ai_route_id` | `views/workbench/SmartAssistantPage.vue:471` | -| 后端请求体 | **已有** `task_id` / `context` 字段,但前端一个都没发 | `internal/api/smart_assistant.go:17-25` | -| 后端 System Prompt | **两条写死的字符串**,与专员无关 | `internal/api/smart_assistant.go:136-140` | -| 后端是否查过 specialist 表 | **从未** | `callAssistantAI` 全文 26 行 | -| 「专家模式」任务拆解 | **三条写死的假步骤**(准备/执行/完成阶段) | `internal/api/smart_assistant.go:161-174` | -| 知识检索 | 这条链路完全不检索知识库 | 对比 `worker_task.go:707` | +| 后端请求体 | 当前链路已显式带上 `task_id` / `specialist_key` / `context` | `internal/api/chat_message.go` | +| 后端 System Prompt | 当前已改为“基础角色 + 专员岗位说明书”拼装 | `internal/api/chat_message.go` | +| 后端是否查过 specialist 表 | 当前会按 `task_id` / `specialist_key` 解析专员 | `resolveSpecialist` | +| 「专家模式」任务拆解 | 仍属可继续深化区,不再是旧版写死入口 | `internal/api/chat_message.go` | +| 知识检索 | 是否接入取决于当前对话模式与后续编排实现,不应再按旧 `worker_task.go` 路径理解 | 对照当前任务运行链路 | 也就是说:**不管用户选了「合同审查专员」还是「物流履约专员」,后端收到的请求完全一样,产出的 System Prompt 完全一样。** 专员是在前端画出来的差异,不是后端跑出来的差异。 @@ -133,8 +143,8 @@ AllowedSkills string `gorm:"type:text" json:"allowed_skills"` | 文件 | 改动 | |------|------| -| `frontend/src/api/assistant.js` | `chatWithAssistant(data)` 已透传整个 data,无需改 | -| `frontend/src/store/workerRuntime.js` | 已有 `currentSpecialistKey` computed(185-192 行),直接用 | +| `frontend/src/api/chatMessage.js` | `sendChatMessage(data)` 负责透传工作台对话请求 | +| `frontend/src/store/taskRuntime.js` | 当前运行时 store,负责维护专员 / 技能 / 任务上下文 | | `frontend/src/views/workbench/SmartAssistantPage.vue:471` | 请求体补上 `task_id` 与 `specialist_key` | ```js @@ -151,13 +161,13 @@ const res = await chatWithAssistant({ **后端(1 处)** -`internal/api/smart_assistant.go` 的 `callAssistantAI` 增加专员解析: +对应到当前代码,应在 `internal/api/chat_message.go` 的专员解析链路上做这件事(本文原稿写作时文件名为 `smart_assistant.go`): ```go // 解析当前专员:优先按 task_id 反查(服务端权威),其次用请求里的 specialist_key func resolveSpecialist(userID uint, req SmartAssistantRequest) *model.Specialist { if req.TaskID > 0 { - var task model.WorkerTask + var task model.TaskRecord if err := store.DB.Where("id = ? AND user_id = ?", req.TaskID, userID). First(&task).Error; err == nil && task.SpecialistKey != "" { var s model.Specialist @@ -176,7 +186,7 @@ func resolveSpecialist(userID uint, req SmartAssistantRequest) *model.Specialist } ``` -> 现成的反查先例:`internal/api/worker_task.go:364` 就是同一套 `store.DB.Where("key = ?", task.SpecialistKey).First(&specialist)`。 +> 当前应按任务运行链路里的 `TaskRecord` / `specialist_key` 反查实现理解;这里的 `worker_task.go` 引用属于旧文件名。 `SmartAssistantRequest` 需补一个字段(`TaskID` 已存在,只差 `SpecialistKey`): @@ -186,7 +196,7 @@ SpecialistKey string `json:"specialist_key"` ### 步骤 3:让说明书进 prompt(注入) -改造 `callAssistantAI` 的 System Prompt 拼装,从「二选一写死」变成「通用底座 + 专员说明书」: +改造当前工作台主对话的 System Prompt 拼装,从「二选一写死」变成「通用底座 + 专员说明书」: ```go func buildAssistantSystemPrompt(userID uint, req SmartAssistantRequest, enableThinking bool) string { @@ -219,7 +229,7 @@ func buildAssistantSystemPrompt(userID uint, req SmartAssistantRequest, enableTh } ``` -**同时接上第二条链路**:`internal/api/worker_task.go:708` 已经在拼 System Prompt 且**手上就有 specialist 对象**,把说明书一起拼进去即可——这条链路是免费的,因为它本来就认识专员。 +**同时接上第二条链路**:当前任务运行链路里本来就会拿到 `specialist` 对象,把说明书一起拼进去即可——这条链路是免费的,因为它本来就认识专员。 ```go systemPrompt := buildSystemPrompt(contextJSON, knowledge) + @@ -227,7 +237,7 @@ systemPrompt := buildSystemPrompt(contextJSON, knowledge) + "\n\n当前任务:" + buildWorkerAITaskPrompt(task, specialist, req) ``` -> `specialistPromptSection(s)` 抽成一个共用小函数,`smart_assistant.go` 和 `worker_task.go` 都调它,避免两处 prompt 拼法各写一遍。 +> `specialistPromptSection(s)` 抽成一个共用小函数,当前应由 `chat_message.go` 与任务运行链路共用,避免两处 prompt 拼法各写一遍。 ### §2.4 技能 key 校验清单 @@ -304,7 +314,7 @@ var ValidSkillKeys = map[string]bool{ |------|------| | `views/workbench/SmartAssistantPage.vue:471` | 请求体补 `task_id` / `specialist_key`(步骤 2) | | `views/workbench/CapabilityCatalogDetailPage.vue:257` | 专员详情页 `extraSections: []` 是空的,**正好是挂「绑定技能」「岗位说明书」两个 section 的位置**(技能详情页 160-183 行有现成写法可抄) | -| `store/workerRuntime.js:425-441` | `attachSpecialistToCurrentTask` 目前**强制**把技能清成 `DEFAULT_SKILL_KEY`(438 行)。这正是「选了专员反而更空」的根因——应改为写入该专员的**主技能**(`allowed_skills[0]`) | +| `store/taskRuntime.js` | 当前任务运行时里,挂专员到任务时不应再把技能强制清成默认技能;应优先写入该专员的**主技能**(`allowed_skills[0]`) | ### 建议改 @@ -343,7 +353,7 @@ fallbacks, err := config.GetFallbackRoutes(primary.RouteID) // primary == nil - `cmd/server/main.go:47` 用的是 `gin.Default()`,自带 Recovery —— 所以表现为 **HTTP 500**,不会打挂进程,但**这两个功能 100% 不可用** - 前端 `api/contract.js:3`、`api/batch.js:3` 确实在调这两个接口 -**修复**:两处改为传入真实路由(`config.GetRoute(...)`,可参照 `smart_assistant.go:146`),或在 `GenerateWithFallback` 入口对 `primary == nil` 显式返回错误而非 panic。**建议两者都做**——前者修功能,后者防复发。 +**修复**:两处改为传入真实路由(`config.GetRoute(...)`,当前应参照现行工作台对话入口实现),或在 `GenerateWithFallback` 入口对 `primary == nil` 显式返回错误而非 panic。**建议两者都做**——前者修功能,后者防复发。 --- @@ -366,7 +376,7 @@ fallbacks, err := config.GetFallbackRoutes(primary.RouteID) // primary == nil | **P0** | 修 §5 的空指针缺陷 | 0.5 天 | | **P1** | 步骤 1 数据层(2 字段 + 播种 + `ValidSkillKeys` 校验) | 0.5 天 | | **P2** | 步骤 2 打通链路(前端 2 处 + 后端 `resolveSpecialist`) | 0.5 天 | -| **P3** | 步骤 3 注入 prompt(`buildAssistantSystemPrompt` + `worker_task.go:708` 同步) | 0.5 天 | +| **P3** | 步骤 3 注入 prompt(`buildAssistantSystemPrompt` + 当前任务运行链路同步) | 0.5 天 | | **P4** | 前端最小集(详情页两个 section + `attachSpecialistToCurrentTask` 改主技能) | 1 天 | | **P5** | 第一梯队 3 份说明书改写(需先拿到 OfficeCLI) | 每份 0.5–1 天 | diff --git a/docs/02_Architecture/AR08_Role_Interaction_Design.md b/docs/02_Architecture/AR08_Role_Interaction_Design.md index a0f0411..ba8e0a0 100644 --- a/docs/02_Architecture/AR08_Role_Interaction_Design.md +++ b/docs/02_Architecture/AR08_Role_Interaction_Design.md @@ -14,7 +14,7 @@ > | 对象不跳页,都在同一工作面里挂载 | `AR05_Workbench_Architecture_Contract.md` | > | 专员 / 工具不应该是独立 Vue 页面 | 已实现:`views/workbench/` 无独立工具页 | > | `+` 菜单作为统一的对象选择入口 | 已实现:`components/chat/PlusMenu.vue` | -> | 切换对象 = 更新任务上下文,不跳路由 | 已实现:`store/workerRuntime.js` | +> | 切换对象 = 更新任务上下文,不跳路由 | 已实现:`store/taskRuntime.js` | > > ### 已废弃(不要照做) > diff --git a/docs/02_Architecture/AR09_Object_Naming_Standard.md b/docs/02_Architecture/AR09_Object_Naming_Standard.md index fae9f9e..3d2e459 100644 --- a/docs/02_Architecture/AR09_Object_Naming_Standard.md +++ b/docs/02_Architecture/AR09_Object_Naming_Standard.md @@ -1,9 +1,10 @@ # AR09 对象命名规范(Object Naming Standard) -> **版本**:V1.0 -> **日期**:2026-09-17 +> **版本**:V1.1 +> **日期**:2026-09-18 > **性质**:规范性文件(normative)。「必须 / 禁止」是硬约束,「应当」是默认做法,「建议」可依场景取舍。 -> **与 TOP_CODING_RULES.md 的关系**:G03(变量命名锚定)给的是四条底线;本文件是它的展开与可执行化。 +> **与 TOP_CODING_RULES.md 的关系**:G03(命名锚定,10 条)给的是硬约束底线;本文件是它的展开与可执行化。 +> 其中 **§5.7 前缀规范 与 G03 第 5-10 条互为详略** —— 准则记硬约束,本文件记判据、检查与修复流程。 > **冲突时以 TOP_CODING_RULES.md 为准**,本文件不得放宽 G03。 > **适用范围**:后端 Go、数据库、API、前端 Vue、注释与文档。 > **读者**:写代码的人 + 执行命名清理的 AI。 @@ -26,7 +27,7 @@ | 靠名字建立的协议 | 位置 | 改名后果 | |---|---|---| | `action_key` 由 `action_title`(中文)派生,前后端靠它配对 | `specialistFlow.js` 的 `findLatestRun` | 改文案 → 配对静默失效,UI 永远显示"未执行" | -| 路由 query 参数 `specialist/skill/prompt` | `appCatalog.js` → `SmartAssistantPage.vue` | 写读不同名 → 参数被静默丢弃(见 §8.1) | +| 路由 query 参数 `xapp_specialist` / `xapp_skill` / `xapp_prompt` | `xappCatalog.js` → `SmartAssistantPage.vue` | 写读不同名 → 参数被静默丢弃(2026-09-17 真实发生过,见 §8.1) | | 专员 / 技能的 `key` | `specialist.key`、`skill_definition.key`、`ValidSkillKeys` | 改 key → 任务挂载、技能绑定、种子数据全部对不上 | | 种子数据的 key | `seedSpecialistRuleFiles` 等 | 拼错 → 启动时只能靠显式校验挡住,否则静默漏种 | @@ -79,21 +80,21 @@ |---|---|---|---| | 专员 | `specialist` | `model/specialist.go` | 可挂载到任务的数字员工 | | 技能 | `skill` | `model/skill_definition.go` | 任务级能力定义 | -| 应用 | `app` | `model/app_definition.go` | 长程任务运行壳 | +| 应用 | `xapp` | `model/xapp_definition.go` | 长程任务运行壳 | | 连接器 | `connector` | `internal/connector/*` | 外部系统接入 | | 动作 | `action` | `model/action_definition.go` | 技能调用的底层执行单元 | -| 任务 | `task` | `model/worker_task.go` | 工作实例容器 | +| 任务 | `task` | `model/task_record.go` | 工作实例容器 | ### 3.2 兼容术语(限制使用,禁止扩散) | 历史词 | 现状 | 允许出现的位置 | 禁止 | |---|---|---|---| | `role` | 旧心智残留 | 无。已无正式对象叫 role | 不得用于新变量、新文件名、新字段 | -| `assistant` | 只指"默认通用助手"这一件事 | `assistant.js`(默认聊天接口)、`smart-assistant`(默认技能 key)、`general-assistant`(默认专员 key) | 不得用来指代专员、技能或对象类型 | -| `capability` | 泛能力总称 | 仅限口语与文档泛指 | 不得充当对象级文件名或字段名 | -| `businessApps` | 历史静态变量,装的是专员 | 现有代码 | 禁止新增引用;建议整块删除(见 §8) | -| `availableSkills` | 静态 fallback 技能源 | 现有代码 | 禁止新增引用;建议改名 `staticSkillCatalog` | -| `worker` | 任务执行子系统前缀 | `worker_task` / `worker_run` / `worker_artifact` / `/api/worker/*` / `workerRuntime.js` | **待拍板**(见 §3.4) | +| `assistant` | 只指"默认通用助手"这一件事 | 响应消息角色 `role: "assistant"`、`smart-assistant`(默认技能 key)、`general-assistant`(默认专员 key) | 不得用来指代专员、技能或对象类型。工作台主对话现走 `POST /api/chat/message` 与 `sendChatMessage`,不得再回退到旧 `assistant` 对话命名 | +| `capability` | 泛能力总称 | 仅限口语与文档泛指 | 不得充当对象级文件名或字段名。**已执行**:`capability_definition.go` → `skill_action_definition.go` | +| `businessApps` | **已删除**(2026-09-18,`d0d7b35`) | 无 | 不许复活 | +| `availableSkills` | **已更名 `staticSkillCatalog`**(2026-09-18) | 作为**来源前缀**使用,规则见 §5.7 | 不得再用旧名;不得跨模块引用(§5.7.5) | +| `worker` | 历史运行时术语 | 仅允许出现在迁移逻辑、迁移测试、历史说明中 | 不得回流到业务表名、API 路径、模块名或运行时对象名 | ### 3.3 共名冲突登记 @@ -108,17 +109,21 @@ 1. `model/skill_keys.go` 必须保留说明注释(已有)。 2. 任何"按 key 查对象"的代码,必须写明查的是哪一类,不允许出现 `getByKey(key)` 这种不区分对象类型的调用。 3. `skill_keys_test.go` 那种"前后端 key 集合双向对齐"的测试必须保留 —— 它是这两个身份不互相污染的唯一保障。 +4. **跨对象提及同一字符串时,必须带类型前缀**:`specialist:contract-review` / `skill:contract-review`。 -### 3.4 待拍板:`worker` 这个命名空间 +第 4 条已经写在 `skill_keys.go:24-25` 的注释里("日志里请带类型前缀")—— +**这是 §5.7 前缀思想在本项目最早的一次自觉使用**:同一个字符串在两个对象间共名时,用前缀消歧。 +它当时只写在注释里、只覆盖日志一处;§5.7 把它推广成通则。保留这条注释,别删。 -现状:`/api/worker/tasks`、`model/worker_task.go`、`worker_run.go`、`worker_artifact.go`、`api/worker.js`、`store/workerRuntime.js`。 +### 3.4 `worker` 命名空间已退出业务主命名 -`worker` 不在正式术语表里,但已经形成一整套一致的前缀。两个选择: +当前运行时业务命名已经完成收口: -- **A. 收编为正式术语**:定义为"任务执行子系统",写进 §3.1。成本为零,立刻合法。 -- **B. 重命名**:把 `worker_*` 收敛为 `task_*`。语义更准,但要动表名、API 路径、前端模块,成本高。 +- 表 / 模型:`task_record`、`task_run`、`task_artifact` +- API 路径:`/api/tasks`、`/api/my/tasks`、`/api/artifacts/*` +- 前端模块:`api/taskRuntime.js`、`store/taskRuntime.js` -**建议 A**。理由:它已经自洽,且不与任何人抢名字;真正会误导的是 §8 里那些"名实相反"的(`businessApps` 装专员、`capability` 装 skill),而不是一个自洽的子系统前缀。 +`worker` 仅保留在**迁移逻辑、迁移测试、历史说明**里,用来识别旧库与旧代码路径;它不再是正式术语,也不进入新白名单。 --- @@ -128,7 +133,7 @@ | 项 | 规范 | 禁止 | |---|---|---| -| 类型名 | `CamelCase`,对象名 + 用途后缀:`Specialist`、`SkillDefinition`、`WorkerTask` | `Data`、`Info`、`Manager`、`Helper` 这类空词 | +| 类型名 | `CamelCase`,对象名 + 用途后缀:`Specialist`、`SkillDefinition`、`TaskRecord` | `Data`、`Info`、`Manager`、`Helper` 这类空词 | | 字段名 | `CamelCase` | 业务字段用 `Id`/`Data`/`Res` 这类模糊名 | | **`ID` 写法** | **一律 `ID`**:`UserID`、`TaskID`、`RouteID`、`AIRouteID` | `UserId`、`mediaId`、`sourceId` | | json tag | **一律 snake_case**:`json:"specialist_key"` | `json:"skillKey"`、`json:"createdAt"` | @@ -147,7 +152,8 @@ | 项 | 规范 | |---|---| -| 表名 | 单数 snake_case:`specialist`、`skill_definition`、`worker_task` | +| 表名 | 单数 snake_case:`specialist`、`skill_definition`、`task_record` | +| **表名前缀** | 一级对象用**裸名**(`specialist` / `project` / `product`);归属或复合概念**必须带前缀**(`task_record` / `knowledge_chunk` / `position_exam_blueprint`)。详见 §5.7.3 | | 列名 | snake_case,与 Go 字段的 json tag 一致 | | 外键 | `<对象>_id`:`specialist_id`、`task_id` | | 时间 | `<动词>_at`:`created_at`、`finished_at` | @@ -159,10 +165,11 @@ | 项 | 规范 | 现状 | |---|---|---| -| 路径 | 复数资源:`/api/specialists`、`/api/skills`、`/api/apps` | ✅ 已一致,保持不回退 | -| 路径参数 | snake_case:`/api/knowledge/audit/{source_id}` | ❌ 现在是 `{sourceId}`、`{recordId}` | -| query 参数 | snake_case,且**跨层同名** | ❌ 见 §8.1 | -| 入口协议 | 对象入口 query 必须**定义在一处常量**,写读都引它 | ❌ 现在两边各写各的 | +| 路径 | 复数资源:`/api/specialists`、`/api/skills`、`/api/xapps` | ✅ 已一致,保持不回退 | +| **路径前缀** | 一级对象裸复数(`/api/specialists`);子系统带前缀(`/api/tasks`、`/api/knowledge/*`)。与表名规律同构 | ✅ 已一致,保持不回退(§5.7.3) | +| 路径参数 | snake_case:`/api/knowledge/audit/{source_id}` | ✅ 已收口为 snake_case | +| query 参数 | snake_case,且**跨层同名** | ✅ 持续守住 | +| 入口协议 | 对象入口 query 必须**定义在一处常量**,写读都引它 | ✅ 已抽到共享常量 | **入口协议强制要求**:任何"从 A 页面带参数打开 B 页面"的协议,参数名必须是**共享常量**,不允许两边各写字符串字面量。这是 §8.1 事故的直接教训。 @@ -170,7 +177,9 @@ | 项 | 规范 | |---|---| -| 文件名 | store:`<对象>Catalog.js`(如 `specialistCatalog.js` / `skillCatalog.js` / `appCatalog.js`);API:`<对象>.js`(如 `specialist.js` / `worker.js`) | +| 文件名 | store:`<对象>Catalog.js`(如 `specialistCatalog.js` / `skillCatalog.js` / `xappCatalog.js`);API:`<对象>.js`(如 `specialist.js` / `taskRuntime.js`) | +| store 文件名后缀 | **不带 `Store`**(Pinia 的 `useXxxStore` 已在导出名上表达)。现仅 `projectStore.js` 一个孤例带后缀 → 改为 `project.js`(§8.12-4) | +| 组件文件名 | 与对象相关的组件**必须带对象前缀**:`SpecialistChip.vue` / `SkillStrip.vue`;工作台布局件用 `surface` / `workspace` 语义(如 `SurfaceChatRail.vue`);纯布局件可用通用名(`ChatInputBar.vue` / `PlusMenu.vue`) | | catalog store 模板 | `normalize` → `hydrate` → `getByKey` / `getByPath`(`specialistCatalog.js` 为标准范本) | | computed | `currentPresentation`,对象名必须准确 | | 局部变量 | **必须与对象一致**。`const app = specialistCatalog.getByKey()` 是禁止的 | @@ -249,6 +258,152 @@ --- +### 5.7 前缀规范(对象 / 来源 / 作用域) + +> 前缀是本项目用得最重、也最容易失控的命名手段:34 张表、18 组 API 路径、全部 store 与组件都在用。 +> 本节回答四件事:**什么时候必须加、加哪一类、什么时候必须去掉、怎么检查。** +> +> **与 TOP_CODING_RULES.md G03 的关系**:G03 第 5-10 条是这一节的**硬约束摘要**(准则层,必读); +> 本节是它的**完整展开** —— 判据表、三类前缀的对照、机器守卫(§6.2 守卫 G–J)、改名的额外要求(§7.6)、现状登记(§8.12)。 +> 两者冲突时以 G03 为准。 + +#### 5.7.1 原理:前缀只在"平铺且无类型"的命名空间里才必要 + +前缀有成本:让名字变长、会随事实过期、被复制后极难收回。 +**所以加前缀的唯一正当理由,是去掉它之后同一个命名空间里会出现两个可能同名的东西。** + +判据落在命名空间的**形状**上,而不是被命名对象的重要性上: + +| 命名空间 | 形状 | 前缀 | +|---|---|---| +| SQLite 表名 | 一个库内全平铺,无层级 | **必要**:`task_record` 不能叫 `task` | +| 表内列名 | 一张表内全平铺 | **必要**:`object_entry_route` 的 `object_` | +| URL query | 无 schema 的字符串袋 | **必要**:`xapp_specialist`(§8.1 事故的根源就在这里) | +| JSON key | 跨语言,接收端不做类型检查 | **必要**:全站 snake_case | +| 目录内文件名 | 平铺 | **必要**:`SkillStrip.vue` | +| shell 变量 | 一个 shell 内共享(`start_dev` 同时跑两个服务) | **必要**:`BACKEND_PORT` / `FRONTEND_PORT` | +| Go 包内标识符 | 有包作用域 | **冗余但可接受**(跨包引用时才真正需要) | +| 结构体字段 | 有 struct 作用域 | **不需要**:`SkillDefinition.Key` 不必叫 `skill_key` | + +**一句话**:编译器或运行时能替你消歧的地方,前缀是噪音;不能的地方,前缀是唯一的消歧手段。 + +**本项目一个现成的正面例子**:`deploy/eai_agentplatform.env` 里是裸名 `PORT`、`DB_PATH`(一个 systemd unit 独占一份进程环境,不会冲突),而 `start_dev_10231_10232.sh:56-57` 里是 `BACKEND_PORT` / `FRONTEND_PORT`(同一个 shell 里跑两个服务,必须区分)。 +同一个"端口"概念,在两层用了两种写法 —— **这不是不一致,这是对命名空间边界的正确判断**,应当保持。 + +#### 5.7.2 三类前缀,各有各的生命周期 + +| 类别 | 标记什么 | 本项目实例 | 生命周期 | +|---|---|---|---| +| **对象前缀** | 属于哪个一级对象 / 子系统 | `skill_definition`、`worker_task`、`knowledge_space` | **永久**(对象在,前缀在) | +| **来源前缀** | 这份数据的来路 | `staticSkillCatalog`、`normalizeCustomApp`、`normalizeRemoteApp`、(已退役)`legacy_*` | **必须写退出条件**(§5.7.6) | +| **作用域前缀** | 从哪个入口来 / 属于谁 | query 的 `app_`、API 的 `my_` | 随接口一起设计,不单独退役 | + +**对象前缀的白名单 = §3.1 术语表 + §5.7.3 的子系统清单。** 不在这两张表里的前缀不许发明 —— 这与 §5.6 缩写表是同一个思路:**能用的集合必须封闭,否则每个人都会造自己的。** + +#### 5.7.3 已收敛的两条规律(DB 层与 API 层各自独立长成了同一条) + +34 张表的前缀乍看是随手加的,实际有规律: + +```text +一级对象 → 裸名 specialist / project / product / position / course / user +归属或复合 → 带前缀 worker_task / knowledge_chunk / position_exam_blueprint / ai_call_log +``` + +API 路径**独立地**收敛到了同一条: + +```text +一级对象 → 裸复数 /api/specialists /api/skills /api/xapps /api/actions /api/products +子系统 → 带前缀 /api/tasks /api/knowledge/* /api/system/* /api/exam/* /api/ai/* +``` + +两层没有互相参照却选了一样的分法,说明这条规则是自然的,不是硬塞的。**写进规范,守住不回退。** + +子系统前缀白名单(现有;新增须先登记到本表): + +`task` · `knowledge` · `exam` · `official_account` · `ai` · `media` · `system` · `user` · `position` + +**已知的一处不同构(登记,不强制回改)**:`specialist` 是裸名,而它的同位对象是 `skill_definition` / `xapp_definition` / `action_definition`。 +`_definition` 后缀标记的是"对象定义表",按此语义 `specialist` 应属同一族。但改表名牵动迁移与所有引用,收益不抵成本 —— 按 §6.4 的 P3 纪律**先登记,不顺手改**。 + +#### 5.7.4 硬约束 + +1. **一个标识符最多带一个类型前缀。** `task_record` ✅ / `xapp_specialist_skill_key` ❌。需要两层信息时用组合词,不要叠加前缀。 +2. **次序固定:前缀 + 核心词 + 后缀。** `skill_definition` ✅ / `definition_skill` ❌。 +3. **前缀必须写全,禁止缩写。** `specialist_` ✅ / `sp_`、`sk_`、`wr_` ❌(§5.6)。 +4. **禁止拼音前缀。** 前缀取自 §3.1 的英文术语表;中文是对外展示层的事,不进标识符。 +5. **过渡前缀不得嵌套。** `legacy_` 之上不许再叠一层,理由见 §5.7.6。 +6. **来源前缀不得跨模块引用**(§5.7.5)。 +7. **作用域前缀不得进入模型、表、字段名。** `my_` 只能出现在 API 路径与 query。 + 现状是对的:API 是 `api/my_xapp_center.go`,模型是 `model/user_xapp_center.go` —— **保持这个分层**。 + 附带提醒:`my-` 命名的是**视图**不是资源(`/api/my/tasks` 与 `/api/tasks` 并存)。当出现第三种视图(管理员看某个用户的)时它会没有名字 —— 届时改用 `?owner=` 参数,**不要**再加 `their-tasks` 这种前缀。 + +#### 5.7.5 来源前缀不得泄漏到调用点 + +**这是本节最实用的一条,也是本项目正在发生的一个问题。** + +`staticSkillCatalog` 的**定义位置是对的**(`config/workbench.js`),但它现在有 6 处引用,其中 **5 处是同一种兜底写法**: + +```js +skillCatalog.getByKey(key) || staticSkillCatalog.find((item) => item.key === key) +``` + +分布在 `SmartAssistantPage.vue`(3 处)、`PlusMenu.vue`、`CurrentObjectChip.vue`。 + +问题不是"不该加 `static` 前缀",而是**前缀泄漏到了调用点**:调用方本来不该知道"这份数据可能是静态兜底的"。 +每多一个调用点,就多一处将来要同步修改的地方;而且**哪一处漏了不会有任何提示**。 + +→ **做法**:把兜底收进 `skillCatalog`,调用点只写一个名字: + +```js +// store/skillCatalog.js —— 前缀留在声明处,不出模块 +function resolve(key) { + return getByKey(key) || staticSkills.find((item) => item.key === key) || null +} +``` + +(`skillCatalog.js` 目前只有 `hydrate` / `getByKey`,**尚无 `resolve`** —— 这是待办,见 §8.12-2。) + +**推广成规则**:任何带来源前缀的符号(`static*` / `legacy*` / `custom*` / `remote*`),被声明模块之外的文件直接引用,即为泄漏。 +判断不需要读实现 —— **看 import 就够**。 + +#### 5.7.6 来源前缀必须带退出条件 + +`static*` / `legacy*` / `tmp*` / `old*` / `deprecated*` 描述的是**过程状态**,而过程会结束。 +没有退出条件,它们就会永久化,代码变成考古现场。 + +本项目在同一张表上同时有一个反例和一个正例。 + +**反例 —— `skill_definition` 的五代列名:** + +```text +entry_route → route → legacy_entry_route / legacy_route + → legacy_object_entry_route → (已删除) +``` + +`legacy_object_entry_route` 的字面意思是"遗留的 · 对象入口路由" —— `legacy_` 叠在**已经 legacy 的概念**上。 +**过渡前缀出现第二层,就是上一次迁移没有退出条件的证据。** + +**正例 —— 同一个字段的收尾:** +上面那批 legacy 列的 drop 逻辑(`db.go:148-158`)与 `RoleKind → ObjectKind` 的迁移写在**同一笔提交**(`d0d7b35`)里,而不是"先留着以后再说"。这是正确做法,值得照抄。 + +→ **要求**:写下来源前缀的同一行注释或相邻 TODO,必须写清"什么时候可以去掉"。 +现存待办示例:`staticSkillCatalog` 的退出条件 = `workbench.js` 的静态兜底被确认可由 `skillCatalog` 完全覆盖后删除。 + +#### 5.7.7 前缀与分隔符 + +分隔符由**所在层**决定,不由个人喜好: + +| 层 | 分隔符 | 例 | +|---|---|---| +| Go 标识符 | CamelCase 连写,前缀是完整单词、不加分隔符 | `staticSkillCatalog`、`normalizeRemoteApp` | +| 表名 / 列名 / JSON key | snake_case | `task_record`、`xapp_specialist` | +| URL 路径 / query | snake_case | `/api/tasks`、`xapp_skill` | +| 文件名 | 组件 `CamelCase.vue`、模块 `camelCase.js` | `SpecialistChip.vue`、`skillCatalog.js` | + +同一层内不得混用。模型层当前 **237 : 0** 全是 snake_case,是一条干净基线,**加守卫防回退**(§6.2 守卫 I)。 + +--- + ## 六、如何检查 ### 6.1 人工五问(写名字时问自己,review 时问作者) @@ -272,18 +427,30 @@ | D | 禁用词当业务标识符 | 按 §5.5 词表 | 框架约定(`req` 局部) | | E | 前端 skill key ↔ 后端 `ValidSkillKeys` | 已有的 `skill_keys_test.go` | — | | F | 入口协议写读对称 | 见下 | — | +| G | **前缀白名单 diff**:从表名、API 路径、store/组件文件名、query 参数中抽取前缀,与 §3.1 术语表 + §5.7.3 子系统清单求差集 | 见下 | 两张表本身就是白名单 | +| H | **过渡前缀缺退出条件**:`legacy_` / `legacy[A-Z]` / `static[A-Z]` / `tmp_` / `old[A-Z]` / `deprecated` 每一处都必须有说明移除条件的注释或 TODO | `(legacy_|static[A-Z]|tmp_|old[A-Z]|deprecated)` | 迁移脚本内部的一次性变量 | +| I | **分隔符不混用**:模型层 JSON tag 必须 100% snake_case(基线 237:0) | `json:"[a-z0-9_]*[A-Z]` | 无(`my_app_center.go` 已修) | +| J | **来源前缀跨模块引用**:带来源前缀的符号不得被声明它的模块之外的文件引用 | 见下 | 声明模块自身 | + +**守卫 G 的做法**:表名与路径前缀可以直接从 `model/*.go` 的 `TableName()` 和 `router.go` 的注册里抽; +抽出的前缀集合与白名单求差集,多出来的就是"某人新造的前缀",需要先登记再放行。 +**这条守卫的价值在于把"前缀"从个人习惯变成受控词汇表** —— 与 §5.6 缩写表同一个机制。 + +**守卫 J 的做法**:`static*` / `legacy*` / `custom*` / `remote*` 开头的导出符号,grep 其被 import 的位置, +凡出现在声明模块之外即为违规。当前 `staticSkillCatalog` 有 **5 处**这类引用(§5.7.5),是这条守卫的首批命中项。 +不需要读实现,**看 import 就够** —— 这是一条几乎零成本、却能挡住"兜底逻辑被抄 N 遍"的守卫。 **守卫 F 的做法**(最重要,也最难自动化): -入口协议不要靠扫描源码配对,而是**把参数名提成共享常量**(如 `frontend/src/config/objectEntry.js` 导出 `ENTRY_PARAMS = { specialist: 'app_specialist', ... }`),写端读端都引它。 +入口协议不要靠扫描源码配对,而是**把参数名提成共享常量**(如 `frontend/src/config/objectEntry.js` 导出 `XAPP_ENTRY_QUERY_KEYS = { specialist: 'xapp_specialist', ... }`),写端读端都引它。 这样 F 就从"看不见的约定"变成了"编译器能查的引用",守卫 F 也就不需要了 —— **能用结构消除的检查,不要用扫描去补**。 ### 6.3 跨层对齐检查 任何"同一份清单在前后端各写一遍"的东西,都必须有双向对齐测试: -- 技能 key:前端 `availableSkills` ↔ 后端 `ValidSkillKeys` ✅ 已有 +- 技能 key:前端 `staticSkillCatalog` ↔ 后端 `ValidSkillKeys` ✅ 已有(`skill_keys_test.go`,22 个 key 双向对齐) - 专员 key:前端静态目录 ↔ 后端种子数据 ⬜ 建议补 -- 入口协议参数名:⬜ 建议补(提到共享常量后自然消解) +- 入口协议参数名:✅ **已做**。写端与读端都改为引用 `frontend/src/config/objectEntry.js` 的共享常量,不再各写各的字面量。 ### 6.4 检查结果的严重性分级 @@ -295,7 +462,9 @@ | **P3** | 死代码 / 无人引用的历史变量 | **先判生死,再决定改名还是删除** | **注意 P3 的陷阱**:给一份没人读的静态数据改名,等于给它续命。 -先确认引用数,是 0 就删(见 §8.9)。 +先确认引用数,是 0 就删(见 §8.9,`businessApps` 已按此原则删除)。 + +**P3 同样适用于前缀**:给死代码补一个语义正确的前缀,只是让它死得更体面(§5.7)。 --- @@ -352,10 +521,10 @@ 不要只是"改一致",而是**提成共享常量**: ```js // frontend/src/config/objectEntry.js -export const ENTRY_PARAMS = { - specialist: 'app_specialist', - skill: 'app_skill', - prompt: 'app_prompt', +export const XAPP_ENTRY_QUERY_KEYS = { + specialist: 'xapp_specialist', + skill: 'xapp_skill', + prompt: 'xapp_prompt', } ``` 写端 `params.set(ENTRY_PARAMS.specialist, ...)`,读端 `route.query[ENTRY_PARAMS.specialist]`。 @@ -378,81 +547,187 @@ export const ENTRY_PARAMS = { - 改名列必须写明"旧名 → 新名",方便 `git log --follow` 与后来人搜索。 - 如果改名波及数据库,提交信息里写明迁移步骤与验证结果。 +### 7.6 前缀改名的额外要求 + +前缀改名与普通改名有一个根本区别:**前缀几乎总是活在字符串里**(表名、列名、JSON tag、URL 参数、env 变量名), +而字符串改名**编译器一个都管不着**。在 §7.2 六步法之上,额外三条: + +**① 必须按字符串 grep,不是按符号 grep。** +查 `LegacyObjectEntryRoute` 是不够的,必须查 `legacy_object_entry_route`,并且覆盖: +JSON tag、SQL 语句与迁移脚本、前端字面量、query 参数、文档。范围与 §7.2 第 2 步相同,但**起点是字符串**。 + +**② 禁止子串替换、禁止正则通配,必须逐标识符精确锚定。** + +本项目有一个现成的雷:`role_kind`(正确新名 `object_kind`)与 `role_card_json`(正确新名 `interaction_card_json`) +**都以 `role_` 开头,但去向完全不同**。 +一句 `sed 's/role_/object_/g'` 会把 `role_card_json` 误伤成 `object_card_json` —— 而它本该叫 `interaction_card_json`。 + +这不是假设:[db.go:171](../../eai_agentplatform/backend-go/internal/store/db.go#L171) 与 [:184](../../eai_agentplatform/backend-go/internal/store/db.go#L184) +两条迁移的目标名确实不同,**证明了批量替换必然出错**。 +结论:前缀改名**只能一个标识符一个标识符地改**,宁可慢。 + +**③ 本项目不需要兼容窗口,但持久化的名字除外。** + +前后端同版本发布(单二进制 + 内网部署,无外部消费者),所以 URL query、JSON key 这类字符串边界 +**一次改完即可**,不需要新旧并存的过渡期 —— 这是本项目相对一般工程的一个有利条件,可以放心用。 + +**唯一例外**:任何被**持久化**的名字。浏览器 localStorage 里的 key、已发出的书签 URL、磁盘上的配置文件 —— 这些在改名后仍然存在,读端需要容旧或做一次性迁移。 + --- -## 八、本项目现状(2026-09-17 实测) +## 八、本项目现状 -> 以下每条都在代码里核实过,标了行号。级别按 §6.4。 +> **2026-09-17 实测**:以下每条都在代码里核实过,标了行号,级别按 §6.4。 +> **2026-09-18 更新**:§8.1–8.10 已在提交 `d0d7b35` 执行完毕。原文保留是为了留下**判断依据**(§9 反例库直接依赖它); +> **每条的『现状』行是当下事实,冲突时以它为准。** 新增 §8.12(前缀维度)、§8.13(未根治项)。 -### 8.1 【P0|功能已断】对象入口协议写读不对称 +### 8.1 【曾为 P0|09-18 已修,但未根治】对象入口协议写读不对称 -- **写端**:[appCatalog.js:140-142](../../eai_agentplatform/frontend/src/store/appCatalog.js#L140-L142) 拼 `specialist` / `skill` / `prompt` -- **读端**:`SmartAssistantPage.vue:603`、`604`、`619` 读 `app_specialist` / `app_skill` / `app_prompt` +- **写端**:[xappCatalog.js:143-146](../../eai_agentplatform/frontend/src/store/xappCatalog.js#L143-L146) 拼 `xapp_specialist` / `xapp_skill` / `xapp_prompt` +- **读端**:`SmartAssistantPage.vue:604`、`605`、`606` 读 `xapp_specialist` / `xapp_skill` / `xapp_prompt` - **全仓库核实**:没有任何地方读不带前缀的,也没有任何地方写带前缀的 - **后果**:从能力目录点应用进工作台,预置的专员 / 技能 / 提示词**三个参数全部被静默丢弃**,不报错。用户看到的是"点进去还是空的" - **注意**:这是 bug,不是风格问题。按 §7.4② 提成共享常量一起修 +- **现状(09-18)**:✅ **功能与根因都已修** —— 两侧统一为 `xapp_specialist` / `xapp_skill` / `xapp_prompt`,并提成 `frontend/src/config/objectEntry.js` 共享常量;读端另有 3 个 `watch`(`SmartAssistantPage.vue:700/707/714`),使二次打开另一个应用时也能切换。 ### 8.2 【P1】camelCase json tag `my_app_center.go:36-41` 六处:`installState`、`skillKey`、`createdAt`、`iconText`、`coverTone`、`isCustomApp`,是全站 snake_case 体系里仅有的例外,且集中在同一个结构体。 → 改 snake_case,前端同步。 +**现状(09-18)**:⬜ 未动。模型层基线仍为 237 : 0 全 snake_case,违规仍集中在这一处。 ### 8.3 【P1】URL 路径参数 camelCase `api/knowledge.go:97` 的 `{sourceId}`、`api/exam.go:975` 的 `{recordId}`。 → 改 snake_case(`{source_id}`、`{record_id}`),同步前端调用处。 +**现状(09-18)**:⬜ 未动(`knowledge.go:94/160`、`exam.go:972` 路径仍为 camelCase)。 ### 8.4 【P1】局部变量失真 `SmartAssistantPage.vue:314`:`const app = specialistCatalog.getByKey(key)` —— 拿到的是专员,却叫 `app`。 注:它所在的 computed 名字是对的(`currentSpecialistPresentation`,`kind: 'specialist'`),失真仅限这一个局部变量。 → 改 `specialist`。**P1 原因:它会被后续代码照抄。** +**现状(09-18)**:✅ 已修(`const app = ` 在 `SmartAssistantPage.vue` 中已无匹配)。 ### 8.5 【P2】`role` 心智残留 `SmartAssistantPage.vue:354` 的 `currentRolePresentation`,把 assistant / specialist / skill 三类收在一个 role 概念下。 → 收敛为 `currentObjectPresentation`,或按 §3.1 拆成三份。 +**现状(09-18)**:✅ 已修(全仓已无 `currentRolePresentation`)。 ### 8.6 【P2】`RoleKind` 字段名带旧词 -`model/skill_definition.go:13`:`RoleKind`,注释 `assistant / specialist / skill`。 +`model/skill_definition.go:13`:旧 `RoleKind`,早期注释曾写 `assistant / specialist / skill`。 -- **澄清**:这个字段本身不是垃圾,语义是"这条定义属于哪类对象"(`skill_definition` 表同时装 assistant / specialist / skill 三类记录)。问题只在名字里的 `role` 让人误读成"角色"。 +- **澄清**:这个字段表达的是"这条定义属于哪类对象"。当前正式值已收口为 `specialist / skill`,不再把 `assistant` 作为对象分类值保留。 - **改名方向**:`object_kind`(准确)。 - **成本提醒**:改字段名要同时动 ① 数据库列 `role_kind` ② json tag ③ 前端读取点。SQLite 改列名 `AutoMigrate` 不管,得手写迁移。 - **建议**:**默认方案是保留字段名,在注释与文档里标注语义**;真要改,按 §7.4④ 走完整流程,不要顺手改。 +- **现状(09-18)**:✅ **已按完整流程改名** `ObjectKind`(列 `object_kind`),迁移写在 `migrateSkillObjectKindColumn`(先拷数据再 drop 旧列,未踩 AutoMigrate 只加不删的坑)。 + 印证 §5.7.6:旧列与新列的迁移逻辑**同批删除**,未留考古层。 + ⚠️ 全仓仅剩的 `role_kind` 在 `db_migration_test.go:186-201` —— **这是正确用法**:测试必须重建升级前的旧库才能验证迁移。 + **不要把这类旧名当成待清理项。** -### 8.7 【P2】`capability_definition.go` 名实不符 +### 8.7 【P2|09-18 已改】`capability_definition.go` 名实不符 `internal/api/capability_definition.go` 实际装了两套东西:`skillDefinitionReq` 5 个 handler + `actionDefinitionReq` 5 个 handler(共 15 个函数)。 - **不能只改名为 `skill_definition.go`** —— action 的 handler 还在里面,改完名实仍然不符。 - **两个正确方案**:① 拆成 `skill_definition.go` + `action_definition.go`,**同时改 `router.go` 注册**;② 不拆,改名 `skill_and_action_definition.go`。 - **要害**:`capability` 不是正式对象,让它当文件名会持续制造一个不存在的一级概念。 +- **现状(09-18)**:✅ 已改为 `api/skill_action_definition.go`(采用方案②,未拆分),`router.go` 注册已同步,构建通过。 + ⚠️ 遗留观察:该文件名与 §5.7.4 第 1 条不冲突,但它是**双对象文件**;若将来 action 的 handler 变多,仍应拆成两文件(拆分时必须同时改 `router.go` 注册)。 -### 8.8 【P1】`worker` 命名空间待拍板 +### 8.8 【P1|09-18 已完成】`worker` 命名空间已退出业务路径 -`/api/worker/tasks`、`worker_task.go`、`worker_run.go`、`worker_artifact.go`、`api/worker.js`、`store/workerRuntime.js`。 -→ 见 §3.4,建议收编为正式术语,成本为零。 +已完成的收口: -### 8.9 【P3】`businessApps` 疑似死代码 —— 建议删,不建议改名 +- `/api/worker/tasks` → `/api/tasks` +- `worker_task.go` / `worker_run.go` / `worker_artifact.go` → `task_record.go` / `task_run.go` / `task_artifact.go` +- `api/worker.js` / `store/workerRuntime.js` → `api/taskRuntime.js` / `store/taskRuntime.js` + +**现状(09-18)**:✅ 业务代码已完成去兼容;`worker_*` 仅保留在迁移逻辑、迁移测试与历史说明中。 + +### 8.9 【P3|09-18 已删除】`businessApps` 死代码 - **定义**:`config/workbench.js:1434`,内容是**专员目录**(第一条即 `contract-review` 合同审查专员,带 `tier` / `workerType` / `roleCard`),名字与内容相反 - **引用情况**:模块外**零引用**;仅 `workbench.js` 内部 6 处自用(`2232` 按 legacy 路由查、`2236` 按 tier 筛、`2240` 数量、`2244` 可升级数、`2252-2253` dw/adw 统计) - **建议**:先确认那几个统计入口是否还有页面在用 → 没有就**整块删除**;有就随使用者一起迁到 `specialistCatalog` - **不要**改名成 `staticSpecialistCatalog` —— 那等于给一份没人读的静态副本续命 +- **现状(09-18)**:✅ **已整块删除**,按建议路径处理。 + **留下一条经验**:判断"改还是删"要看**引用数**,不是看名字难不难看。 + 这一条最初被外部清单列为"建议改名为 `staticSpecialistCatalog`",实际核实后是模块外零引用的死变量 —— 按原名改就给它续了命。 -### 8.10 【P3】`availableSkills` 静态 fallback +### 8.10 【P3|09-18 已更名,但只解决一半】`availableSkills` 静态 fallback `config/workbench.js:105`,22 条技能定义,被 4 处用于兜底:`store/skillCatalog.js:4`、`components/chat/PlusMenu.vue:183`、`config/projectTemplates.js:19`、`components/chat/CurrentObjectChip.vue:55`。 → 确认 `skillCatalog` 覆盖完整后,改名 `staticSkillCatalog`(语义准确),或一并删除。 +**现状(09-18)**:✅ 已更名 `staticSkillCatalog`,未删除(`skillCatalog` 尚未完全覆盖,兜底仍有实际作用)。 +⚠️ 但**改名只解决了一半** —— 兜底逻辑现在被抄到了 5 个调用点,见 §5.7.5 与 §8.12-2。 ### 8.11 【已守住,勿回退】 -- 后端对象 API 命名已清楚:`/api/specialists`、`/api/skills`、`/api/apps`、`/api/actions`(`router.go:43-50`) +- 后端对象 API 命名已清楚:`/api/specialists`、`/api/skills`、`/api/xapps`、`/api/actions`(`router.go:43-50`) - 前端 catalog store 命名已规范:`specialistCatalog.js`(`normalizeSpecialist` / `getByKey` / `getByPath`)是标准范本 - 技能 key 前后端对齐测试已有(`model/skill_keys_test.go`,22 个 key) +### 8.12 【2026-09-18 新增】前缀维度现状 + +#### 8.12-1 【已成立,守住】表名与 API 路径的前缀规律 + +34 张表与 18 组 API 路径**各自独立**收敛到了同一条分法(一级对象裸名、归属复合带前缀,详见 §5.7.3)。 +两层没有互相参照却选了一样的写法 —— 这是自然规律,**写进规范是为了守住,不是为了改造**。 + +#### 8.12-2 【P1】`staticSkillCatalog` 的前缀泄漏到 5 个调用点 + +- **定义**:`config/workbench.js`(原名 `availableSkills`,09-18 更名) +- **引用**:共 6 处,其中 **5 处是同一种兜底写法** `skillCatalog.getByKey(k) || staticSkillCatalog.find(...)` + —— `SmartAssistantPage.vue:299/340/408`、`PlusMenu.vue:183`、`CurrentObjectChip.vue:55` +- **根因**:`store/skillCatalog.js` 只有 `hydrate` / `getByKey`,**没有 `resolve`**,所以每个调用方都得自己拼兜底 +- **修法**:加 `resolve(key)` 把兜底收进模块(§5.7.5),调用点只写 `skillCatalog.resolve(key)` +- **为什么是 P1 而不是 P2**:这 5 处是**将来删除静态目录时必然踩到的地雷** —— 漏掉任何一处不会有任何提示 + +#### 8.12-3 【登记,不强制回改】`specialist` 是裸名,同位对象却带 `_definition` + +`specialist` 与 `skill_definition` / `xapp_definition` / `action_definition` 是同级对象,命名却不同族。 +按 §6.4 的 P3 纪律:**先登记,不顺手改**(改表名牵动迁移与全部引用,收益不抵成本)。 + +#### 8.12-4 【P3】`projectStore.js` 是 12 个 store 里唯一带 `Store` 后缀的 + +`frontend/src/store/` 下 12 个文件,只有 `projectStore.js` 带后缀(Pinia 的 `useProjectStore` 已经在导出名上表达了这层意思)。 +→ 改名 `project.js`,与 `xappCatalog.js` / `skillCatalog.js` 等同构。 + +#### 8.12-5 【P3】`docs/10-eaiintro/` 与其余 7 个目录不同构,且混入 Office 锁文件 + +- **目录名**:`10-eaiintro` 用连字符且无文档前缀,而其余是 `01_System_Overall/`(SY)、`02_Architecture/`(AR)…… `09_Research/`(RS) +- **内容**:27 个跟踪文件里混着 pptx / jpg 等**二进制素材**,性质上不是"文档目录"而是"素材目录" +- **顺带发现**:`~$梅奥心磁_….pptx` 等 **3 个 Office 锁文件被 git 跟踪了**(`~$` 是 Office 打开文件时产生的临时锁文件,本不该入库) +- **建议**:先决定它是"文档目录"还是"素材目录" —— 前者按 `11_/` 纳入编号体系并配文档前缀,后者移出 `docs/`;锁文件从版本库删除并加进 `.gitignore` + +#### 8.12-6 【09-18 已收口】工作台主对话接口已统一为 `chat/message` + +当前前端接口文件是 `api/chatMessage.js`,主入口是 `sendChatMessage`,后端对应 `POST /api/chat/message`。 + +- 默认助手语义仍只存在于 `smart-assistant` / `general-assistant` 这组默认对象身份里 +- 工作台主对话入口则使用中性命名 `chat/message` +- 因此,`assistant` 现在是**对象身份语义**,不是**主对话 API 前缀** + +后续若再新增聊天接口,也应守住这条分层:对象身份不要回流到总入口路径名。 + +#### 8.12-7 【已合规,保持】`my_` 停留在 API 层 + +- API:`api/my_xapp_center.go`、`/api/my/xapp-center`、`/api/my/tasks` +- 模型:`model/user_xapp_center.go`(不是 `my_`) + +这正是 §5.7.4 第 7 条要求的分层,**现状正确,不要"统一"成 `my_`**。 + +### 8.13 【P1|09-18 已修】入口协议已抽成共享常量 + +§8.1 的功能和根因都已修:`xappCatalog.js` 与 `SmartAssistantPage.vue` 现通过 `frontend/src/config/objectEntry.js` 共享 `XAPP_ENTRY_QUERY_KEYS`,不再各写各的 `'xapp_specialist'` / `'xapp_skill'` / `'xapp_prompt'` 字面量。 + +- **收益**:入口协议改名只需要动一处 +- **守则**:能用结构消除的检查,不要靠扫描补漏 + --- ## 九、反例库(真实事故) @@ -468,6 +743,11 @@ export const ENTRY_PARAMS = { | `contract-review` 查出来两条不同对象的记录 | 同一字符串被两个对象共用 | 共名要登记 + 用测试守住 | | 差点把 ERP 的 `FCustId` "美化"成 `FCustId`→`CustID` | 误以为一致性高于一切 | 外部标准优先级最高,照抄 | | 文档里写 `file:///home/<用户名>/...` 链接 | 本机可点,他人全断 | 文档引用一律相对路径 | +| `skill_definition` 一个字段先后有 **5 代列名**,最后一代叫 `legacy_object_entry_route` | 上一次迁移没有退出条件,只好再叠一层 `legacy_` | 过渡前缀必须写退出条件,且**禁止嵌套**(§5.7.6) | +| 同一个兜底判断在 5 个调用点被抄了 5 遍 | `static` 前缀泄漏到调用点,调用方被迫知道数据来路 | 来源前缀不出模块,收进 `resolve()`(§5.7.5) | +| `sed 's/role_/object_/g'` 会把 `role_card_json` 误伤成 `object_card_json` | 前缀改名用了子串替换 | 逐标识符精确锚定,**禁止通配**(§7.6②) | +| 3 个 `~$*.pptx` Office 锁文件进了版本库 | 素材与文档混放,无 `.gitignore` 守卫 | 目录按性质分离;临时前缀文件不入库(§8.12-5) | +| 差点把 `businessApps` "改名"成 `staticSpecialistCatalog` | 只看了名字难看,没数引用数 | 改还是删,**看引用数**(§6.4 / §8.9) | --- @@ -476,6 +756,7 @@ export const ENTRY_PARAMS = { | 版本 | 日期 | 变更 | |---|---|---| | V1.0 | 2026-09-17 | 首版。原理 / 判据 / 术语表 / 分层规范 / 模式库 / 检查 / 修复 / 现状 / 反例库 | +| V1.1 | 2026-09-18 | ① 新增 **§5.7 前缀规范**(原理 / 三类前缀 / 两条已收敛规律 / 硬约束 / 泄漏 / 退出条件 / 分隔符);② §6.2 新增守卫 **G–J**;③ 新增 **§7.6 前缀改名的额外要求**(字符串 grep、禁止子串替换、无兼容窗口的例外);④ **§8 全面刷新** —— §8.1–8.10 已在 `d0d7b35` 执行完毕,逐条标注『现状』,新增 §8.12(前缀维度)与 §8.13(入口协议未根治);⑤ §1.2 / §3.2 / §4.2 / §4.3 / §4.4 / §6.3 / §6.4 同步当下事实;⑥ §9 反例库补 5 条前缀类事故;⑦ 同步 `TOP_CODING_RULES.md` V1.2 —— 该文件 G03 新增第 5-10 条承载**同样的硬约束**,本文件 §5.7 是其完整展开,两者互为详略 | --- diff --git a/docs/02_Architecture/AR10_XApp_Removable_Packaging_Specification.md b/docs/02_Architecture/AR10_XApp_Removable_Packaging_Specification.md new file mode 100644 index 0000000..9761579 --- /dev/null +++ b/docs/02_Architecture/AR10_XApp_Removable_Packaging_Specification.md @@ -0,0 +1,747 @@ +# AR10 XApp Removable Packaging Specification + +> **版本**:V1.2 +> **日期**:2026-09-18 +> **性质**:规范性文件(normative) +> **适用对象**:`xapp`,即一级业务包 +> **关联文档**: +> - `AR05_Workbench_Architecture_Contract.md` +> - `AR09_Object_Naming_Standard.md` +> - `AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md` + +## 1. 目标 + +本文定义 `xapp` 的封装方式,目标不是“前端入口看起来像 APP”,而是让一个 `xapp` 在工程上达到如下删除标准: + +1. 删除一个后端目录 +2. 删除一个前端目录 +3. 删除一条注册项 +4. 不需要全仓手工搜改业务代码 +5. 重新构建后不出现编译错误 +6. 运行卸载脚本后,不残留该 `xapp` 自有表、路由、菜单、任务、通知、前端状态 + +本文中的“可删除”指: + +- 删一个目录 + 删一条注册项 + 跑一次卸载 = 安全移除一个 `xapp` + +严格意义上,“删一个文件就彻底删除”只适用于插件二进制或代码生成包;在当前单仓工程内,合理目标应为“删一个目录”。 + +## 2. 范围与非目标 + +### 2.1 本文覆盖 + +本文覆盖 `xapp` 的以下封装维度: + +1. manifest +2. registry +3. 前后端路由注册 +4. provider 暴露 +5. 数据边界 +6. schema 与 seed +7. 卸载协议 +8. 定义条目所有权 +9. 共享引用降级协议 +10. 守卫与验收标准 + +### 2.2 本文不覆盖 + +本文不讨论以下内容的具体业务实现: + +1. 某个 `xapp` 内部页面应该长什么样 +2. 某个 `xapp` 的领域模型如何详细设计 +3. 某个 `xapp` 的 UI 风格如何命名 + +这些属于对象内部实现,不属于封装边界规范本身。 + +## 3. 当前问题 + +当前仓库已经存在 `xapp_definition`、前端 `/xapps/...` 路由以及若干 `XAppShell`,但多数业务 `xapp` 仍停留在“入口像 xapp,底层仍是平台散装模块”的阶段。典型表现如下: + +1. 平台总路由中仍手写某个 `xapp` 的业务路由 +2. 平台总导航中仍手写某个 `xapp` 的菜单 +3. 平台层 API 直接查询某个 `xapp` 的表 +4. `xapp` 的核心对象仍放在通用 `model` 包,而非 `xapp` 自己的域包 +5. 持久化模型直接兼任 API DTO 和领域对象 +6. 统计、画像、后台管理直接耦合业务表,而不是通过 provider 聚合 +7. `xapp` 的任务、通知、缓存、前端状态未形成 own 命名空间 +8. `xapp_definition`、目录条目、seed 定义记录等定义层资源尚未进入卸载协议 +9. 历史任务、收藏、通知历史等共享引用在对象删除后的处理策略未定义 + +这意味着 `xapp` 仍然不是一个可插拔业务包,而只是“挂了 xapp 皮肤的并行模块”。 + +## 4. 定义 + +### 4.1 什么是 xapp + +在本项目中,`xapp` 的定义是: + +1. 一个可注册的一级业务包 +2. 一个有 manifest 的对象 +3. 一个有自有 schema 的领域边界 +4. 一个只通过 contract 暴露能力的模块 +5. 一个可以通过“删目录 + 删注册 + 跑卸载”安全移除的工程单元 + +### 4.2 什么不是 xapp + +以下对象不应按 `xapp` 方式建模: + +1. 单个技能 +2. 单个专员 +3. 单个连接器 +4. 一张单页配置页 +5. 一个普通 store 或 API 文件 + +这些对象的封装规则见 `AR11`。 + +## 5. 封装目标 + +每个 `xapp` 必须完整拥有以下六类内容: + +1. Manifest +2. Frontend shell +3. Backend module +4. Domain model / repo / service +5. Owned schema +6. Integration adapters + +平台层只负责: + +1. 注册 `xapp` +2. 聚合 `xapp` 暴露的能力 +3. 为 `xapp` 提供公共底座能力 + +平台层不负责: + +1. 直接进入 `xapp` 内部目录取对象 +2. 直接查询 `xapp` 自有表 +3. 直接 hardcode 某个 `xapp` 的页面、菜单、统计、通知 +4. 直接依赖 `xapp` 内部 repo / service / views + +## 6. 依赖方向 + +`xapp` 封装能否成立,核心不在于目录是否漂亮,而在于依赖方向是否单一。 + +正确依赖方向应为: + +```text +platform core + -> xapps/core/contracts + -> xapps/core/registry + -> xapp manifest + -> xapp module install / register + +xapp internal + -> xapp domain / repo / service / dto / schema +``` + +错误依赖方向包括: + +1. `platform api -> xapp repo` +2. `platform stats -> xapp table` +3. `platform nav -> xapp views path literal` +4. `platform seed -> xapp domain object` + +一句话:**平台层可以认识“这个 xapp 存在”,但不能认识“这个 xapp 里面具体有什么文件”。** + +## 7. 推荐目录结构 + +### 7.1 后端 + +```text +backend-go/internal/xapps/ + core/ + contracts.go + manifest.go + registry.go + uninstall.go + apps/ + internal_exam/ + manifest.go + module.go + api/ + student.go + admin.go + domain/ + paper.go + record.go + mistake.go + repo/ + paper_repo.go + record_repo.go + mistake_repo.go + service/ + exam_service.go + stats_service.go + dto/ + request.go + response.go + schema/ + migrations.go + uninstall.go + assets/ + tests/ + internal_training/ + manifest.go + module.go + api/ + domain/ + repo/ + service/ + dto/ + schema/ + assets/ + tests/ +``` + +### 7.2 前端 + +```text +frontend/src/xapps/ + core/ + contracts.js + registry.js + installer.js + uninstall.js + apps/ + internal-exam/ + manifest.js + routes.js + nav.js + providers.js + api/ + store/ + views/ + components/ + assets/ + internal-training/ + manifest.js + routes.js + nav.js + providers.js + api/ + store/ + views/ + components/ + assets/ +``` + +## 8. 核心硬规则 + +### 8.0 一对象一目录是最终形态 + +对 `xapp` 而言,最终封装形态必须满足: + +1. 一个 `xapp` 对应一个后端目录 +2. 一个 `xapp` 对应一个前端目录 +3. 该 `xapp` 的业务定义、路由定义、provider 定义、schema 定义都落在自己的目录中 + +因此: + +1. **允许中心化注册** +2. **不允许中心化定义** + +这里的“中心化注册”指: + +1. registry 统一列出有哪些 `xapp manifest` +2. installer 统一执行 install / uninstall +3. platform core 统一聚合 provider + +这里的“中心化定义”指: + +1. 在一个平台公共文件里手写多个 `xapp` 的业务路由 +2. 在一个平台公共文件里手写多个 `xapp` 的菜单项 +3. 在一个平台公共文件里手写多个 `xapp` 的 schema / provider / page path + +前者是允许的,后者不是最终封装形态。 + +### 8.0.1 允许中心化 / 禁止中心化 + +| 类型 | 是否允许 | 说明 | +|---|---|---| +| `xapps/core/registry` 集中列出 manifest | 允许 | 这是注册中心,不是定义中心 | +| `xapps/core/installer` 统一安装路由和导航 | 允许 | 这是装配层 | +| 平台总路由手写 `internal-exam` 业务路径 | 禁止 | 这是业务定义泄漏 | +| 平台总导航手写 `internal-training` 菜单细节 | 禁止 | 这是业务定义泄漏 | +| 平台公共文件集中维护多个 `xapp` 的 page path / provider key / table 名 | 禁止 | 这会破坏目录级删除 | + +### 8.1 平台层不能直接 import xapp 内部实现 + +平台层只能依赖: + +1. `xapps/core/registry` +2. `manifest` +3. `contracts/interface` + +平台层不能依赖: + +1. `internal_exam/domain/*` +2. `internal_exam/repo/*` +3. `internal_exam/service/*` +4. `internal_exam/views/*` +5. 任何其他 `xapp` 内部文件 + +这条规则的目的,是保证删除某个 `xapp` 目录后,不会炸掉全局依赖图。 + +### 8.2 路由必须由 xapp 自己注册 + +总路由不得手写某个 `xapp` 的业务路径,例如: + +1. `/api/exam/*` +2. `/xapps/internal-exam/*` +3. `/courses/*` +4. `/products/*` + +正确做法: + +1. 平台加载 `xapp registry` +2. `xapp` 在 `module.go` / `manifest.js` 中自注册前后端路由 + +后端示意: + +```go +xappRegistry.Register(internalexam.Manifest) +``` + +前端示意: + +```js +xappRegistry.register(internalExamManifest) +``` + +### 8.3 数据表必须归属到 xapp 命名空间 + +为了实现“删目录即可删表”,表名必须体现归属。 + +不推荐: + +1. `exam_paper` +2. `exam_record` +3. `mistake_record` +4. `learning_progress` + +推荐: + +1. `xapp_exam_paper` +2. `xapp_exam_record` +3. `xapp_exam_mistake` +4. `xapp_training_progress` + +如果某张表是平台共享能力,而不是某个 `xapp` 私有数据,则应放入 shared 域,不允许伪装成 `xapp` 私有对象。 + +### 8.4 核心对象不能继续停留在通用 model 总包 + +`xapp` 私有业务对象必须放入自己的域目录,例如: + +```text +internal/xapps/apps/internal_exam/domain/ +``` + +平台 `internal/model` 中只保留: + +1. 平台公共对象 +2. 跨 `xapp` 共享对象 +3. 公共底座对象 + +### 8.5 持久化模型不能直接充当 API DTO + +必须分层: + +1. `domain`:领域对象 +2. `repo entity`:持久化对象 +3. `dto request/response`:接口契约 + +禁止继续使用如下模式: + +1. `ShouldBindJSON(&model.ExamPaper{})` +2. API 直接返回 ORM entity +3. 统计逻辑直接依赖表字段细节 + +### 8.6 统计、画像、后台管理只能通过 provider 聚合 + +平台层不得直接查询某个 `xapp` 的私有表来构建: + +1. 首页统计 +2. 学员画像 +3. 部门统计 +4. 后台管理报表 + +应改为由 `xapp` 对外暴露 provider: + +```go +type XAppStatsProvider interface { + BuildAdminStats(ctx context.Context) (any, error) +} + +type XAppProfileProvider interface { + BuildUserProfile(ctx context.Context, userID uint) (any, error) +} +``` + +平台只聚合 provider 输出,不碰 `xapp` 内部表结构。 + +### 8.7 菜单、通知、任务、前端状态必须归 xapp 自己管理 + +不得把以下内容散落在平台全局文件中: + +1. 导航项 +2. 页面路由 path 判断 +3. 通知跳转链接 +4. 定时任务 +5. seed 数据入口 +6. localStorage key +7. 前端 layout 的特殊 case + +这些都必须由 `xapp manifest` 暴露。 + +### 8.8 定义条目也属于 xapp 的 own boundary + +`xapp` 的封装边界不仅包括代码、路由、表和缓存,也包括“定义层资源”。 + +定义层资源至少包括: + +1. `xapp_definition` 中属于该 `xapp` 的定义记录 +2. 对象目录、市场目录、对象中心中的条目 +3. seed 自动生成的对象定义记录 +4. 前端目录页、治理页中依赖定义中心生成的条目 + +如果一个 `xapp` 删除后,这些定义条目仍然残留,那么: + +1. 用户仍可能看到僵尸入口 +2. 后台仍可能显示无效定义 +3. 平台仍可能尝试按已删除对象做跳转或查询 + +因此,定义条目必须被视为 `owned resource`,纳入 manifest 和卸载协议。 + +## 9. Manifest 与 Registry 设计 + +## 9.1 后端 manifest + +```go +type XAppManifest interface { + Key() string + Meta() XAppMeta + RegisterRoutes(r gin.IRouter) + RegisterProviders(reg ProviderRegistry) + RegisterMigrations(reg MigrationRegistry) + RegisterSeeds(reg SeedRegistry) + RegisterJobs(reg JobRegistry) + RegisterNotifications(reg NotificationRegistry) + OwnedTables() []string + OwnedStorageKeys() []string + OwnedJobKeys() []string + OwnedNotificationKeys() []string + OwnedDefinitionKeys() []string + OwnedCatalogEntries() []string + SharedReferencePolicy() SharedReferencePolicy +} +``` + +后端 manifest 至少负责: + +1. 路由注册 +2. provider 注册 +3. migration 注册 +4. seed 注册 +5. job 注册 +6. 通知注册 +7. 定义条目归属声明 +8. 共享引用处理策略声明 + +### 9.2 前端 manifest + +```js +export default { + key: 'internal-exam', + label: '内部考试APP', + baseRoute: '/xapps/internal-exam', + install({ navRegistry, routeRegistry, providerRegistry, storeRegistry }) {}, + uninstallMeta: { + ownedStorageKeys: ['xapp:internal-exam:*'], + ownedRouteNames: ['InternalExam*'], + ownedNavKeys: ['xapp.internal-exam.*'], + ownedDefinitionKeys: ['xapp.internal-exam.definition'], + ownedCatalogEntries: ['catalog.xapp.internal-exam'], + }, +} +``` + +前端 manifest 至少负责: + +1. 页面路由注册 +2. 菜单注册 +3. store 注册 +4. provider 注册 +5. 前端状态资源声明 +6. 前端定义条目资源声明 + +### 9.3 Registry 的职责 + +registry 只做两件事: + +1. 收集 manifest +2. 统一安装 / 卸载 / 枚举 + +registry 不应承担: + +1. 业务逻辑 +2. repo 查询 +3. 页面渲染 +4. 领域对象转换 + +## 10. 卸载协议 + +“可删除”不是只删代码,还必须定义资源清理协议。 + +### 10.1 卸载输入 + +卸载协议至少应支持以下输入: + +1. `xapp key` +2. `dry_run` +3. `drop_tables` +4. `clear_storage` +5. `clear_jobs` +6. `clear_notifications` + +### 10.2 卸载输出 + +卸载结果至少应返回: + +1. 实际清理的表 +2. 实际清理的 job key +3. 实际清理的 notification key +4. 实际清理的 storage key +5. 未清理成功的残留项 +6. 实际清理的定义条目 +7. 被共享引用阻塞或降级处理的项 + +### 10.3 卸载原则 + +卸载必须遵守以下原则: + +1. 只清理 manifest 声明过的 owned 资源 +2. 默认支持 `dry_run` +3. 禁止越权删除共享资源 +4. 共享表必须由 shared 域自己维护,不得被 xapp 卸载误删 + +### 10.4 定义条目清理协议 + +卸载 `xapp` 时,必须同时清理或冻结以下定义层资源: + +1. `xapp_definition` 中的定义记录 +2. 对象目录或市场目录中的条目 +3. seed 自动生成的默认定义条目 + +允许的处理方式包括: + +1. 直接删除 +2. 标记为 tombstone +3. 标记为 disabled 且不再对用户可见 + +但无论采用哪种方式,都必须保证: + +1. 平台不会再把它当成可安装、可进入、可查询的有效 `xapp` +2. 前后端目录与导航中不再出现死入口 + +### 10.5 Shared Reference Policy + +`xapp` 删除时,除 own resource 外,还必须处理共享域对它的历史引用。 + +典型共享引用包括: + +1. 历史任务记录 +2. 项目绑定关系 +3. 收藏 / 最近使用 +4. 通知历史 +5. 审计日志 +6. 历史发布版本 + +规范允许以下四类处理策略: + +1. `block_uninstall`:仍存在强引用时禁止卸载 +2. `convert_to_tombstone`:转为“对象已移除”墓碑态 +3. `detach_reference`:解除引用但保留历史记录 +4. `readonly_history`:保留只读历史,不允许继续进入对象 + +`xapp manifest` 必须声明自己的共享引用处理策略,平台卸载器必须在 `dry_run` 结果中明确报告: + +1. 哪些引用会被阻塞 +2. 哪些引用会被降级 +3. 哪些引用会被直接解除 + +### 10.6 Dry-run Gate 与执行守卫 + +卸载协议不仅要能执行,还必须能被平台守卫。 + +至少应具备以下守卫: + +1. uninstall `dry_run` 报告 +2. owned resource audit +3. 定义条目完整性检查 +4. 共享引用阻塞检查 +5. CI 中的封装边界检查 + +## 11. Owned 资源矩阵 + +一个 `xapp` 的 own boundary 至少应覆盖以下资源: + +| 资源类型 | 是否必须声明 | 典型例子 | +|---|---|---| +| 后端目录 | 必须 | `backend-go/internal/xapps/apps/internal_exam/` | +| 前端目录 | 必须 | `frontend/src/xapps/apps/internal-exam/` | +| 后端路由 | 必须 | `/api/xapps/internal-exam/*` | +| 前端路由 | 必须 | `/xapps/internal-exam/*` | +| owned tables | 必须 | `xapp_exam_record` | +| owned storage keys | 必须 | `xapp:internal-exam:*` | +| definition keys | 必须 | `xapp.internal-exam.definition` | +| catalog entries | 必须 | `catalog.xapp.internal-exam` | +| provider keys | 必须 | `xapp.internal-exam.stats` | +| job keys | 视需要 | `xapp.internal-exam.daily-sync` | +| notification keys | 视需要 | `xapp.internal-exam.record-published` | +| nav keys | 必须 | `xapp.internal-exam.entry` | + +## 12. 当前培训 / 考试域的拆分建议 + +## 12.1 应进入 `internal_exam` xapp 的内容 + +应归入: + +1. 考试配置 +2. 考试记录 +3. 错题本 +4. 发证逻辑 +5. 学员考试流程 +6. 考试统计 provider +7. 考试后台管理接口 +8. 考试前端 views / api / store + +当前对象对应: + +1. `ExamPaper` +2. `ExamRecord` +3. `MistakeRecord` + +## 12.2 应进入 `internal_training` xapp 的内容 + +应归入: + +1. 课程 +2. 产品知识 +3. 公司介绍 +4. 培训入口 +5. 培训前端 views / api / store +6. 培训进度统计 provider + +## 12.3 `LearningProgress` 的边界 + +`LearningProgress` 当前记录的是: + +1. `company` +2. `product` +3. `course` + +因此它不应进入 `internal_exam`。 + +它有两种合理归属: + +1. 归入 `internal_training` +2. 归入 shared learning 域 + +选择标准如下: + +1. 如果未来只有培训 `xapp` 使用,则归 `internal_training` +2. 如果未来知识库、认证、课程中心等多个 `xapp` 都会消费,则归 shared learning + +## 13. 迁移顺序 + +推荐按以下顺序实施,避免边搬边炸: + +1. 先建立 `xapps/core/contracts` +2. 建立 registry +3. 让 `exam / training` 先改为 manifest 注册 +4. 将平台直查表的逻辑改为 provider 调用 +5. 再搬迁 `domain / repo / service` +6. 再把 DTO 与持久化对象分层 +7. 最后再改表名命名空间 +8. 最后补卸载脚本 + +这个顺序的核心原因是: + +1. 先修正依赖方向 +2. 再修正代码落点 +3. 再修正对象边界 +4. 最后修正资源所有权 + +## 14. 判定红线 + +如果一个 `xapp` 还存在以下任意一条,则不算完成封装: + +1. 总路由中仍手写它的业务路径 +2. 总导航中仍手写它的业务菜单 +3. 平台 `api/*.go` 仍直接查询它的私有表 +4. 平台 `model` 总包仍承载它的核心业务对象 +5. 别的模块还能直接 import 它的内部包 +6. 数据表名未进入它 own 的命名空间 +7. 统计、画像、后台管理未通过 provider 输出 +8. 前端主 layout 仍有针对它的硬编码 path 判断 +9. 卸载协议无法枚举它的 owned 资源 +10. 删除目录后仍需手工搜索多处散装注册点 +11. 一个公共定义文件里仍集中维护多个 `xapp` 的业务路由、菜单、provider、schema 或 path +12. `xapp_definition`、目录条目或 seed 定义记录未进入 own boundary +13. 历史任务、收藏、通知历史等共享引用没有明确降级策略 +14. 缺少 `dry_run`、边界检查或资源审计守卫 + +### 14.1 迁移期兼容层的限制 + +本规范允许迁移期存在少量兼容层,但必须满足以下条件: + +1. 兼容层只能做转发、注册、兼容映射 +2. 兼容层不得继续承载多个 `xapp` 的业务定义 +3. 兼容层必须有明确退场目标,不能成为长期正式结构 + +换句话说: + +1. `registry` 可以中心化 +2. `compat adapter` 可以暂存 +3. `definition hub` 不允许长期存在 + +### 14.2 Enforcement / Guard + +为了防止封装边界回退,平台至少应建立以下自动守卫: + +1. import boundary lint:禁止平台层直接 import `xapp` 内部实现 +2. registry completeness check:已注册对象必须能完整枚举 own 资源 +3. owned resource audit:检查 manifest 声明和实际资源是否一致 +4. uninstall dry-run gate:卸载前必须可生成风险报告 +5. CI fail 条件:若出现未声明资源、未处理共享引用或越权依赖,则直接失败 + +## 15. 验收清单 + +一个 `xapp` 只有满足以下条件,才算达到“整包可删除”: + +1. 删除 `backend-go/internal/xapps/apps//` +2. 删除 `frontend/src/xapps/apps//` +3. 删除 registry 里的注册项 +4. 后端构建通过 +5. 前端构建通过 +6. 没有平台层 import 残留 +7. 没有平台级 hardcoded path 残留 +8. 运行卸载脚本后,该 `xapp` 的 owned tables 被清理 +9. 前端导航、页面、缓存、localStorage 全部消失 +10. 平台统计只少这一个 `xapp` 的 provider 结果,不出现空指针或编译错误 +11. job / notification / provider key 不残留 +12. 共享域不被误删 +13. `xapp_definition`、目录条目、seed 定义条目不会残留死入口 +14. `dry_run` 报告能正确列出 shared reference 的阻塞与降级结果 + +## 16. 最终标准 + +`xapp` 的最终定义应为: + +1. 一个可注册的业务包 +2. 一个有 manifest 的对象 +3. 一个有自有 schema 的领域边界 +4. 一个只通过 contract 暴露能力的模块 +5. 一个可以通过“删目录 + 删注册 + 跑卸载”安全移除的工程单元 + +只有达到这个标准,`xapp` 才不是“挂在平台上的专题页面”,而是真正的一级对象。 diff --git a/docs/02_Architecture/AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md b/docs/02_Architecture/AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md new file mode 100644 index 0000000..278af95 --- /dev/null +++ b/docs/02_Architecture/AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md @@ -0,0 +1,1179 @@ +# AR11 Skill / Specialist / Connector Removable Packaging Specification + +> **版本**:V1.3 +> **日期**:2026-09-18 +> **性质**:规范性文件(normative) +> **适用对象**:`skill`、`specialist`、`connector` +> **关联文档**: +> - `AR06_Skill_Packaging_Specification.md` +> - `AR09_Object_Naming_Standard.md` +> - `AR10_XApp_Removable_Packaging_Specification.md` + +## 1. 目标 + +本文定义 `skill`、`specialist`、`connector` 三类对象的可删除封装方式。 + +本文中的“可删除”指: + +1. 删除一个对象目录 +2. 删除一条注册项 +3. 不需要全仓手工搜改业务代码 +4. 重新构建后不出现编译错误 +5. 运行卸载脚本后,不残留该对象自有资源 + +在当前单仓工程内,合理目标应为: + +- 删一个目录 + 删一条注册项 + 跑一次卸载 = 安全移除一个对象包 + +但三类对象的封装深度不同: + +1. `connector` 是连接插件包 +2. `skill` 是能力插件包 +3. `specialist` 是策略定义包 + +它们不能机械复用同一套“完整业务包”模板。 + +## 2. 与 AR10 的关系 + +`AR10_XApp_Removable_Packaging_Specification.md` 定义的是 `xapp` 的可删除封装规范。 + +本文不是 AR10 的重复版,而是它在另外三类对象上的边界化扩展: + +1. 复用 AR10 的 `manifest / registry / contracts / own boundary` 思想 +2. 保留“删目录 + 删注册项 + 跑卸载”的工程目标 +3. 明确指出:`xapp` 的“完整业务包”模板不能原样套到 `skill / specialist / connector` + +换句话说: + +1. AR10 解决的是“一级业务包如何可删除” +2. AR11 解决的是“能力包 / 策略包 / 连接包如何可删除” + +## 3. 对 AR10 的复核结论 + +## 3.1 正确的部分 + +AR10 的大方向是正确的,尤其是以下几点: + +1. 平台层只能认 `manifest / registry / contracts` +2. 总路由和总导航不应再手写对象业务路径 +3. 私有对象不应继续停留在平台总 `model` 包 +4. 持久化模型不应直接兼任 API DTO +5. 统计、画像、后台管理应通过 provider 聚合 +6. “删目录 + 删注册 + 跑卸载”是比“删一个文件”更现实的工程目标 + +这些判断对 `xapp` 是成立的,也应成为其他对象的总体方向。 + +## 3.2 需要收窄的部分 + +AR10 的结构不能原样复用于全部对象,主要因为三类对象的职责不同: + +1. `skill` 不是业务子系统,通常不 own 大量页面、任务和后台管理 +2. `specialist` 不是业务应用,更不应强制 own 独立表、独立路由、独立后台 +3. `connector` 才最接近真正的可插拔基础设施模块 + +因此: + +1. 对 `xapp`,“完整业务包”模板基本成立 +2. 对 `connector`,AR10 模板大体可复用,但要加强 auth / webhook / secret / health / job 的 own 边界 +3. 对 `skill`,应改写为“能力包”模板 +4. 对 `specialist`,应改写为“策略定义包”模板 + +## 3.3 最终结论 + +AR10 是对的,但它的适用对象主要是 `xapp`。 + +若把 AR10 原样套用到 `skill / specialist / connector`,会出现两种问题: + +1. 对 `connector` 还不够彻底 +2. 对 `specialist` 则会明显过度工程化 + +## 4. 三类对象的共同硬规则 + +### 4.0 一对象一目录是最终形态 + +对 `skill / specialist / connector` 三类对象而言,最终封装形态必须满足: + +1. 一个对象对应一个后端目录 +2. 一个对象对应一个前端目录 +3. 该对象的定义文件、资源边界、可删除元数据都落在自己的目录中 + +因此: + +1. **允许中心化注册** +2. **不允许中心化定义** + +“中心化注册”是指: + +1. registry 统一列出有哪些对象 manifest +2. installer / uninstall 统一执行安装和清理 +3. platform core 统一聚合 provider + +“中心化定义”是指: + +1. 在一个平台公共文件里集中维护多个 specialist 的 prompt 和绑定 +2. 在一个平台公共文件里集中维护多个 skill 的 schema 和静态定义 +3. 在一个平台公共文件里集中维护多个 connector 的 catalog、能力和实现细节 + +前者允许,后者不是最终封装形态。 + +### 4.0.1 允许中心化 / 禁止中心化 + +| 类型 | 是否允许 | 说明 | +|---|---|---| +| `core/registry` 集中列出 manifest | 允许 | 这是注册中心 | +| `core/uninstall` 统一执行清理 | 允许 | 这是卸载中心 | +| `specialists/rules.go` 这类集中式多专员定义文件 | 禁止作为最终形态 | 最多只能做迁移期兼容层 | +| `skills/keys.go` 或全局 skill catalog 承载多个 skill 完整定义 | 禁止作为最终形态 | 这会阻断目录级删除 | +| `connectors/static_catalog.go` 集中维护多个 connector 完整能力定义 | 禁止作为最终形态 | 这是定义中心,不是注册中心 | + +### 4.1 平台层只能依赖 registry / manifest / contracts + +平台层允许依赖: + +1. `core/registry` +2. `core/contracts` +3. 对象包暴露的 `manifest` + +平台层禁止依赖: + +1. 对象包内部的 `domain` +2. 对象包内部的 `repo` +3. 对象包内部的 `service` +4. 对象包内部的前端组件和内部运行时实现 + +### 4.2 总路由不得手写对象内部业务入口 + +总路由只能装配对象包,不得继续手写对象内部路径。 + +### 4.3 总导航不得手写对象内部菜单 + +平台导航只能聚合 manifest 暴露的菜单项,不能再认识对象包内部细节。 + +### 4.4 平台 seed 不得直接创建具体对象定义 + +平台总 seed 只允许: + +1. 加载 registry +2. 调用对象包自注册 seed + +平台总 seed 不允许继续硬编码: + +1. 某个 specialist 的 prompt 和绑定 +2. 某个 skill 的默认定义 +3. 某个 connector 的静态目录项 + +### 4.5 删除对象包后,平台只能“少一个能力” + +删除某个对象包后,平台允许出现: + +1. 某个能力不再可选 +2. 某个菜单自动消失 +3. 某个 provider 的结果缺失 + +平台不允许出现: + +1. 编译错误 +2. 运行时 import error +3. 路由残留导致启动失败 +4. 平台层空指针 + +### 4.6 一级导航三是对象治理区 + +`skill / specialist / connector` 三类对象,应统一纳入一级导航三进行治理。 + +一级导航三的定位不是“对象源码目录浏览器”,而是**对象治理区**。 + +它应至少支持: + +1. 查看对象基础信息 +2. 编辑对象定义 +3. 启停对象 +4. 复制对象 +5. 版本管理 +6. 发布与回滚 + +因此: + +1. 对象包的默认定义在目录中 +2. 对象包的治理入口在导航三中 +3. 对象包的当前生效状态不应直接依赖源码文件实时变更 + +### 4.7 对象包文件层与运行时持久化层必须分离 + +为了同时满足“目录级可删除”和“在线可编辑”,必须显式区分两层: + +1. **对象包文件层** +2. **运行时持久化层** + +对象包文件层负责: + +1. 默认定义 +2. 内置模板 +3. manifest +4. 可导入导出的包资源 +5. 可版本化的静态素材 + +运行时持久化层负责: + +1. 管理员在导航三中的编辑结果 +2. 草稿版本 +3. 已发布版本 +4. 回滚记录 +5. 审计记录 + +因此: + +1. 包目录是“对象源码与默认定义” +2. 数据库是“当前运行时生效定义” + +### 4.8 在线编辑结果默认不直接回写源码文件 + +管理员在导航三中修改对象时,默认不应直接写回仓库内的 `json / md / js / go` 文件。 + +原因如下: + +1. 运行中服务不适合直接改仓库源码 +2. 多人协作下,文件写回缺少稳定的审计、并发控制、发布控制 +3. 运行时读取数据库比扫描仓库文件更稳定 +4. “对象包可删除”和“在线治理”是两套不同诉求,不能混为一体 + +因此规范要求: + +1. 包目录中的 `json / md` 文件用于默认定义、模板、导入导出、离线版本管理 +2. 在线编辑后的结构化结果应写入数据库 +3. 运行时以数据库中的发布版本为准,而不是以仓库文件实时内容为准 + +### 4.9 文件格式建议 + +对象包文件层推荐使用可读、可审阅的静态格式: + +1. `manifest`:`go / js` +2. 结构化配置:`json` +3. 长文本说明:`md` +4. 资源素材:对象目录内自有资源文件 + +但这些文件的角色是: + +1. 包定义来源 +2. 默认值来源 +3. 导入导出载体 +4. 开发期版本管理载体 + +它们不是在线治理的唯一真实存储。 + +### 4.10 发布模型建议 + +对象治理区建议统一采用以下发布模型: + +1. 包默认定义 +2. 运行时草稿 +3. 已发布版本 +4. 回滚版本 + +推荐流程: + +1. 从对象包默认定义初始化对象 +2. 管理员在导航三中编辑草稿 +3. 发布后生成当前生效版本 +4. 运行时只读取已发布版本 +5. 删除对象目录时,只影响该对象的包来源和后续发布,不影响历史审计记录 + +### 4.11 定义条目也属于对象 own boundary + +对 `skill / specialist / connector` 而言,own boundary 不能只覆盖代码、表和 storage key,还必须覆盖定义层资源。 + +定义层资源至少包括: + +1. 对象定义记录 +2. 对象目录页 / 市场页 / 治理页中的对象条目 +3. seed 自动创建的对象默认定义 +4. 对象包发布后形成的对象目录映射 + +如果删除对象目录后,这些定义条目仍然残留,那么就仍会出现: + +1. 僵尸入口 +2. 失效对象卡片 +3. 后台目录中的死条目 +4. 平台试图按已删除对象继续加载配置 + +因此,定义条目必须被视为对象 own boundary 的一部分。 + +### 4.12 Shared Reference Policy + +对象删除时,不仅要处理 own resource,还要处理平台共享域中的历史引用。 + +共享引用至少包括: + +1. 历史任务 +2. 项目绑定 +3. 收藏 / 最近使用 +4. 历史通知 +5. 审计日志 +6. 历史发布版本 + +规范允许以下策略: + +1. `block_uninstall` +2. `convert_to_tombstone` +3. `detach_reference` +4. `readonly_history` + +不同对象可选择不同策略,但必须显式声明,不能留给运行时临时猜测。 + +### 4.13 Enforcement / Guard + +若要保证封装不回退,平台至少应建立以下自动守卫: + +1. import 边界检查 +2. registry 完整性检查 +3. owned resource audit +4. 定义条目完整性检查 +5. shared reference dry-run 检查 +6. CI gate + +## 5. Skill 规范 + +## 5.1 Skill 的工程定义 + +`skill` 的本质是“能力包”,不是“业务子系统”。 + +它回答的是: + +1. 这件事怎么做 +2. 需要什么输入 +3. 会产生什么结果 + +它通常不应该自带完整后台系统。 + +## 5.2 与 AR06 的关系 + +`AR06_Skill_Packaging_Specification.md` 解决的是“技能文件如何组织”。 + +本文在此基础上进一步定义: + +1. 技能如何拥有 manifest +2. 技能如何注册到 platform +3. 技能如何形成 own boundary +4. 技能如何达到目录级安全删除 + +所以: + +1. AR06 是文件组织规范 +2. AR11 是可删除封装规范 + +二者不冲突,而是前后两层。 + +## 5.3 推荐目录结构 + +### 后端 + +```text +backend-go/internal/skills/ + core/ + contracts.go + registry.go + manifest.go + uninstall.go + packages/ + document_translate/ + manifest.go + provider.go + dto/ + schema/ + tests/ + contract_review/ + manifest.go + provider.go + dto/ + schema/ + tests/ +``` + +### 前端 + +```text +frontend/src/skills/ + core/ + contracts.js + registry.js + installer.js + uninstall.js + packages/ + document-translate/ + manifest.js + executor.js + ResultCard.vue + composer.js + schema.js + assets/ + contract-review/ + manifest.js + executor.js + ResultCard.vue + composer.js + schema.js + assets/ +``` + +## 5.4 Skill manifest 必须拥有的内容 + +每个 skill manifest 至少应声明: + +1. `key` +2. `label` +3. `source` +4. `executor` +5. `input_schema` +6. `output_schema` +7. `artifact_schema` +8. `result_renderer` +9. `starter_prompts` +10. `owned_storage_keys` +11. `owned_tables` +12. `owned_definition_keys` +13. `owned_catalog_entries` +14. `shared_reference_policy` +15. `register_providers()` + +### 5.5 Skill registry 的职责 + +skill registry 只负责: + +1. 注册 skill manifest +2. 返回可用技能清单 +3. 安装技能入口 +4. 卸载技能资源 + +skill registry 不负责: + +1. 业务执行 +2. 页面逻辑 +3. 平台级 fallback 逻辑 + +## 5.6 Skill 的资源归属 + +skill 可以 own: + +1. 执行器 +2. 输入输出 schema +3. 结果卡片 +4. 对话挂载块 +5. 可选 provider +6. 可选 own table +7. 可选 own localStorage key + +skill 不应默认 own: + +1. 一整套业务导航 +2. 大量后台管理页面 +3. 一整套任务系统 +4. 专属业务报表入口 + +## 5.7 Skill 的命名空间建议 + +一个 skill 若拥有自有资源,命名空间应为: + +1. 表:`skill__*` +2. storage key:`skill::*` +3. provider key:`skill..*` + +## 5.8 Skill 的删除标准 + +一个 skill 只有满足以下条件,才算达到“整包可删除”: + +1. 删除 `frontend/src/skills/packages//` +2. 删除 `backend-go/internal/skills/packages//` +3. 删除 registry 注册项 +4. 平台页面不直接 import 它的 executor / result card +5. 平台构建通过 +6. 只是少了这个技能,不影响其他技能和工作台运行 + +## 5.9 当前仓库的主要问题 + +当前仓库中,`skill` 仍存在以下硬耦合: + +1. 技能静态定义大量停留在全局配置 +2. 后端存在独立的技能 key 硬编码白名单 +3. 工作台页面仍直接感知部分技能运行时细节 + +因此当前的 `skill` 还没有达到目录级安全删除标准。 + +### 5.10 迁移期兼容层判定 + +以下结构只允许作为迁移期兼容层存在,不是最终封装形态: + +1. 全局静态 `skill catalog` +2. 后端统一维护的 skill key 白名单 +3. 平台页面直接 import 单个 skill 的内部 executor / result card + +兼容层若存在,必须满足: + +1. 只做桥接和转发 +2. 不再新增新 skill 定义 +3. 有明确退场目标 + +### 5.11 Skill 在对象治理区的编辑模型 + +`skill` 应允许在一级导航三中被查看、编辑、复制、启停和发布。 + +但编辑内容应分层: + +1. 包目录中保留 skill 的默认定义 +2. 导航三中的编辑结果写入运行时持久化层 + +对 `skill` 而言,包目录中适合保留的内容包括: + +1. `manifest.js / manifest.go` +2. `schema.js / schema.json` +3. `starter prompts` +4. `result renderer` 的默认元数据 + +运行时持久化层适合保存: + +1. 当前启用状态 +2. 管理员修改后的输入输出 schema +3. 对话挂载配置 +4. 发布版本与回滚记录 + +因此,`skill` 可以使用 `json` 表达部分默认定义,但导航三中的在线编辑结果不应直接回写 skill 源码目录。 + +## 6. Specialist 规范 + +## 6.1 Specialist 的工程定义 + +`specialist` 的本质是“策略定义包”,不是“业务应用包”。 + +它回答的是: + +1. 以谁的方式做 +2. 用什么身份说话 +3. 默认挂载哪些技能和连接器 +4. 以什么交互卡片和任务骨架呈现 + +因此,不应把每个 specialist 都设计成自带一整套后端系统的业务模块。 + +## 6.2 推荐目录结构 + +### 后端 + +```text +backend-go/internal/specialists/ + core/ + contracts.go + registry.go + prompt_provider.go + binding_provider.go + uninstall.go + packages/ + contract_review/ + manifest.go + prompt.md + bindings.json + interaction_card.json + seeds.go + tests/ + report_generation/ + manifest.go + prompt.md + bindings.json + interaction_card.json + seeds.go + tests/ +``` + +### 前端 + +```text +frontend/src/specialists/ + core/ + contracts.js + registry.js + uninstall.js + packages/ + contract-review/ + manifest.js + interaction-card.json + starter-prompts.json + bindings.json + assets/ + report-generation/ + manifest.js + interaction-card.json + starter-prompts.json + bindings.json + assets/ +``` + +## 6.3 Specialist manifest 必须拥有的内容 + +每个 specialist manifest 至少应声明: + +1. `key` +2. `label` +3. `mode` +4. `prompt_file` +5. `interaction_card` +6. `starter_prompts` +7. `allowed_skills` +8. `allowed_connectors` +9. `default_task_template` +10. `default_action_template` +11. `owned_seed_keys` +12. `owned_storage_keys` +13. `owned_definition_keys` +14. `owned_catalog_entries` +15. `shared_reference_policy` + +## 6.4 Specialist registry 的职责 + +specialist registry 只负责: + +1. 注册 specialist manifest +2. 暴露 prompt provider +3. 暴露 binding provider +4. 安装 specialist 目录项 +5. 卸载 specialist 自有资源 + +specialist registry 不负责: + +1. 创建任务实例 +2. 执行技能 +3. 查询 connector 数据 + +## 6.5 Specialist 的资源归属 + +specialist 应 own: + +1. prompt / rule file +2. 技能绑定 +3. 连接器绑定 +4. 交互卡片 +5. 默认任务骨架 +6. 默认动作骨架 +7. 自身 seed + +specialist 不应默认 own: + +1. 独立业务数据表 +2. 独立后台管理系统 +3. 独立业务路由 +4. 独立统计系统 + +除非未来出现真正“专员子产品”级别的对象,否则不建议突破这条边界。 + +## 6.6 Specialist 的命名空间建议 + +specialist 若拥有自有资源,命名空间应为: + +1. seed key:`specialist..*` +2. storage key:`specialist::*` +3. provider key:`specialist..*` + +## 6.7 Specialist 的删除标准 + +一个 specialist 只有满足以下条件,才算达到“整包可删除”: + +1. 删除 `frontend/src/specialists/packages//` +2. 删除 `backend-go/internal/specialists/packages//` +3. 删除 registry 注册项 +4. 平台 prompt 组装只通过 provider,不直读具体对象文件 +5. 任务系统遇到历史 `specialist_key` 时可安全 fallback +6. 平台构建通过 +7. 只是少了这个专员定义,不影响任务系统和其他专员 + +## 6.8 当前仓库的主要问题 + +当前仓库中,`specialist` 仍存在以下硬耦合: + +1. 平台 API 仍直接组装专员 prompt +2. 平台种子仍承载专员说明书和技能绑定 +3. 其他模块仍直接感知专员的具体字段结构 + +因此当前的 `specialist` 仍然是“平台共享对象”,还不是可删除的策略定义包。 + +### 6.9 关于集中式 `rules.go` 的判定 + +类似下面这种结构: + +1. 一个公共文件里集中维护多个 specialist 的 prompt +2. 同一个公共文件里集中维护多个 specialist 的 skill binding +3. 同一个公共文件里集中维护多个 specialist 的默认规则草稿 + +它**不算完成封装**。 + +这种文件最多只能算: + +1. 迁移期种子汇总 +2. 迁移期兼容层 +3. 过渡期桥接实现 + +但它不是最终形态。 + +`specialist` 的最终形态必须是: + +1. 一个 specialist 一个目录 +2. 每个 specialist 自己拥有 prompt / binding / interaction card / manifest +3. 平台只通过 registry 和 provider 认识它 + +### 6.10 Specialist 在对象治理区的编辑模型 + +`specialist` 也应允许在一级导航三中被查看、编辑、启停、复制和发布。 + +但其编辑模型应遵守“文件层与持久化层分离”: + +1. 包目录保留默认 prompt、默认 binding、默认 interaction card +2. 导航三中的编辑结果写入数据库中的 specialist 草稿与发布版本 + +对 `specialist` 而言,包目录中适合保留的内容包括: + +1. `prompt.md` +2. `bindings.json` +3. `interaction_card.json` +4. `starter-prompts.json` +5. `manifest.go / manifest.js` + +运行时持久化层适合保存: + +1. 管理员编辑后的 prompt 版本 +2. 管理员编辑后的技能绑定和连接器绑定 +3. 启停状态 +4. 发布版本 +5. 回滚与审计记录 + +因此: + +1. `specialist` 可以以 `md + json` 作为包定义格式 +2. 但在线编辑后的结果不应直接回写 `backend-go/internal/specialists/packages/...` +3. 运行时应以数据库中的已发布版本为准 + +## 7. Connector 规范 + +## 7.1 Connector 的工程定义 + +`connector` 的本质是“连接包 / 适配器包”。 + +它回答的是: + +1. 连谁 +2. 怎么鉴权 +3. 能查什么对象 +4. 能执行什么动作 +5. 健康状态怎么判断 + +在三类对象里,`connector` 最适合做成真正的可插拔插件包。 + +## 7.2 推荐目录结构 + +### 后端 + +```text +backend-go/internal/connectors/ + core/ + contracts.go + registry.go + auth_contract.go + query_contract.go + webhook_contract.go + health_contract.go + uninstall.go + packages/ + kingdee/ + manifest.go + adapter.go + auth.go + health.go + webhook.go + dto/ + schema/ + tests/ + feishu_bitable/ + manifest.go + adapter.go + auth.go + health.go + webhook.go + dto/ + schema/ + tests/ +``` + +### 前端 + +```text +frontend/src/connectors/ + core/ + contracts.js + registry.js + uninstall.js + packages/ + kingdee/ + manifest.js + settings.js + health.js + assets/ + feishu-bitable/ + manifest.js + settings.js + health.js + assets/ +``` + +## 7.3 Connector manifest 必须拥有的内容 + +每个 connector manifest 至少应声明: + +1. `key` +2. `label` +3. `direction` +4. `auth_kind` +5. `supported_objects` +6. `query_provider` +7. `webhook_provider` +8. `health_provider` +9. `owned_secret_keys` +10. `owned_tables` +11. `owned_job_keys` +12. `owned_storage_keys` +13. `owned_definition_keys` +14. `owned_catalog_entries` +15. `shared_reference_policy` + +## 7.4 Connector registry 的职责 + +connector registry 只负责: + +1. 注册 connector manifest +2. 按 key 路由到 provider +3. 安装 connector 目录项 +4. 枚举 connector 健康和能力 +5. 卸载 connector 自有资源 + +connector registry 不负责: + +1. 平台级业务调度 +2. 平台统计拼装 +3. 平台任务决策 + +## 7.5 Connector 的资源归属 + +connector 应 own: + +1. adapter +2. auth +3. query +4. webhook +5. health check +6. secret key 命名空间 +7. own tables +8. own jobs +9. own localStorage key + +connector 不应再把这些能力散在平台通用文件中。 + +## 7.6 Connector 的命名空间建议 + +connector 的命名空间建议如下: + +1. 表:`connector__*` +2. secret key:`connector..*` +3. job key:`connector..*` +4. storage key:`connector::*` +5. provider key:`connector..*` + +## 7.7 Connector 的删除标准 + +一个 connector 只有满足以下条件,才算达到“整包可删除”: + +1. 删除 `frontend/src/connectors/packages//` +2. 删除 `backend-go/internal/connectors/packages//` +3. 删除 registry 注册项 +4. 平台 runtime 不再 `switch key` +5. 平台控制台 / 统计页只消费 provider 输出 +6. secret / webhook / job / health 状态都在该 connector 命名空间下 +7. 平台构建通过 + +## 7.8 当前仓库的主要问题 + +当前仓库中,`connector` 仍存在以下硬耦合: + +1. connector registry 仍是平台硬编码 map +2. runtime 仍直接 import connector 根包并调用具体能力 +3. 前端还没有 connector manifest / registry 分层 + +因此 `connector` 虽然最接近插件化,但仍未达到目录级安全删除标准。 + +### 7.9 关于集中式 connector catalog 的判定 + +类似下面这种结构: + +1. 一个公共文件里集中维护多个 connector 的 definition +2. 在同一个 catalog 中继续扩张多个 connector 的能力、对象、动作 +3. 平台 runtime 直接依赖这个 catalog 来判断 connector 行为 + +它也不算完成封装。 + +这种文件最多只能算: + +1. 迁移期注册表 +2. 迁移期兼容目录 +3. 过渡期桥接实现 + +但它不是最终形态。 + +`connector` 的最终形态必须是: + +1. 一个 connector 一个目录 +2. 每个 connector 自己拥有 adapter / auth / health / webhook / manifest +3. 平台只通过 registry 和 contracts 认识它 + +### 7.10 Connector 在对象治理区的编辑模型 + +`connector` 同样应纳入一级导航三治理,但它的在线编辑边界要更谨慎。 + +包目录中适合保留: + +1. `manifest` +2. 默认 settings 元数据 +3. 健康检查元数据 +4. 能力声明 + +运行时持久化层适合保存: + +1. connector 启停状态 +2. 租户级配置 +3. 凭据引用 +4. 健康状态快照 +5. 发布版本与审计记录 + +特别说明: + +1. connector 的在线治理通常只改配置和启停 +2. 不应允许在导航三中直接改 adapter 源码实现 +3. 凭据应进入独立 secret 存储,不应直接裸写进包定义文件 + +## 8. 三类对象的拥有边界 + +为了避免过度工程化,三类对象的拥有边界必须明确区分。 + +### 8.1 Skill owns + +1. executor +2. schema +3. result card +4. composer block +5. capability provider + +### 8.2 Specialist owns + +1. prompt +2. bindings +3. interaction card +4. default strategy +5. default task skeleton + +### 8.3 Connector owns + +1. adapter +2. auth +3. health +4. webhook +5. query +6. secrets +7. jobs +8. own tables + +因此: + +1. `skill` 是“做什么” +2. `specialist` 是“以谁的方式做” +3. `connector` 是“连谁、取谁、写谁” + +## 9. 合同接口建议 + +## 9.1 Skill manifest + +```text +SkillManifest + Key() + Meta() + Install() + RegisterProviders() + OwnedTables() + OwnedStorageKeys() + OwnedDefinitionKeys() + OwnedCatalogEntries() + SharedReferencePolicy() +``` + +## 9.2 Specialist manifest + +```text +SpecialistManifest + Key() + Meta() + PromptProvider() + BindingProvider() + RegisterSeeds() + OwnedStorageKeys() + OwnedDefinitionKeys() + OwnedCatalogEntries() + SharedReferencePolicy() +``` + +## 9.3 Connector manifest + +```text +ConnectorManifest + Key() + Meta() + RegisterQueryProvider() + RegisterWebhookProvider() + RegisterHealthProvider() + RegisterMigrations() + OwnedTables() + OwnedSecretKeys() + OwnedJobKeys() + OwnedStorageKeys() + OwnedDefinitionKeys() + OwnedCatalogEntries() + SharedReferencePolicy() +``` + +## 10. 卸载协议 + +三类对象都必须支持卸载协议,但清理维度不同。 + +### 10.1 Skill uninstall + +至少应支持: + +1. 清理 owned storage key +2. 清理 owned table +3. 清理 provider 注册 +4. 清理 definition key / catalog entry +5. 报告 shared reference 的阻塞或降级结果 + +### 10.2 Specialist uninstall + +至少应支持: + +1. 清理 owned storage key +2. 清理 seed 注册 +3. 移除 prompt / binding provider +4. 对历史任务 key 做 fallback,而不是报错 +5. 清理 definition key / catalog entry +6. 对历史发布版本和任务引用执行 tombstone / readonly 策略 + +### 10.3 Connector uninstall + +至少应支持: + +1. 清理 owned storage key +2. 清理 owned table +3. 清理 secret key +4. 清理 webhook 配置 +5. 清理 job key +6. 清理 provider 注册 +7. 清理 definition key / catalog entry +8. 对共享引用执行 block / detach / tombstone 策略 + +### 10.4 卸载的统一 dry-run 输出要求 + +三类对象的卸载都应支持统一的 `dry_run` 输出,至少包括: + +1. 将被清理的 owned resource +2. 将被清理的定义条目 +3. 将被阻塞的共享引用 +4. 将被降级为 tombstone / readonly 的历史引用 + +## 11. 迁移顺序 + +推荐按以下顺序实施,避免边搬边炸: + +1. 先建立 `skills / specialists / connectors` 的 `core/contracts` +2. 建立三类对象各自的 registry +3. 先把平台层硬编码改为 registry 消费 +4. 再把默认定义迁成 manifest 注册 +5. 再搬迁对象包目录 +6. 最后补卸载脚本和 owned resource 清理 + +这个顺序的核心原因是: + +1. 先修正依赖方向 +2. 再修正代码落点 +3. 最后修正资源所有权 + +## 12. 判定红线 + +如果一个 `skill / specialist / connector` 还存在以下任意一条,则不算完成封装: + +1. 总路由中仍手写它的内部业务路径 +2. 总导航中仍手写它的对象级菜单 +3. 平台配置文件仍硬编码它的完整定义 +4. 平台总 seed 仍直接创建它 +5. 别的模块还能直接 import 它内部包 +6. 统计、控制台、工作台仍直接读取它的内部实现细节 +7. 该对象自己的存储 key / 表 / job / secret 没有进入 own 命名空间 +8. 一个公共定义文件里仍集中维护多个对象的完整定义 +9. 对象定义记录、目录条目、seed 默认定义没有进入 own boundary +10. 共享引用没有明确降级策略 +11. 缺少 `dry_run`、资源审计或 CI guard + +### 12.1 集中式定义文件的红线说明 + +以下文件形态,不管名字叫什么,只要内容本质相同,都应视为未完成封装: + +1. 多 specialist 的集中式规则定义文件 +2. 多 skill 的集中式静态定义文件 +3. 多 connector 的集中式 catalog 定义文件 + +判断标准不是文件名,而是它是否同时满足: + +1. 承载多个对象 +2. 承载对象完整定义,而不只是注册引用 +3. 删除单个对象时仍需进入该文件手工删业务细节 + +只要满足这三条,就说明它还是“定义中心”,不是“注册中心”。 + +### 12.2 Enforcement / Guard 的最低要求 + +若三类对象要长期保持可删除封装,平台至少应实现: + +1. import boundary lint +2. registry completeness check +3. owned definition audit +4. shared reference dry-run report +5. CI fail 条件 + +## 13. 验收清单 + +三类对象只有满足以下条件,才算达到“整包可删除”: + +1. 删除对象目录 +2. 删除 registry 注册项 +3. 前后端构建通过 +4. 平台层没有 import 残留 +5. 卸载脚本只清理该对象 own 的资源 +6. 平台只是少一个能力,不发生整体失败 +7. 对象定义记录、目录条目、seed 默认定义不会残留死入口 +8. `dry_run` 能准确报告 shared reference 的阻塞、解绑与 tombstone 结果 + +## 14. 最终标准 + +三类对象的最终定义应为: + +1. 一个可注册的对象包 +2. 一个有 manifest 的对象 +3. 一个只通过 contract 暴露能力的模块 +4. 一个可以通过“删目录 + 删注册 + 跑卸载”安全移除的工程单元 + +但三类对象的最终形态不同: + +1. `skill` 是能力包 +2. `specialist` 是策略定义包 +3. `connector` 是连接插件包 + +只有先承认这种边界差异,所谓“可删除封装”才不会走向两种错误: + +1. 该做彻底的地方没有做彻底 +2. 不该做成子系统的对象被硬做成子系统 diff --git a/docs/02_Architecture/README.md b/docs/02_Architecture/README.md index 99ae799..abf6ddb 100644 --- a/docs/02_Architecture/README.md +++ b/docs/02_Architecture/README.md @@ -23,3 +23,5 @@ | `AR07_Architecture_Alignment_Audit.md` | 架构对齐确认与本轮修复范围(收敛两套并行模型的确认记录) | | `AR08_Role_Interaction_Design.md` | AI 角色与工具统一交互设计(**部分被取代**:不跳页结论已采纳,「数字技术员」对象已废弃,见文首补注) | | `AR09_Object_Naming_Standard.md` | 对象命名规范(**规范性文件**:原理 / 判据 / 术语表 / 分层规范 / 检查守卫 / 修复流程 / 现状问题登记 / 反例库) | +| `AR10_XApp_Removable_Packaging_Specification.md` | XApp 可删除封装规范(面向一级业务包,目标是“删目录 + 删注册 + 跑卸载”) | +| `AR11_Skill_Specialist_Connector_Removable_Packaging_Specification.md` | Skill / Specialist / Connector 可删除封装规范(复用 AR10 思想,但区分能力包、策略定义包、连接插件包的边界) | diff --git a/docs/04_Backend/BE06_AI_Config_Credits_Module.md b/docs/04_Backend/BE06_AI_Config_Credits_Module.md index 4e5843e..adc55ec 100644 --- a/docs/04_Backend/BE06_AI_Config_Credits_Module.md +++ b/docs/04_Backend/BE06_AI_Config_Credits_Module.md @@ -12,7 +12,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运 **移植范围(保留)**: 1. AI 路由/Provider/密钥配置文件体系(`ai_config.json` + `ai_secrets.json`)——已落地 2. Agent → 路由映射、默认路由、回退链(fallback) -3. **按用户算力点计费**:每次 AI 调用按能力扣点 + 写调用日志 +3. **按用户算力点计费**:每次 AI 调用按用量类型扣点 + 写调用日志 4. AI 配置管理 API(读/写/热重载/密钥状态)+ 用量查询 API 5. 前端:AI 配置 UI + AI 用量看板 + 用户剩余点数展示 @@ -58,7 +58,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运 | `company_id` | int | 公司维度 | ❌ 单公司,删除 | | `user_id` | int | 调用人 | ✅ | | `provider` | enum | provider | ✅ | -| `capability` | enum | AI 能力 | ✅ | +| `usage_kind` | enum | AI 用量类型 | ✅ | | `input_asset_id` / `output_asset_id` | int | 电商素材 | ❌ | | `ai_route_id` | str | AI 路由 ID | ✅ | | `model_id` | str | 模型 | ✅ | @@ -78,7 +78,7 @@ pj034 的 AI 系统已演进为「多层、多公司、多租户」的完整运 ### 3.2 扣点收口 `compute_credits`(pj034) ```python -CAPABILITY_CREDITS = { +USAGE_KIND_CREDITS = { AiCapability.TEXT_DIAGNOSE: 1, AiCapability.BG_REMOVE: 1, AiCapability.BG_REPLACE: 2, @@ -88,17 +88,17 @@ CAPABILITY_CREDITS = { AiCapability.IMAGE_VALIDATE: 1, } -def compute_credits(capability, billing_mode, status): +def compute_credits(usage_kind, billing_mode, status): if status != SUCCESS or billing_mode != PLATFORM: return 0 - return CAPABILITY_CREDITS.get(capability, 0) + return USAGE_KIND_CREDITS.get(usage_kind, 0) ``` **核心规则**:只有「调用成功」才扣点;失败不扣点。扣点决策收口到 service 层,不在各端点散落判断。 ### 3.3 计费聚合 API(pj034 `ai_billing.py`) -`GET /data/ai-billing?days=30&group_by=month|capability|provider|month_capability` +`GET /data/ai-billing?days=30&group_by=month|usage_kind|provider|month_usage_kind` 返回 `{ summary: {total_calls, success_calls, failed_calls, total_credits_charged}, buckets: [...] }`。SQLite 下按月聚合走 Go 侧 group。 @@ -106,9 +106,9 @@ def compute_credits(capability, billing_mode, status): ## 4. pj0231 计费模型(精简) -### 4.1 能力 → 点数(CAPABILITY_CREDITS) +### 4.1 用量类型 → 点数(USAGE_KIND_CREDITS) -| capability | 含义 | 点数 | +| usage_kind | 含义 | 点数 | |-----------|------|------| | `ai_chat` | PathCoach 对话(每轮) | 1 | | `text_gen` | 快捷动作(情景演练/查佣金/产品对比) | 1 | @@ -128,7 +128,7 @@ def compute_credits(capability, billing_mode, status): |------|------|------| | `id` | uint PK | | | `user_id` | uint index | 调用人 | -| `capability` | str | ai_chat / text_gen / embed | +| `usage_kind` | str | ai_chat / text_gen / embed | | `provider` | str | 实际命中的 provider | | `ai_route_id` | str | AI 路由 ID | | `model` | str | 模型名 | @@ -159,7 +159,7 @@ def compute_credits(capability, billing_mode, status): | 方法 | 路径 | 说明 | |------|------|------| -| GET | `/api/ai/usage?days=30&group_by=month` | 用量聚合(管理员 = 全量;员工 = 本人,后端按角色过滤) | +| GET | `/api/ai/usage?days=30&group_by=month|usage_kind|provider` | 用量聚合(管理员 = 全量;员工 = 本人,后端按角色过滤) | | GET | `/api/ai/usage/users` | 管理员:按用户聚合的用量 + 剩余点数(充值入口数据源) | | GET | `/api/ai/me` | 员工:本人剩余点数 + 近 N 天用量 | @@ -188,7 +188,7 @@ def compute_credits(capability, billing_mode, status): 复刻 pj034 `AiUsage.vue` 的精简版: - 汇总卡:总调用 / 成功 / 失败 / 总消耗点数(去掉「¥ 花费」) - 时间窗切换:近 30 / 90 / 365 天 -- 分组明细:按月 / 按能力 / 按 provider +- 分组明细:按月 / 按类型 / 按 provider - 管理员额外视角:按用户聚合(含剩余点数) 菜单位置:知识管理下新增「AI 用量」(管理员);员工入口放在 PathCoach 面板内(本人剩余点数 + 近 30 天用量)。 @@ -205,7 +205,7 @@ def compute_credits(capability, billing_mode, status): - [x] LLM 客户端 + 回退链(`GenerateWithFallback` 已实现,待接入 ChatMessage) - [x] `model/ai_call_log.go` + `model/user.go` 加 `ai_points` - [x] `store/db.go` AutoMigrate 加 `AiCallLog` -- [x] `internal/ai/credits.go`:CAPABILITY_CREDITS + compute_credits + log_ai_call +- [x] `internal/ai/credits.go`:`UsageKindCredits` + `ComputeCredits` + `LogCall` - [x] `internal/api/ai_chat.go`:ChatMessage/QuickAction 接入扣点 + 日志 + 回退链 - [x] `internal/api/ai_admin.go`:AI 配置读/写/热重载/密钥状态 - [x] `internal/api/ai_usage.go`:用量聚合 + 按用户聚合 + 本人剩余点数 diff --git a/docs/2026-09-17_对象命名标准化清单.md b/docs/2026-09-17_对象命名标准化清单.md index 6e52fea..2942ef3 100644 --- a/docs/2026-09-17_对象命名标准化清单.md +++ b/docs/2026-09-17_对象命名标准化清单.md @@ -1,68 +1,70 @@ # 对象命名标准化清单 > 日期:2026-09-17 -> 性质:代码目录、文件名、变量名、函数名的对象命名标准化清单 +> 性质:**阶段性清单 / 历史记录** +> 2026-09-18 补注:本文原本记录的是 09-17 当天的待改项。其后代码与规范已大幅收口,**请勿再把本文当作当前现状说明**。当前规范以 `AR09_Object_Naming_Standard.md`、`TOP_CODING_RULES.md` 与代码现实为准。 > 关联文档: > - `docs/对象标准化与解耦总则.md` > - `docs/2026-09-17_六层架构与三对象建设重点阶段性复盘.md` +> - `docs/02_Architecture/AR09_Object_Naming_Standard.md` +> - `更名收尾说明.md` --- -## 一、结论 +## 一、这份清单现在该怎么读 -当前代码目录整体上已经开始围绕对象收口, -但命名层面仍然处于**新旧术语混用**的过渡态。 +这不是“还有哪些问题没修”的现状文档, +而是一次命名收口行动的**问题发现记录**。 -最准确的判断是: +它现在有两个用途: -- **目录结构基本清楚** -- **对象落点已经清楚** -- **命名标准还不统一** -- **旧词残留仍在持续污染对象边界** +1. 解释我们当时为什么要动那些名字 +2. 给后来者留一份“哪些误导性命名曾真实存在过”的反例库 -当前最主要的问题不是“找不到代码在哪”, -而是: +如果本文与当前代码冲突: -**看名字时,仍然经常不知道它到底指的是专员、技能、应用、默认助手,还是历史遗留概念。** +```text +以当前代码 + AR09 + 更名收尾说明 为准 +``` --- -## 二、当前对象落点是否清楚 +## 二、09-18 后的总状态 -### 1. 后端主模型落点是清楚的 +### 1. 已完成的主收口 -- 专员:`backend-go/internal/model/specialist.go` -- 技能:`backend-go/internal/model/skill_definition.go` -- 应用:`backend-go/internal/model/app_definition.go` -- 连接器:`backend-go/internal/connector/*` +以下问题已经在 09-18 这轮重构中完成: -其中三大对象模型命名基本准确: +| 项 | 09-17 问题 | 09-18 结果 | +|---|---|---| +| 应用对象主词 | `app` 与对象体系冲突 | 已统一为 `xapp` | +| 任务运行命名 | `worker_*` 混杂“专员形态 / 任务运行”两层语义 | 已统一为 `task_*` | +| 技能对象分类字段 | `RoleKind / role_kind` 带旧 `role` 心智 | 已迁到 `ObjectKind / object_kind` | +| AI 用量字段 | `capability` 停留在存储层 | 已迁到 `usage_kind` | +| 工作台入口协议 | 写读参数不一致 | 已统一为 `xapp_specialist / xapp_skill / xapp_prompt`,并抽成共享常量 | +| 静态旧目录变量 | `businessApps`、`availableSkills` | 前者已删;后者已更名 `staticSkillCatalog` | +| 默认聊天入口旧名 | `assistant.js` / `smart_assistant.go` | 已收口到 `chatMessage.js` / `chat_message.go` | +| XApp 中心旧存储 key | `eai-app-center` 长期兼容 | 已改为一次迁移后清旧 key | -- [specialist.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/specialist.go) -- [skill_definition.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/skill_definition.go#L5-L32) -- [app_definition.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/app_definition.go#L5-L38) +### 2. 仍可继续优化,但不再是主阻塞 -### 2. 前端 catalog store 方向也是清楚的 +这些点还值得继续做,但已经不是“命名体系没立住”的主问题: -- `specialistCatalog` -- `skillCatalog` -- `appCatalog` - -对应文件: - -- [specialistCatalog.js](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/specialistCatalog.js#L17-L88) -- [skillCatalog.js](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/skillCatalog.js) -- [appCatalog.js](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/appCatalog.js#L22-L155) - -这一层说明:**对象目录化方向是正确的。** +| 项 | 当前状态 | +|---|---| +| `projectStore.js` 文件名后缀 | 仍是孤例,可继续收口为 `project.js` | +| 某些历史方案文档 | 仍保留旧路径名,需逐步补“历史映射”说明 | +| 一些研究/阶段性文档中的 `app_*` 或 `capability` | 多属历史语境,不应再回流到代码实现 | --- -## 三、当前命名不清晰的主要问题 +## 三、09-17 当时识别出的核心问题 -## 3.1 旧术语和新术语混用 +下面这些判断在当时是成立的,之所以保留,是因为它们解释了后续为什么要那样改。 -当前代码中同时混着: +### 3.1 旧术语和新术语混用 + +当时代码里同时混着: - `role` - `assistant` @@ -72,478 +74,133 @@ - `worker` - `capability` -这会导致以下问题: +这个判断是对的,也是后续重构的出发点。 -1. 同一个对象被多个词指代 -2. 同一个词被多个对象复用 -3. 页面层很容易把对象边界重新写乱 +### 3.2 文件名、字段名、变量名在讲旧故事 -### 典型例子 +当时最典型的误导项包括: -技能模型已经叫 `SkillDefinition`, -但字段仍叫 `RoleKind`: -[skill_definition.go:L5-L25](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/skill_definition.go#L5-L25) - -这说明: - -- 模型名是新的 -- 字段语义还是旧的 - -这类命名会误导人以为 `role` 仍然是正式一级对象。 - -## 3.2 文件名与真实职责不完全一致 - -最典型的是: - -- [capability_definition.go](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/api/capability_definition.go#L1-L120) - -这个文件名叫 `capability_definition`, -但里面做的是: - -- `skillDefinitionReq` -- `actionDefinitionReq` -- `ListSkillDefinitions` - -问题不只是不好看, -而是它在语义上制造了一个模糊的一级概念:`capability`。 - -当前系统正式对象语言应是: - -- `specialist` -- `skill` -- `app` -- `connector` -- `action`(底层) - -不应再让 `capability` 作为主要文件名继续扩散。 - -## 3.3 历史静态目录变量名不准 - -最典型的旧变量在: -[workbench.js:L99-L105](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/config/workbench.js#L99-L105) -[workbench.js:L1434-L1443](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/config/workbench.js#L1434-L1443) - -包括: - -- `availableSkills` +- `capability_definition.go` +- `RoleKind` - `businessApps` -- `connectors` +- `availableSkills` +- 页面局部 `const app = specialistCatalog.getByKey(...)` +- `assistant.js` +- `workerRuntime.js` -其中问题最大的是: +这些名字的共同问题不是“难看”,而是**让对象边界持续失真**。 -- `businessApps` 实际装的是专员目录,不是应用目录 -- `availableSkills` 现在更像静态 fallback,不是正式生产技能目录 +### 3.3 入口协议写读不对称 -所以这些名字会把开发者带偏。 +09-17 时,应用目录与工作台的 query 参数协议不统一,这不是风格问题,而是功能 bug。 -## 3.4 页面局部变量有失真 - -例如: -[SmartAssistantPage.vue:L311-L334](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue#L311-L334) - -这里: - -```js -const app = specialistCatalog.getByKey(key) -``` - -拿到的是专员对象,却命名成 `app`。 - -这类局部变量不会影响编译, -但会持续破坏对象认知。 - -## 3.5 路由参数协议不统一 - -应用目录里拼路由时使用: -[appCatalog.js:L135-L145](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/appCatalog.js#L135-L145) - -- `specialist` -- `skill` -- `prompt` - -但工作台页面读取的是: -[SmartAssistantPage.vue:L602-L626](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/views/workbench/SmartAssistantPage.vue#L602-L626) - -- `app_specialist` -- `app_skill` -- `app_prompt` - -这已经不是风格差异, -而是**入口协议不统一**。 - -## 3.6 默认助手概念没有完全收口 - -当前存在: - -- API 文件名:`assistant.js` -- 默认技能 key:`smart-assistant` -- 默认专员 key:`general-assistant` - -见: - -- [assistant.js:L1-L3](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/api/assistant.js#L1-L3) -- [workerRuntime.js:L17-L21](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/workerRuntime.js#L17-L21) - -这说明“assistant”当前同时在承担: - -- 默认聊天接口名 -- 默认技能语义 -- 默认专员语义 - -需要明确边界,不然以后越做越乱。 +这一条后来直接推动了共享常量 `XAPP_ENTRY_QUERY_KEYS` 的建立。 --- -## 四、标准命名原则 +## 四、旧结论与新现实映射表 -## 4.1 正式对象术语 +为了避免后续阅读时把旧清单直接套到现代码,这里给出一张映射表。 -对外和对内统一如下: +| 本文旧说法 | 当前应理解为 | +|---|---| +| `app` 对象 | `xapp` 对象 | +| `appCatalog.js` | `xappCatalog.js` | +| `assistant.js` | `chatMessage.js` | +| `smart_assistant.go` | `chat_message.go` | +| `workerRuntime.js` | `taskRuntime.js` | +| `api/worker.js` | `api/taskRuntime.js` | +| `worker_task.go` / `worker_run.go` / `worker_artifact.go` | `task_record.go` / `task_run.go` / `task_artifact.go` | +| `app_specialist / app_skill / app_prompt` | `xapp_specialist / xapp_skill / xapp_prompt` | +| `RoleKind` | `ObjectKind` | +| `capability`(AI 用量字段) | `usage_kind` | +| `capability_definition.go` | `skill_action_definition.go` | +| `businessApps` | 已删除,不应复活 | +| `availableSkills` | `staticSkillCatalog` | -- 专员:`specialist` -- 技能:`skill` -- 应用:`app` -- 连接器:`connector` -- 动作:`action` +--- -## 4.2 非正式或历史兼容词的处理原则 +## 五、保留下来的有效原则 -以下词允许保留在历史兼容层, -但**不再继续扩散为新的正式命名**: +这份旧清单里,有些原则到今天仍然完全成立: -- `role` -- `businessApps` -- `availableSkills` -- `capability`(除非明确表示“泛能力总称”,不能再充当对象级文件名) -- `assistant`(除非明确指聊天接口或默认助手) - -## 4.3 命名优先级 - -命名时遵守: +### 5.1 对象名必须准确 ```text 对象准确性 > 历史兼容性 > 书写简短 ``` -意思是: +这条没有过时。 -- 宁可名字长一点 -- 也不要再用会误导对象边界的旧词 +### 5.2 旧词只能留在兼容层 ---- +像下面这些旧词: -## 五、标准化建议 +- `role` +- `worker` +- `capability` +- `assistant` -## 5.1 P0:立即统一入口协议和最容易误导人的命名 +只有在这三类位置允许保留: -### A. 统一应用入口 query 参数 +1. 迁移逻辑 +2. 迁移测试 +3. 历史说明文档 -当前应统一为一套, -不要一边写 `specialist/skill/prompt`, -一边读 `app_specialist/app_skill/app_prompt`。 +不能再回流到业务模型、API、store、组件和新文档标题。 -建议二选一,但必须全链路统一。 +### 5.3 页面局部变量也会污染认知 -推荐统一成: +`const app = specialistCatalog.getByKey(...)` 这种问题之所以要修, +不是因为会报错, +而是因为它会被后续代码继续照抄。 -- `app_specialist` -- `app_skill` -- `app_prompt` +这一判断现在仍然成立。 -原因: +### 5.4 入口协议必须抽成共享常量 -- 一眼能看出这是“应用带入工作台”的预置参数 -- 不会和普通页面 query 混淆 - -### B. 修正页面里的失真变量名 - -例如: - -- `const app = specialistCatalog.getByKey(...)` - -应改成: - -- `const specialist = ...` - -这类改动优先级很高, -因为它们会直接影响后续开发者理解对象边界。 - -### C. 停止新增 `businessApps` / `availableSkills` 这类旧变量名 - -现有代码可暂时保留兼容, -但后续新增代码禁止继续使用这些命名。 - ---- - -## 5.2 P1:统一文件名与对象职责 - -### A. `capability_definition.go` - -当前建议拆或改名: - -方案 1: -- `skill_definition.go` -- `action_definition.go` - -方案 2: -- 保留文件不拆,但改名为 `skill_and_action_definition.go` - -不建议继续用: - -- `capability_definition.go` - -因为它已经不能准确表达该文件实际职责。 - -### B. `assistant.js` - -当前如果它只是默认聊天接口, -建议更明确地表达为: - -- `smartAssistant.js` -或 -- `assistantChat.js` - -而不是继续模糊地叫 `assistant.js`。 - ---- - -## 5.3 P1:统一 store 与 fallback 的命名语义 - -### A. `availableSkills` - -如果继续保留作为静态补丁源, -建议改名为: - -- `staticSkillCatalog` -或 -- `legacySkillCatalog` - -不要再叫: - -- `availableSkills` - -因为现在真正“可用技能目录”已经是后端 + `skillCatalog`。 - -### B. `businessApps` - -如果继续保留作为专员静态兼容源, -建议改名为: - -- `staticSpecialistCatalog` -或 -- `legacySpecialistCatalog` - -不要再叫: - -- `businessApps` - -因为它和 `app` 已经明确冲突。 - ---- - -## 5.4 P2:统一字段语义 - -### A. `RoleKind` - -当前字段: -[skill_definition.go:L13](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/model/skill_definition.go#L13) - -建议后续迁移为更准确的名字,例如: - -- `object_kind` -或 -- `owner_kind` - -如果业务语义是“这个技能归属于哪类对象”。 - -如果短期不迁字段, -至少要在文档中明确: - -- `RoleKind` 是历史兼容字段 -- 不再代表正式 `role` 概念 - -### B. `assistant` 的语义边界 - -需要明确规定: - -- `assistant` 只用于默认通用助手的聊天接口语义 -- `specialist` 才是正式对象名 - -否则会继续出现: - -- 默认助手既像 skill 又像 specialist -- 页面里又再抽象成 role - ---- - -## 六、按文件的具体清单 - -## 6.1 后端 - -### `backend-go/internal/model/skill_definition.go` - -问题: - -- `RoleKind` 仍带旧语义 - -建议: - -- 文档先标记为历史兼容字段 -- 后续统一迁移为更准确字段名 - -### `backend-go/internal/api/capability_definition.go` - -问题: - -- 文件名与实际职责不匹配 -- `capability` 不是当前正式对象主词 - -建议: - -- 重命名或拆分 - -### `backend-go/internal/api/router.go` - -优点: - -- `/api/specialists` -- `/api/skills` -- `/api/apps` -- `/api/connectors` - -这层命名已经较清楚: -[router.go:L39-L49](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/backend-go/internal/api/router.go#L39-L49) - -建议: - -- 保持这层对象 API 命名不再回退 - -## 6.2 前端 - -### `frontend/src/config/workbench.js` - -问题: - -- 是历史静态大杂烩 -- `businessApps` 命名错误 -- `availableSkills` 命名已过时 - -建议: - -- 不再作为生产目录源 -- 继续降级为静态 fallback -- 变量名按对象真实语义重命名 - -### `frontend/src/views/workbench/SmartAssistantPage.vue` - -问题: - -- 局部变量名存在失真 -- `currentRolePresentation` 仍有旧 `role` 心智残留 - -建议: - -- 局部变量全部改成对象准确名 -- 将 `role presentation` 逐步收敛为 `currentObjectPresentation` - 或明确区分: - - `currentSpecialistPresentation` - - `currentSkillPresentation` - - `defaultAssistantPresentation` - -### `frontend/src/api/assistant.js` - -问题: - -- 文件名语义太宽 - -建议: - -- 仅在确认其职责是“默认助手聊天接口”后保留 -- 否则改名为更准确的接口文件名 - -### `frontend/src/store/appCatalog.js` - -优点: - -- `normalizeRemoteApp` -- `normalizeCustomApp` -- `resolveOpenRoute` - -这套命名整体清楚: -[appCatalog.js:L22-L145](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/appCatalog.js#L22-L145) - -问题: - -- 路由 query 命名与工作台读取不一致 - -建议: - -- 先统一 query 协议 - -### `frontend/src/store/specialistCatalog.js` - -优点: - -- 命名整体比较准确 -- `normalizeSpecialist / getByKey / getByPath` 清楚 - -见: -[specialistCatalog.js:L17-L87](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/eai_agentplatform/frontend/src/store/specialistCatalog.js#L17-L87) - -建议: - -- 继续作为专员目录的标准写法模板 - ---- - -## 七、执行顺序 - -建议按下面顺序做命名清理: - -1. **统一入口协议** - - app query 参数统一 - -2. **清理页面级失真变量** - - 特别是 `SmartAssistantPage.vue` - - `CurrentObjectChip.vue` - - `PlusMenu.vue` - -3. **清理历史静态变量名** - - `businessApps` - - `availableSkills` - -4. **清理文件名级旧词** - - `capability_definition.go` - - `assistant.js` - -5. **最后处理字段迁移** - - 如 `RoleKind` - -这样做的原因是: - -- 先收敛入口协议,避免继续产生新分叉 -- 再清局部变量,马上提升可读性 -- 最后再碰数据库字段,避免一次性改太重 - ---- - -## 八、最终判断 - -当前代码不是“命名完全混乱”, -而是: - -**目录已经开始清楚,但命名标准还没有真正收口。** - -最核心的问题不是技术能力, -而是术语迁移还没做完。 - -后续只要坚持: +09-17 的诊断后来被证明完全正确: ```text -对象名必须准确 -旧词只做兼容 -页面变量不得歪曲对象语义 -路由 / API / store 使用同一套对象协议 +能用结构消除的协议分叉,不要靠人工扫描补漏 ``` -这套代码的可读性会明显上一个台阶。 +--- + +## 六、当前建议的阅读顺序 + +如果现在要继续做命名与对象规范工作,建议按这个顺序看: + +1. [AR09_Object_Naming_Standard.md](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/docs/02_Architecture/AR09_Object_Naming_Standard.md) +2. [更名收尾说明.md](file:///home/eaiadmin/eaifiles/codebase/pj0235-eai_agentplatform-ubu/更名收尾说明.md) +3. 本文 + +原因很简单: + +- `AR09` 讲**当前正式规范** +- `更名收尾说明` 讲**这轮收口后哪些旧名只该留在迁移里** +- 本文只讲**09-17 当时发现过什么问题** + +--- + +## 七、结论 + +09-17 这份清单的价值不在于“它现在还是不是待办列表”, +而在于它准确记录了当时的混乱来源: + +- 对象主词混用 +- 路径协议分叉 +- 旧变量名继续扩散 +- 局部变量失真 +- 历史术语假装仍是正式术语 + +而 09-18 的重构已经把这些主问题大体收口。 + +所以现在最准确的判断是: + +```text +这份文档应被视为历史问题记录,而不是当前待办清单。 +``` + +后续若继续优化,应优先清理仍会误导人的历史文档与剩余孤例, +而不是回头按本文旧路径逐项“照单执行”。 diff --git a/docs/AionUi助手到pj0235三层映射表与首个样板方案.md b/docs/AionUi助手到pj0235三层映射表与首个样板方案.md index 95b0ab8..a554b05 100644 --- a/docs/AionUi助手到pj0235三层映射表与首个样板方案.md +++ b/docs/AionUi助手到pj0235三层映射表与首个样板方案.md @@ -139,9 +139,9 @@ - 优化默认提示词和文案 3. 让应用入口支持同时挂载 - - `app_specialist` - - `app_skill` - - `app_prompt` + - `xapp_specialist` + - `xapp_skill` + - `xapp_prompt` 4. 增加项目模板 - `汇报 PPT` @@ -179,4 +179,3 @@ - **第一个样板已经确定为:汇报 PPT** 下一轮继续扩,不再需要重做架构判断,直接按样板复制即可。 - diff --git a/docs/对象标准化与解耦总则.md b/docs/对象标准化与解耦总则.md index 9c5af8d..32de78e 100644 --- a/docs/对象标准化与解耦总则.md +++ b/docs/对象标准化与解耦总则.md @@ -237,8 +237,8 @@ CatalogObjectView 应该逐步补成显式关系: - `specialist_skill_binding` -- `app_specialist_binding` -- `app_skill_binding` +- `xapp_specialist_binding` +- `xapp_skill_binding` - `specialist_connector_binding` 最终应该形成: diff --git a/eai_agentplatform/CLAUDE.md b/eai_agentplatform/CLAUDE.md index e2d0385..c1e4ea4 100644 --- a/eai_agentplatform/CLAUDE.md +++ b/eai_agentplatform/CLAUDE.md @@ -38,9 +38,11 @@ pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根) └── eai_agentplatform/ # 应用代码 ├── backend-go/ # Go 后端(正式,唯一后端) │ ├── cmd/ internal/ # Go 源码 - │ ├── knowledge_source/ # 知识源 Markdown(运行时数据) │ ├── data/ # 运行时数据(db+media,git 忽略) │ └── deploy/ # 交付物(systemd/env/清理脚本/DELIVERY.md) + ├── assets/ # 内容资产层(运行时输入,不属于源码) + │ ├── knowledge/source/ # 知识源 Markdown + │ └── training/materials/ # 培训资料资产 ├── frontend/ # 前端完整代码 ├── CLAUDE.md # AI 协作锚点 ├── PROJECT_STATE.md # 项目全局状态 @@ -83,4 +85,4 @@ pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根) - **LLM 适配器**:Go net/http 实现(无 openai SDK 依赖),支持 SSE 流式 + 非流式调用 - **配置优先级**:system_config 数据库表 → .env 文件,缺任何配置 Fail Fast 抛 501 -- **知识检索**:混合检索(向量 brute-force + 关键词),embedding 走 Ollama bge-m3 \ No newline at end of file +- **知识检索**:混合检索(向量 brute-force + 关键词),embedding 走 Ollama bge-m3 diff --git a/eai_agentplatform/PROJECT_STATE.md b/eai_agentplatform/PROJECT_STATE.md index d10686b..34df104 100644 --- a/eai_agentplatform/PROJECT_STATE.md +++ b/eai_agentplatform/PROJECT_STATE.md @@ -53,7 +53,7 @@ | D09 | 视频 > 100MB 分片上传 + 断点续传 | 已定 | | D10 | 题库 + 组卷配置(随机/固定抽题) | 已定 | | D11 | 角色:employee / admin 两级,无复杂权限 | 已定 | -| D12 | 结构化知识源通道:backend-go/knowledge_source/ md → knowledge_source 表审批 → 摄入 product/question/chunk | 已定 | +| D12 | 结构化知识源通道:assets/knowledge/source/ md → knowledge_source 表审批 → 摄入 product/question/chunk | 已定 | | D13 | 向量检索:brute-force 余弦 + Ollama bge-m3 embedding(单容器双模型,随盘交付) | 已定 | | D14 | 交付形态:裸进程 + systemd + Clonezilla 整盘克隆,Go 单二进制无源码交付 | 已定 | | D15 | 岗位与知识对应:Position/PositionKnowledge 表 + 用户单岗位(`user.position_id`)+ 岗位驱动组卷 | 已定(P0 落地) | @@ -80,7 +80,8 @@ | 部署文档 | `../docs/deploy.md` | 部署架构与步骤 | | 变更日志 | `../docs/changelog.md` | 版本变更记录 | | 知识入库体系 | `../docs/04_Backend/BE05_Knowledge_Ingest_Module.md` | 上传→生成→审批→入库总设计 | -| 知识源目录 | `backend-go/knowledge_source/` | 结构化知识源 md(5 份,28 产品 + 20 题) | +| 知识源目录 | `assets/knowledge/source/` | 结构化知识源 md(5 份,28 产品 + 20 题) | +| 培训资料目录 | `assets/training/materials/` | 培训课程资产与种子源文件 | | 编码准则 | `../TOP_CODING_RULES.md` | 编码与调试最高准则 | | AI 协作锚点 | `CLAUDE.md` | AI 新对话启动仪式 | @@ -103,9 +104,11 @@ pj0235-eai_agentplatform-ubu/ # 工作区根(仓库根) └── eai_agentplatform/ # 应用代码 ├── backend-go/ # Go 后端(正式,唯一后端,已完成) │ ├── cmd/ internal/ # Go 源码 - │ ├── knowledge_source/ # 知识源 Markdown(运行时数据) │ ├── data/ # 运行时数据(db+media,git 忽略) │ └── deploy/ # 交付物(systemd/env/清理脚本/DELIVERY.md) + ├── assets/ # 内容资产层(运行时输入,不属于源码) + │ ├── knowledge/source/ # 知识源 Markdown + │ └── training/materials/ # 培训资料资产 ├── frontend/ # 前端代码 ├── CLAUDE.md ├── PROJECT_STATE.md diff --git a/eai_agentplatform/backend-go/knowledge_source/01_通用规则.md b/eai_agentplatform/assets/knowledge/source/01_通用规则.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/01_通用规则.md rename to eai_agentplatform/assets/knowledge/source/01_通用规则.md diff --git a/eai_agentplatform/backend-go/knowledge_source/02_资本咨询类.md b/eai_agentplatform/assets/knowledge/source/02_资本咨询类.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/02_资本咨询类.md rename to eai_agentplatform/assets/knowledge/source/02_资本咨询类.md diff --git a/eai_agentplatform/backend-go/knowledge_source/03_资质认定辅导类.md b/eai_agentplatform/assets/knowledge/source/03_资质认定辅导类.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/03_资质认定辅导类.md rename to eai_agentplatform/assets/knowledge/source/03_资质认定辅导类.md diff --git a/eai_agentplatform/backend-go/knowledge_source/04_AI咨询与实施类.md b/eai_agentplatform/assets/knowledge/source/04_AI咨询与实施类.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/04_AI咨询与实施类.md rename to eai_agentplatform/assets/knowledge/source/04_AI咨询与实施类.md diff --git a/eai_agentplatform/backend-go/knowledge_source/05_企业级AI工具与平台.md b/eai_agentplatform/assets/knowledge/source/05_企业级AI工具与平台.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/05_企业级AI工具与平台.md rename to eai_agentplatform/assets/knowledge/source/05_企业级AI工具与平台.md diff --git a/eai_agentplatform/backend-go/knowledge_source/06_资质认定政策与实操.md b/eai_agentplatform/assets/knowledge/source/06_资质认定政策与实操.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/06_资质认定政策与实操.md rename to eai_agentplatform/assets/knowledge/source/06_资质认定政策与实操.md diff --git a/eai_agentplatform/backend-go/knowledge_source/07_资本运作与股权融资.md b/eai_agentplatform/assets/knowledge/source/07_资本运作与股权融资.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/07_资本运作与股权融资.md rename to eai_agentplatform/assets/knowledge/source/07_资本运作与股权融资.md diff --git a/eai_agentplatform/backend-go/knowledge_source/08_企业AI落地方法论.md b/eai_agentplatform/assets/knowledge/source/08_企业AI落地方法论.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/08_企业AI落地方法论.md rename to eai_agentplatform/assets/knowledge/source/08_企业AI落地方法论.md diff --git a/eai_agentplatform/backend-go/knowledge_source/09_顾问式销售方法论.md b/eai_agentplatform/assets/knowledge/source/09_顾问式销售方法论.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/09_顾问式销售方法论.md rename to eai_agentplatform/assets/knowledge/source/09_顾问式销售方法论.md diff --git a/eai_agentplatform/backend-go/knowledge_source/10_管理员审批测试样本.md b/eai_agentplatform/assets/knowledge/source/10_管理员审批测试样本.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/10_管理员审批测试样本.md rename to eai_agentplatform/assets/knowledge/source/10_管理员审批测试样本.md diff --git a/eai_agentplatform/backend-go/knowledge_source/11_规则制度库_审批制度样本.md b/eai_agentplatform/assets/knowledge/source/11_规则制度库_审批制度样本.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/11_规则制度库_审批制度样本.md rename to eai_agentplatform/assets/knowledge/source/11_规则制度库_审批制度样本.md diff --git a/eai_agentplatform/backend-go/knowledge_source/12_通用知识库_日常协同样本.md b/eai_agentplatform/assets/knowledge/source/12_通用知识库_日常协同样本.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/12_通用知识库_日常协同样本.md rename to eai_agentplatform/assets/knowledge/source/12_通用知识库_日常协同样本.md diff --git a/eai_agentplatform/backend-go/knowledge_source/13_产品知识库_产品卖点样本.md b/eai_agentplatform/assets/knowledge/source/13_产品知识库_产品卖点样本.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/13_产品知识库_产品卖点样本.md rename to eai_agentplatform/assets/knowledge/source/13_产品知识库_产品卖点样本.md diff --git a/eai_agentplatform/backend-go/knowledge_source/14_培训资料库_学习运营样本.md b/eai_agentplatform/assets/knowledge/source/14_培训资料库_学习运营样本.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/14_培训资料库_学习运营样本.md rename to eai_agentplatform/assets/knowledge/source/14_培训资料库_学习运营样本.md diff --git a/eai_agentplatform/backend-go/knowledge_source/15_规则制度库_发布合规样本.md b/eai_agentplatform/assets/knowledge/source/15_规则制度库_发布合规样本.md similarity index 100% rename from eai_agentplatform/backend-go/knowledge_source/15_规则制度库_发布合规样本.md rename to eai_agentplatform/assets/knowledge/source/15_规则制度库_发布合规样本.md diff --git a/eai_agentplatform/backend-go/knowledge_source/README.md b/eai_agentplatform/assets/knowledge/source/README.md similarity index 90% rename from eai_agentplatform/backend-go/knowledge_source/README.md rename to eai_agentplatform/assets/knowledge/source/README.md index 68ac03e..95fd477 100644 --- a/eai_agentplatform/backend-go/knowledge_source/README.md +++ b/eai_agentplatform/assets/knowledge/source/README.md @@ -1,7 +1,7 @@ # 知识源入库目录(Knowledge Source Ingest) -> 本目录是「结构化知识源文档」的唯一入库口,与「素材上传流水线」(data/kb_data)并行。 -> 数据权威源:`../../docs/博昇产品与渠道合作表.md`(对齐《客户介绍合作协议》第二条)。 +> 本目录是「结构化知识源文档」的唯一入库口,与「素材上传流水线」(backend-go/data/kb_data)并行。 +> 数据权威源:`../../../../docs/博昇产品与渠道合作表.md`(对齐《客户介绍合作协议》第二条)。 --- @@ -68,7 +68,7 @@ pending ──审批通过──▶ approved ──▶ 生效(product active / ## 6. 代码侧已实现(Go) -> 完整设计见 `../../docs/04_Backend/BE05_Knowledge_Ingest_Module.md`(知识入库体系总设计)。 +> 完整设计见 `../../../../docs/04_Backend/BE05_Knowledge_Ingest_Module.md`(知识入库体系总设计)。 - [x] `internal/model/knowledge_source.go` —— 知识源文档表 - [x] `internal/model/knowledge_chunk.go` —— 来源扩展(media_file_id 可空 + knowledge_source_id) diff --git a/eai_agentplatform/training_materials/mp4/01_公司介绍.mp4 b/eai_agentplatform/assets/training/materials/mp4/01_公司介绍.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/01_公司介绍.mp4 rename to eai_agentplatform/assets/training/materials/mp4/01_公司介绍.mp4 diff --git a/eai_agentplatform/training_materials/mp4/02_产品线总览.mp4 b/eai_agentplatform/assets/training/materials/mp4/02_产品线总览.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/02_产品线总览.mp4 rename to eai_agentplatform/assets/training/materials/mp4/02_产品线总览.mp4 diff --git a/eai_agentplatform/training_materials/mp4/03_高企认定八大条件.mp4 b/eai_agentplatform/assets/training/materials/mp4/03_高企认定八大条件.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/03_高企认定八大条件.mp4 rename to eai_agentplatform/assets/training/materials/mp4/03_高企认定八大条件.mp4 diff --git a/eai_agentplatform/training_materials/mp4/04_专精特新梯度培育.mp4 b/eai_agentplatform/assets/training/materials/mp4/04_专精特新梯度培育.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/04_专精特新梯度培育.mp4 rename to eai_agentplatform/assets/training/materials/mp4/04_专精特新梯度培育.mp4 diff --git a/eai_agentplatform/training_materials/mp4/05_资本咨询销售要点.mp4 b/eai_agentplatform/assets/training/materials/mp4/05_资本咨询销售要点.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/05_资本咨询销售要点.mp4 rename to eai_agentplatform/assets/training/materials/mp4/05_资本咨询销售要点.mp4 diff --git a/eai_agentplatform/training_materials/mp4/06_企业AI落地方法论.mp4 b/eai_agentplatform/assets/training/materials/mp4/06_企业AI落地方法论.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/06_企业AI落地方法论.mp4 rename to eai_agentplatform/assets/training/materials/mp4/06_企业AI落地方法论.mp4 diff --git a/eai_agentplatform/training_materials/mp4/07_顾问式销售FABE法则.mp4 b/eai_agentplatform/assets/training/materials/mp4/07_顾问式销售FABE法则.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/07_顾问式销售FABE法则.mp4 rename to eai_agentplatform/assets/training/materials/mp4/07_顾问式销售FABE法则.mp4 diff --git a/eai_agentplatform/training_materials/mp4/08_SPIN提问技巧.mp4 b/eai_agentplatform/assets/training/materials/mp4/08_SPIN提问技巧.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/08_SPIN提问技巧.mp4 rename to eai_agentplatform/assets/training/materials/mp4/08_SPIN提问技巧.mp4 diff --git a/eai_agentplatform/training_materials/mp4/09_LSCPA异议处理.mp4 b/eai_agentplatform/assets/training/materials/mp4/09_LSCPA异议处理.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/09_LSCPA异议处理.mp4 rename to eai_agentplatform/assets/training/materials/mp4/09_LSCPA异议处理.mp4 diff --git a/eai_agentplatform/training_materials/mp4/10_销售成交技巧.mp4 b/eai_agentplatform/assets/training/materials/mp4/10_销售成交技巧.mp4 similarity index 100% rename from eai_agentplatform/training_materials/mp4/10_销售成交技巧.mp4 rename to eai_agentplatform/assets/training/materials/mp4/10_销售成交技巧.mp4 diff --git a/eai_agentplatform/training_materials/pdf/01_公司介绍.pdf b/eai_agentplatform/assets/training/materials/pdf/01_公司介绍.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/01_公司介绍.pdf rename to eai_agentplatform/assets/training/materials/pdf/01_公司介绍.pdf diff --git a/eai_agentplatform/training_materials/pdf/02_产品线总览与销售地图.pdf b/eai_agentplatform/assets/training/materials/pdf/02_产品线总览与销售地图.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/02_产品线总览与销售地图.pdf rename to eai_agentplatform/assets/training/materials/pdf/02_产品线总览与销售地图.pdf diff --git a/eai_agentplatform/training_materials/pdf/03_资本咨询类产品手册.pdf b/eai_agentplatform/assets/training/materials/pdf/03_资本咨询类产品手册.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/03_资本咨询类产品手册.pdf rename to eai_agentplatform/assets/training/materials/pdf/03_资本咨询类产品手册.pdf diff --git a/eai_agentplatform/training_materials/pdf/04_资质认定辅导类产品手册.pdf b/eai_agentplatform/assets/training/materials/pdf/04_资质认定辅导类产品手册.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/04_资质认定辅导类产品手册.pdf rename to eai_agentplatform/assets/training/materials/pdf/04_资质认定辅导类产品手册.pdf diff --git a/eai_agentplatform/training_materials/pdf/05_AI咨询类产品手册.pdf b/eai_agentplatform/assets/training/materials/pdf/05_AI咨询类产品手册.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/05_AI咨询类产品手册.pdf rename to eai_agentplatform/assets/training/materials/pdf/05_AI咨询类产品手册.pdf diff --git a/eai_agentplatform/training_materials/pdf/06_企业级AI工具平台产品手册.pdf b/eai_agentplatform/assets/training/materials/pdf/06_企业级AI工具平台产品手册.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/06_企业级AI工具平台产品手册.pdf rename to eai_agentplatform/assets/training/materials/pdf/06_企业级AI工具平台产品手册.pdf diff --git a/eai_agentplatform/training_materials/pdf/07_高新技术企业认定政策解读.pdf b/eai_agentplatform/assets/training/materials/pdf/07_高新技术企业认定政策解读.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/07_高新技术企业认定政策解读.pdf rename to eai_agentplatform/assets/training/materials/pdf/07_高新技术企业认定政策解读.pdf diff --git a/eai_agentplatform/training_materials/pdf/08_专精特新企业认定政策解读.pdf b/eai_agentplatform/assets/training/materials/pdf/08_专精特新企业认定政策解读.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/08_专精特新企业认定政策解读.pdf rename to eai_agentplatform/assets/training/materials/pdf/08_专精特新企业认定政策解读.pdf diff --git a/eai_agentplatform/training_materials/pdf/09_资本运作与股权融资基础讲义.pdf b/eai_agentplatform/assets/training/materials/pdf/09_资本运作与股权融资基础讲义.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/09_资本运作与股权融资基础讲义.pdf rename to eai_agentplatform/assets/training/materials/pdf/09_资本运作与股权融资基础讲义.pdf diff --git a/eai_agentplatform/training_materials/pdf/10_企业AI落地方法论讲义.pdf b/eai_agentplatform/assets/training/materials/pdf/10_企业AI落地方法论讲义.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/10_企业AI落地方法论讲义.pdf rename to eai_agentplatform/assets/training/materials/pdf/10_企业AI落地方法论讲义.pdf diff --git a/eai_agentplatform/training_materials/pdf/11_顾问式销售方法论讲义.pdf b/eai_agentplatform/assets/training/materials/pdf/11_顾问式销售方法论讲义.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/11_顾问式销售方法论讲义.pdf rename to eai_agentplatform/assets/training/materials/pdf/11_顾问式销售方法论讲义.pdf diff --git a/eai_agentplatform/training_materials/pdf/12_销售话术手册_资本咨询.pdf b/eai_agentplatform/assets/training/materials/pdf/12_销售话术手册_资本咨询.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/12_销售话术手册_资本咨询.pdf rename to eai_agentplatform/assets/training/materials/pdf/12_销售话术手册_资本咨询.pdf diff --git a/eai_agentplatform/training_materials/pdf/13_销售话术手册_资质认定.pdf b/eai_agentplatform/assets/training/materials/pdf/13_销售话术手册_资质认定.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/13_销售话术手册_资质认定.pdf rename to eai_agentplatform/assets/training/materials/pdf/13_销售话术手册_资质认定.pdf diff --git a/eai_agentplatform/training_materials/pdf/14_新人入职培训手册.pdf b/eai_agentplatform/assets/training/materials/pdf/14_新人入职培训手册.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/14_新人入职培训手册.pdf rename to eai_agentplatform/assets/training/materials/pdf/14_新人入职培训手册.pdf diff --git a/eai_agentplatform/training_materials/pdf/15_考试题库复习提纲.pdf b/eai_agentplatform/assets/training/materials/pdf/15_考试题库复习提纲.pdf similarity index 100% rename from eai_agentplatform/training_materials/pdf/15_考试题库复习提纲.pdf rename to eai_agentplatform/assets/training/materials/pdf/15_考试题库复习提纲.pdf diff --git a/eai_agentplatform/training_materials/word/01_新人入职学习计划.docx b/eai_agentplatform/assets/training/materials/word/01_新人入职学习计划.docx similarity index 100% rename from eai_agentplatform/training_materials/word/01_新人入职学习计划.docx rename to eai_agentplatform/assets/training/materials/word/01_新人入职学习计划.docx diff --git a/eai_agentplatform/training_materials/word/02_产品佣金速查表.docx b/eai_agentplatform/assets/training/materials/word/02_产品佣金速查表.docx similarity index 100% rename from eai_agentplatform/training_materials/word/02_产品佣金速查表.docx rename to eai_agentplatform/assets/training/materials/word/02_产品佣金速查表.docx diff --git a/eai_agentplatform/training_materials/word/03_资本咨询销售流程清单.docx b/eai_agentplatform/assets/training/materials/word/03_资本咨询销售流程清单.docx similarity index 100% rename from eai_agentplatform/training_materials/word/03_资本咨询销售流程清单.docx rename to eai_agentplatform/assets/training/materials/word/03_资本咨询销售流程清单.docx diff --git a/eai_agentplatform/training_materials/word/04_资质认定销售流程清单.docx b/eai_agentplatform/assets/training/materials/word/04_资质认定销售流程清单.docx similarity index 100% rename from eai_agentplatform/training_materials/word/04_资质认定销售流程清单.docx rename to eai_agentplatform/assets/training/materials/word/04_资质认定销售流程清单.docx diff --git a/eai_agentplatform/training_materials/word/05_高企认定八条件自查清单.docx b/eai_agentplatform/assets/training/materials/word/05_高企认定八条件自查清单.docx similarity index 100% rename from eai_agentplatform/training_materials/word/05_高企认定八条件自查清单.docx rename to eai_agentplatform/assets/training/materials/word/05_高企认定八条件自查清单.docx diff --git a/eai_agentplatform/training_materials/word/06_专精特新申报自查表.docx b/eai_agentplatform/assets/training/materials/word/06_专精特新申报自查表.docx similarity index 100% rename from eai_agentplatform/training_materials/word/06_专精特新申报自查表.docx rename to eai_agentplatform/assets/training/materials/word/06_专精特新申报自查表.docx diff --git a/eai_agentplatform/training_materials/word/07_融资尽调资料清单.docx b/eai_agentplatform/assets/training/materials/word/07_融资尽调资料清单.docx similarity index 100% rename from eai_agentplatform/training_materials/word/07_融资尽调资料清单.docx rename to eai_agentplatform/assets/training/materials/word/07_融资尽调资料清单.docx diff --git a/eai_agentplatform/training_materials/word/08_AI落地POC检查表.docx b/eai_agentplatform/assets/training/materials/word/08_AI落地POC检查表.docx similarity index 100% rename from eai_agentplatform/training_materials/word/08_AI落地POC检查表.docx rename to eai_agentplatform/assets/training/materials/word/08_AI落地POC检查表.docx diff --git a/eai_agentplatform/training_materials/word/09_顾问式销售演练脚本.docx b/eai_agentplatform/assets/training/materials/word/09_顾问式销售演练脚本.docx similarity index 100% rename from eai_agentplatform/training_materials/word/09_顾问式销售演练脚本.docx rename to eai_agentplatform/assets/training/materials/word/09_顾问式销售演练脚本.docx diff --git a/eai_agentplatform/training_materials/word/10_SPIN提问话术练习.docx b/eai_agentplatform/assets/training/materials/word/10_SPIN提问话术练习.docx similarity index 100% rename from eai_agentplatform/training_materials/word/10_SPIN提问话术练习.docx rename to eai_agentplatform/assets/training/materials/word/10_SPIN提问话术练习.docx diff --git a/eai_agentplatform/training_materials/word/11_LSCPA异议处理练习.docx b/eai_agentplatform/assets/training/materials/word/11_LSCPA异议处理练习.docx similarity index 100% rename from eai_agentplatform/training_materials/word/11_LSCPA异议处理练习.docx rename to eai_agentplatform/assets/training/materials/word/11_LSCPA异议处理练习.docx diff --git a/eai_agentplatform/training_materials/word/12_客户异议处理应对表.docx b/eai_agentplatform/assets/training/materials/word/12_客户异议处理应对表.docx similarity index 100% rename from eai_agentplatform/training_materials/word/12_客户异议处理应对表.docx rename to eai_agentplatform/assets/training/materials/word/12_客户异议处理应对表.docx diff --git a/eai_agentplatform/training_materials/word/13_销售周报模板.docx b/eai_agentplatform/assets/training/materials/word/13_销售周报模板.docx similarity index 100% rename from eai_agentplatform/training_materials/word/13_销售周报模板.docx rename to eai_agentplatform/assets/training/materials/word/13_销售周报模板.docx diff --git a/eai_agentplatform/training_materials/word/14_考试模拟练习卷.docx b/eai_agentplatform/assets/training/materials/word/14_考试模拟练习卷.docx similarity index 100% rename from eai_agentplatform/training_materials/word/14_考试模拟练习卷.docx rename to eai_agentplatform/assets/training/materials/word/14_考试模拟练习卷.docx diff --git a/eai_agentplatform/training_materials/word/15_产品知识速查手册.docx b/eai_agentplatform/assets/training/materials/word/15_产品知识速查手册.docx similarity index 100% rename from eai_agentplatform/training_materials/word/15_产品知识速查手册.docx rename to eai_agentplatform/assets/training/materials/word/15_产品知识速查手册.docx diff --git a/eai_agentplatform/training_materials/work/__pycache__/content.cpython-310.pyc b/eai_agentplatform/assets/training/materials/work/__pycache__/content.cpython-310.pyc similarity index 100% rename from eai_agentplatform/training_materials/work/__pycache__/content.cpython-310.pyc rename to eai_agentplatform/assets/training/materials/work/__pycache__/content.cpython-310.pyc diff --git a/eai_agentplatform/training_materials/work/content.py b/eai_agentplatform/assets/training/materials/work/content.py similarity index 100% rename from eai_agentplatform/training_materials/work/content.py rename to eai_agentplatform/assets/training/materials/work/content.py diff --git a/eai_agentplatform/training_materials/work/courses.json b/eai_agentplatform/assets/training/materials/work/courses.json similarity index 100% rename from eai_agentplatform/training_materials/work/courses.json rename to eai_agentplatform/assets/training/materials/work/courses.json diff --git a/eai_agentplatform/training_materials/work/gen_docx.py b/eai_agentplatform/assets/training/materials/work/gen_docx.py similarity index 100% rename from eai_agentplatform/training_materials/work/gen_docx.py rename to eai_agentplatform/assets/training/materials/work/gen_docx.py diff --git a/eai_agentplatform/training_materials/work/gen_mp4.py b/eai_agentplatform/assets/training/materials/work/gen_mp4.py similarity index 100% rename from eai_agentplatform/training_materials/work/gen_mp4.py rename to eai_agentplatform/assets/training/materials/work/gen_mp4.py diff --git a/eai_agentplatform/training_materials/work/gen_pdf.py b/eai_agentplatform/assets/training/materials/work/gen_pdf.py similarity index 100% rename from eai_agentplatform/training_materials/work/gen_pdf.py rename to eai_agentplatform/assets/training/materials/work/gen_pdf.py diff --git a/eai_agentplatform/training_materials/work/products.json b/eai_agentplatform/assets/training/materials/work/products.json similarity index 100% rename from eai_agentplatform/training_materials/work/products.json rename to eai_agentplatform/assets/training/materials/work/products.json diff --git a/eai_agentplatform/training_materials/work/questions.json b/eai_agentplatform/assets/training/materials/work/questions.json similarity index 100% rename from eai_agentplatform/training_materials/work/questions.json rename to eai_agentplatform/assets/training/materials/work/questions.json diff --git a/eai_agentplatform/backend-go/cmd/server/main.go b/eai_agentplatform/backend-go/cmd/server/main.go index f56986a..ca23834 100644 --- a/eai_agentplatform/backend-go/cmd/server/main.go +++ b/eai_agentplatform/backend-go/cmd/server/main.go @@ -11,6 +11,8 @@ import ( "eai_agentplatform/backend/internal/auth" "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/repository" + specialistseeding "eai_agentplatform/backend/internal/specialists/seeding" "eai_agentplatform/backend/internal/store" ) @@ -29,12 +31,17 @@ func main() { cfg := config.Load() - if _, err := store.Init(cfg.DBPath); err != nil { + db, err := store.Init(cfg.DBPath) + if err != nil { log.Fatalf("数据库初始化失败: %v", err) } + repository.SetDB(db) if err := store.SeedDefaults(); err != nil { log.Fatalf("种子数据初始化失败: %v", err) } + if err := specialistseeding.SeedSpecialists(store.DB); err != nil { + log.Fatalf("专员目录种子初始化失败: %v", err) + } config.StartAIRouteHealthLoop(30 * time.Minute) @@ -58,9 +65,11 @@ func main() { // 用于升级/迁移/交付前留一份手边的副本,比等定期那份更让人放心。 func backupOnce() { cfg := config.Load() - if _, err := store.Init(cfg.DBPath); err != nil { + db, err := store.Init(cfg.DBPath) + if err != nil { log.Fatalf("数据库初始化失败: %v", err) } + repository.SetDB(db) path, err := store.Backup(store.DB, cfg.BackupDir, cfg.BackupKeep, time.Now()) if err != nil { log.Fatalf("备份失败: %v", err) @@ -74,9 +83,11 @@ func resetAdmin(args []string) { log.Fatal("用法: eai_agentplatform-server -reset-admin <新密码>") } cfg := config.Load() - if _, err := store.Init(cfg.DBPath); err != nil { + db, err := store.Init(cfg.DBPath) + if err != nil { log.Fatalf("数据库初始化失败: %v", err) } + repository.SetDB(db) hash, err := auth.HashPassword(args[0]) if err != nil { log.Fatalf("密码加密失败: %v", err) diff --git a/eai_agentplatform/backend-go/deploy/DELIVERY.md b/eai_agentplatform/backend-go/deploy/DELIVERY.md index 53bda47..d912c93 100644 --- a/eai_agentplatform/backend-go/deploy/DELIVERY.md +++ b/eai_agentplatform/backend-go/deploy/DELIVERY.md @@ -15,7 +15,8 @@ | `clonezilla-cleanup.sh` | 克隆前清理脚本(DRY-RUN 默认) | | `data/eai_agentplatform.db` | SQLite 数据库(首启自动建表) | | `data/kb_data/` | 已审批素材目录 | -| `knowledge_source/` | 知识源 Markdown(待入库,由管理员审批) | +| `assets/knowledge/source/` | 知识源 Markdown(待入库,由管理员审批) | +| `assets/training/materials/` | 培训资料资产(课程种子、PDF、视频、脚本) | **不需要**:Go 运行时、Python、Docker、MySQL、任何 pip/npm 依赖。 @@ -27,11 +28,15 @@ /opt/eai_agentplatform/ ├── eai_agentplatform-server # 单二进制 ├── .env # JWT 密钥等(交付前生成,勿提交源码库) +├── assets/ +│ ├── knowledge/ +│ │ └── source/ # 知识源 Markdown +│ └── training/ +│ └── materials/ # 培训资料资产 ├── data/ │ ├── eai_agentplatform.db # SQLite(首启自动建) │ ├── backups/ # 定期备份(服务自动维护,见第 7 节) │ └── kb_data/ # 素材 + 提取缓存 -├── knowledge_source/ # 知识源 Markdown └── (前端静态资源由 Nginx 托管,见 ../docs/deploy.md) ``` @@ -48,7 +53,7 @@ go build -o bin/eai_agentplatform-server ./cmd/server # 2.2 拷贝到原型机 + 建账号 + 装 systemd 单元 sudo install -m 0755 eai_agentplatform-server /opt/eai_agentplatform/eai_agentplatform-server sudo useradd -r -s /usr/sbin/nologin eai_agentplatform -sudo mkdir -p /opt/eai_agentplatform/data/kb_data /opt/eai_agentplatform/knowledge_source +sudo mkdir -p /opt/eai_agentplatform/data/kb_data /opt/eai_agentplatform/assets/knowledge/source /opt/eai_agentplatform/assets/training/materials sudo chown -R eai_agentplatform:eai_agentplatform /opt/eai_agentplatform sudo install -m 0644 deploy/eai_agentplatform.service /etc/systemd/system/eai_agentplatform.service diff --git a/eai_agentplatform/backend-go/deploy/eai_agentplatform.env b/eai_agentplatform/backend-go/deploy/eai_agentplatform.env index f365e12..08f4103 100644 --- a/eai_agentplatform/backend-go/deploy/eai_agentplatform.env +++ b/eai_agentplatform/backend-go/deploy/eai_agentplatform.env @@ -18,8 +18,10 @@ LLM_MODEL=qwen2.5:7b EMBED_MODEL=bge-m3 # 数据目录 +ASSET_ROOT_DIR=/opt/eai_agentplatform/assets KB_DATA_DIR=data/kb_data -KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/knowledge_source +KNOWLEDGE_SOURCE_DIR=/opt/eai_agentplatform/assets/knowledge/source +TRAINING_MATERIALS_DIR=/opt/eai_agentplatform/assets/training/materials # 定期备份(默认开:启动时补一次,之后每 BACKUP_INTERVAL_HOURS 小时检查一次,保留最近 BACKUP_KEEP 份) # 备份目录必须留在 data/ 下 —— systemd 单元是 ProtectSystem=strict,只放开了 data/ 可写 diff --git a/eai_agentplatform/backend-go/internal/ai/credits.go b/eai_agentplatform/backend-go/internal/ai/credits.go index 683e588..a5ab6f7 100644 --- a/eai_agentplatform/backend-go/internal/ai/credits.go +++ b/eai_agentplatform/backend-go/internal/ai/credits.go @@ -13,36 +13,45 @@ import ( // 按用户算力点计费 // ────────────────────────────────────────────── -// AI 能力常量 +// AI 用量类型常量 const ( - CapabilityAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点) - CapabilityTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点) - CapabilityImageGen = "image_gen" // 文生图(按次扣点) - CapabilityEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计) - CapabilityEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计) + UsageKindAIChat = "ai_chat" // PathCoach 对话(每轮扣 1 点) + UsageKindTextGen = "text_gen" // 快捷动作(情景演练/查佣金/产品对比,扣 1 点) + UsageKindImageGen = "image_gen" // 文生图(按次扣点) + UsageKindEmbed = "embed" // 知识检索内部 embedding(不扣点,仅审计) + UsageKindEssayGrade = "essay_grade" // 简答题 LLM 评分(系统自动,不扣点,仅审计) + + // 以下 5 类为工具型 AI 调用,当前只审计、不扣点: + // 它们不在 UsageKindCredits 表里,ComputeCredits 查不到即返回 0。 + // 将来若要计费,只需把它们加进 UsageKindCredits,扣点逻辑自动生效。 + UsageKindBatchExtract = "batch_extract" + UsageKindContractReview = "contract_review" + UsageKindAudioTranscribe = "audio_transcribe" + UsageKindCopyProofread = "copy_proofread" + UsageKindDocumentTranslate = "document_translate" ) -// CapabilityCredits 各能力扣点成本 -var CapabilityCredits = map[string]int{ - CapabilityAIChat: 1, - CapabilityTextGen: 1, - CapabilityImageGen: 1, - CapabilityEmbed: 0, - CapabilityEssayGrade: 0, +// UsageKindCredits 各用量类型扣点成本 +var UsageKindCredits = map[string]int{ + UsageKindAIChat: 1, + UsageKindTextGen: 1, + UsageKindImageGen: 1, + UsageKindEmbed: 0, + UsageKindEssayGrade: 0, } // ComputeCredits 扣点决策:仅「成功」才扣点;失败不扣。 -func ComputeCredits(capability string, success bool) int { +func ComputeCredits(usageKind string, success bool) int { if !success { return 0 } - return CapabilityCredits[capability] + return UsageKindCredits[usageKind] } // LogEntry 一次 AI 调用的审计入参 type LogEntry struct { UserID uint - Capability string + UsageKind string // SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手 SpecialistKey string Provider string @@ -57,14 +66,14 @@ type LogEntry struct { // LogCall 写 ai_call_log;成功且需扣点时从用户余额扣点。审计写入失败不阻断主流程。 func LogCall(e LogEntry) { - credits := ComputeCredits(e.Capability, e.Success) + credits := ComputeCredits(e.UsageKind, e.Success) status := "success" if !e.Success { status = "failed" } rec := model.AiCallLog{ UserID: e.UserID, - Capability: e.Capability, + UsageKind: e.UsageKind, SpecialistKey: e.SpecialistKey, Provider: e.Provider, AIRouteID: e.AIRouteID, diff --git a/eai_agentplatform/backend-go/internal/ai/llm.go b/eai_agentplatform/backend-go/internal/ai/llm.go index 44a68f2..303f5aa 100644 --- a/eai_agentplatform/backend-go/internal/ai/llm.go +++ b/eai_agentplatform/backend-go/internal/ai/llm.go @@ -11,8 +11,6 @@ import ( "time" "eai_agentplatform/backend/internal/config" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" ) // ────────────────────────────────────────────── @@ -64,18 +62,6 @@ func NewClient(aiRoute *config.RouteConfig) *Client { } } -// NewClientLegacy 兼容旧接口(从 LLMConfig 创建) -func NewClientLegacy(cfg LLMConfig) *Client { - return &Client{ - baseURL: strings.TrimRight(cfg.BaseURL, "/"), - apiKey: cfg.APIKey, - model: cfg.Model, - maxTokens: cfg.MaxTokens, - temperature: cfg.Temperature, - hc: &http.Client{Timeout: 120 * time.Second}, - } -} - func (c *Client) url(path string) string { return c.baseURL + path } @@ -450,59 +436,6 @@ func requiresAPIKey(aiRoute *config.RouteConfig) bool { return provider == "openrouter" || provider == "openai" } -// ────────────────────────────────────────────── -// 配置解析(兼容旧接口) -// ────────────────────────────────────────────── - -// LLMConfig 简单的 LLM 连接配置(旧版,逐步淘汰) -type LLMConfig struct { - BaseURL string - APIKey string - Model string - EmbedModel string - MaxTokens int - Temperature float64 -} - -// ResolveLLM 旧版:从 DB/环境 解析 LLM 配置 -func ResolveLLM(cfg *config.Config) (LLMConfig, bool) { - get := func(key, def string) string { - var sc model.SystemConfig - if err := store.DB.Where("config_key = ?", key).First(&sc).Error; err == nil && strings.TrimSpace(sc.ConfigValue) != "" { - return sc.ConfigValue - } - return def - } - - baseURL := get("llm_base_url", cfg.LLMBaseURL) - apiKey := get("llm_api_key", cfg.LLMAPIKey) - modelName := get("llm_model", cfg.LLMModel) - embedModel := get("embed_model", cfg.EmbedModel) - - // DB 空时降级到 JSON secrets(flat 格式) - if baseURL == "" || apiKey == "" { - if baseURL == "" { - baseURL = config.GetProviderBaseURL("ollama") - } - if apiKey == "" { - apiKey = config.GetProviderAPIKey("ollama") - } - } - - c := LLMConfig{ - BaseURL: baseURL, - APIKey: apiKey, - Model: modelName, - EmbedModel: embedModel, - MaxTokens: 2048, - Temperature: 0.7, - } - if c.BaseURL == "" || c.Model == "" { - return c, false - } - return c, true -} - func truncate(s string, n int) string { r := []rune(s) if len(r) <= n { diff --git a/eai_agentplatform/backend-go/internal/api/action_definition.go b/eai_agentplatform/backend-go/internal/api/action_definition.go new file mode 100644 index 0000000..1f48c74 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/action_definition.go @@ -0,0 +1,198 @@ +package api + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "eai_agentplatform/backend/internal/jsonutil" + "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +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 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 !jsonutil.ValidateObjectJSON(req.InputSchemaJSON) { + return web.NewBadRequest("input_schema_json 必须是 JSON 对象") + } + if !jsonutil.ValidateObjectJSON(req.OutputSchemaJSON) { + return web.NewBadRequest("output_schema_json 必须是 JSON 对象") + } + if !jsonutil.ValidateObjectJSON(req.OntologyBindingJSON) { + return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象") + } + return nil +} + +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/ai_chat.go b/eai_agentplatform/backend-go/internal/api/ai_chat.go index 955701c..382843b 100644 --- a/eai_agentplatform/backend-go/internal/api/ai_chat.go +++ b/eai_agentplatform/backend-go/internal/api/ai_chat.go @@ -13,12 +13,13 @@ import ( "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" "eai_agentplatform/backend/internal/web" ) -// checkBalance 调用前校验用户算力点余额(不扣点的能力直接放行) -func checkBalance(c *gin.Context, user *model.User, capability string) bool { - if ai.ComputeCredits(capability, true) <= 0 { +// checkBalance 调用前校验用户算力点余额(不扣点的类型直接放行) +func checkBalance(c *gin.Context, user *model.User, usageKind string) bool { + if ai.ComputeCredits(usageKind, true) <= 0 { return true } if user.AiPoints <= 0 { @@ -81,7 +82,7 @@ func ChatMessage(c *gin.Context) { return } - if !checkBalance(c, user, ai.CapabilityAIChat) { + if !checkBalance(c, user, ai.UsageKindAIChat) { writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"}) return } @@ -107,7 +108,7 @@ func ChatMessage(c *gin.Context) { } writeEvent(gin.H{"type": "error", "message": message}) ai.LogCall(ai.LogEntry{ - UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: aiRoute.Provider, + UserID: user.ID, UsageKind: ai.UsageKindAIChat, Provider: aiRoute.Provider, AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false, ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()), }) @@ -119,34 +120,13 @@ func ChatMessage(c *gin.Context) { finalAiRoute = usedAiRoute } ai.LogCall(ai.LogEntry{ - UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalAiRoute.Provider, + UserID: user.ID, UsageKind: ai.UsageKindAIChat, Provider: finalAiRoute.Provider, AIRouteID: finalAiRoute.RouteID, Model: finalAiRoute.Model, Success: true, LatencyMs: int(time.Since(start).Milliseconds()), }) } -func buildSystemPrompt(ctx map[string]any, knowledge []string) string { - ctxJSON, _ := json.Marshal(ctx) - kc := strings.Join(knowledge, "\n\n") - return fmt.Sprintf(`你是 EAI 数字员工平台的 AI 助手。 - -职责: -1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题 -2. 严格依赖已审批知识库内容回答 -3. 知识库未找到相关资料时,明确回答「未找到相关资料」,不得臆测 - -禁止: -1. 禁止闲聊 -2. 禁止编造数据 -3. 禁止回答超出业务范围的问题 -4. 禁止泄露敏感信息 - -当前页面上下文: -%s - -知识库相关片段: -%s`, string(ctxJSON), kc) -} +// buildSystemPrompt 已迁移至 internal/specialists/runtime(specialistruntime.BuildSystemPrompt)。 // QuickActions GET /api/ai-chat/quick-actions —— 3 个快捷按钮 func QuickActions(c *gin.Context) { @@ -191,7 +171,7 @@ func QuickAction(c *gin.Context) { web.Fail(c, web.NewBadRequest("action_id 必填")) return } - if !checkBalance(c, user, ai.CapabilityTextGen) { + if !checkBalance(c, user, ai.UsageKindTextGen) { return } task, ok := quickActionTask[req.ActionID] @@ -227,7 +207,7 @@ func QuickAction(c *gin.Context) { }) return } - systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task + systemPrompt := specialistruntime.BuildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task aiMessages := []ai.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: task.query}, @@ -237,7 +217,7 @@ func QuickAction(c *gin.Context) { result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages) if err != nil { ai.LogCall(ai.LogEntry{ - UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: aiRoute.Provider, + UserID: user.ID, UsageKind: ai.UsageKindTextGen, Provider: aiRoute.Provider, AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, Success: false, ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()), }) @@ -251,7 +231,7 @@ func QuickAction(c *gin.Context) { return } ai.LogCall(ai.LogEntry{ - UserID: user.ID, Capability: ai.CapabilityTextGen, Provider: usedAiRoute.Provider, + UserID: user.ID, UsageKind: ai.UsageKindTextGen, 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/ai_usage.go b/eai_agentplatform/backend-go/internal/api/ai_usage.go index d534b43..0235241 100644 --- a/eai_agentplatform/backend-go/internal/api/ai_usage.go +++ b/eai_agentplatform/backend-go/internal/api/ai_usage.go @@ -1,22 +1,22 @@ package api import ( - "strconv" - "time" + "strconv" + "time" - "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin" - "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" - "eai_agentplatform/backend/internal/web" + "eai_agentplatform/backend/internal/middleware" + "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" ) // ────────────────────────────────────────────── // 算力点用量查询(管理员 = 全量;员工 = 本人) // ────────────────────────────────────────────── -// AIUsage GET /api/ai/usage?days=30&group_by=month|capability|provider +// AIUsage GET /api/ai/usage?days=30&group_by=month|usage_kind|provider func AIUsage(c *gin.Context) { user := middleware.CurrentUser(c) days := parseIntDefault(c.Query("days"), 30) @@ -25,7 +25,7 @@ func AIUsage(c *gin.Context) { } groupBy := c.DefaultQuery("group_by", "month") switch groupBy { - case "month", "day", "capability", "provider": + case "month", "day", "usage_kind", "provider": default: groupBy = "month" } @@ -55,8 +55,8 @@ func AIUsage(c *gin.Context) { // 分组字段(SQLite 下按月走 strftime,避免依赖 date_trunc) var groupExpr, orderExpr string switch groupBy { - case "capability": - groupExpr, orderExpr = "capability", "capability" + case "usage_kind": + groupExpr, orderExpr = "usage_kind", "usage_kind" case "provider": groupExpr, orderExpr = "COALESCE(provider,'')", "provider" case "day": @@ -129,29 +129,6 @@ func AIUsageUsers(c *gin.Context) { web.OK(c, gin.H{"users": out}) } -// AIUsageMe GET /api/ai/me —— 本人剩余点数 + 用量(PathCoach 面板数据源) -func AIUsageMe(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var totalUsed int64 - store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) FROM ai_call_log WHERE user_id = ?`, user.ID).Scan(&totalUsed) - var recent struct { - Used int64 - Calls int64 - } - store.DB.Raw(`SELECT COALESCE(SUM(credits_charged),0) AS used, COUNT(*) AS calls FROM ai_call_log WHERE user_id = ? AND created_at >= ?`, user.ID, daysAgo(30)).Scan(&recent) - - web.OK(c, gin.H{ - "ai_points": user.AiPoints, - "total_used": totalUsed, - "recent_used": recent.Used, - "recent_calls": recent.Calls, - }) -} - // parseIntDefault 解析 int 查询参数,非法时返回默认值 func parseIntDefault(s string, def int) int { if v, err := strconv.Atoi(s); err == nil { diff --git a/eai_agentplatform/backend-go/internal/api/app_definition.go b/eai_agentplatform/backend-go/internal/api/app_definition.go deleted file mode 100644 index 2cb6408..0000000 --- a/eai_agentplatform/backend-go/internal/api/app_definition.go +++ /dev/null @@ -1,233 +0,0 @@ -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 appDefinitionReq struct { - Key string `json:"key"` - Label string `json:"label"` - Badge string `json:"badge"` - Kind string `json:"kind"` - MarketTag string `json:"market_tag"` - WorkerType string `json:"worker_type"` - Tier string `json:"tier"` - Source string `json:"source"` - Color string `json:"color"` - IconText string `json:"icon_text"` - CoverTone string `json:"cover_tone"` - Summary string `json:"summary"` - Description string `json:"description"` - OpenRoute string `json:"open_route"` - SpecialistKey string `json:"specialist_key"` - SkillKey string `json:"skill_key"` - DefaultPrompt string `json:"default_prompt"` - PromptsJSON string `json:"prompts_json"` - TagsJSON string `json:"tags_json"` - InstallState string `json:"install_state"` - ExposedToUser bool `json:"exposed_to_user"` - State string `json:"state"` - SortOrder int `json:"sort_order"` -} - -func normalizeAppDefinitionReq(req *appDefinitionReq) { - req.Key = strings.TrimSpace(req.Key) - req.Label = strings.TrimSpace(req.Label) - req.Badge = strings.TrimSpace(req.Badge) - req.Kind = strings.TrimSpace(req.Kind) - req.MarketTag = strings.TrimSpace(req.MarketTag) - req.WorkerType = strings.TrimSpace(req.WorkerType) - req.Tier = strings.TrimSpace(req.Tier) - req.Source = strings.TrimSpace(req.Source) - req.Color = strings.TrimSpace(req.Color) - req.IconText = strings.TrimSpace(req.IconText) - req.CoverTone = strings.TrimSpace(req.CoverTone) - req.Summary = strings.TrimSpace(req.Summary) - req.Description = strings.TrimSpace(req.Description) - req.OpenRoute = strings.TrimSpace(req.OpenRoute) - req.SpecialistKey = strings.TrimSpace(req.SpecialistKey) - req.SkillKey = strings.TrimSpace(req.SkillKey) - req.DefaultPrompt = strings.TrimSpace(req.DefaultPrompt) - req.PromptsJSON = strings.TrimSpace(req.PromptsJSON) - req.TagsJSON = strings.TrimSpace(req.TagsJSON) - req.InstallState = strings.TrimSpace(req.InstallState) - req.State = strings.TrimSpace(req.State) -} - -func validateAppDefinitionReq(req *appDefinitionReq) *web.AppError { - normalizeAppDefinitionReq(req) - if req.Key == "" || req.Label == "" { - return web.NewBadRequest("key、label 为必填") - } - if req.WorkerType == "" { - req.WorkerType = "worker" - } - if req.Tier == "" { - req.Tier = "business" - } - if req.Source == "" { - req.Source = "eai" - } - if req.InstallState == "" { - req.InstallState = "installed" - } - if req.State == "" { - req.State = "active" - } - if req.State != "active" && req.State != "inactive" { - return web.NewBadRequest("state 只能是 active 或 inactive") - } - if !store.ValidateJSONStringArray(req.PromptsJSON) { - return web.NewBadRequest("prompts_json 必须是字符串数组") - } - if !store.ValidateJSONStringArray(req.TagsJSON) { - return web.NewBadRequest("tags_json 必须是字符串数组") - } - return nil -} - -func ListAppDefinitions(c *gin.Context) { - q := store.DB.Model(&model.AppDefinition{}) - 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.AppDefinition - 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 GetAppDefinitionByKey(c *gin.Context) { - key := strings.TrimSpace(c.Param("key")) - if key == "" { - web.Fail(c, web.NewBadRequest("应用 key 不能为空")) - return - } - var item model.AppDefinition - if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil { - web.Fail(c, web.NewNotFoundError("应用定义不存在")) - return - } - web.OK(c, item) -} - -func CreateAppDefinition(c *gin.Context) { - var req appDefinitionReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - if appErr := validateAppDefinitionReq(&req); appErr != nil { - web.Fail(c, appErr) - return - } - item := model.AppDefinition{ - Key: req.Key, - Label: req.Label, - Badge: req.Badge, - Kind: req.Kind, - MarketTag: req.MarketTag, - WorkerType: req.WorkerType, - Tier: req.Tier, - Source: req.Source, - Color: req.Color, - IconText: req.IconText, - CoverTone: req.CoverTone, - Summary: req.Summary, - Description: req.Description, - OpenRoute: req.OpenRoute, - SpecialistKey: req.SpecialistKey, - SkillKey: req.SkillKey, - DefaultPrompt: req.DefaultPrompt, - PromptsJSON: req.PromptsJSON, - TagsJSON: req.TagsJSON, - InstallState: req.InstallState, - ExposedToUser: req.ExposedToUser, - 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 UpdateAppDefinition(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - var item model.AppDefinition - if err := store.DB.First(&item, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("应用定义不存在")) - return - } - var req appDefinitionReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - if appErr := validateAppDefinitionReq(&req); appErr != nil { - web.Fail(c, appErr) - return - } - item.Key = req.Key - item.Label = req.Label - item.Badge = req.Badge - item.Kind = req.Kind - item.MarketTag = req.MarketTag - item.WorkerType = req.WorkerType - item.Tier = req.Tier - item.Source = req.Source - item.Color = req.Color - item.IconText = req.IconText - item.CoverTone = req.CoverTone - item.Summary = req.Summary - item.Description = req.Description - item.OpenRoute = req.OpenRoute - item.SpecialistKey = req.SpecialistKey - item.SkillKey = req.SkillKey - item.DefaultPrompt = req.DefaultPrompt - item.PromptsJSON = req.PromptsJSON - item.TagsJSON = req.TagsJSON - item.InstallState = req.InstallState - item.ExposedToUser = req.ExposedToUser - 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 DeleteAppDefinition(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - var item model.AppDefinition - 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}) -} diff --git a/eai_agentplatform/backend-go/internal/api/audio_transcribe.go b/eai_agentplatform/backend-go/internal/api/audio_transcribe.go index 9566a69..f1b7758 100644 --- a/eai_agentplatform/backend-go/internal/api/audio_transcribe.go +++ b/eai_agentplatform/backend-go/internal/api/audio_transcribe.go @@ -15,8 +15,6 @@ import ( "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -71,7 +69,7 @@ func TranscribeAudio(c *gin.Context) { file.Close() result := transcribeAudio(fileData, ext, language) - logTranscription(user.ID, fileHeader.Filename, result) + logTranscription(user.ID) web.OK(c, result) } @@ -171,13 +169,6 @@ func transcribeAudio(fileData []byte, ext, language string) AudioTranscribeResul } // logTranscription 记录语音转录日志 -func logTranscription(userID uint, filename string, result AudioTranscribeResult) { - store.DB.Create(&model.AiCallLog{ - UserID: userID, - Capability: "audio_transcribe", - Status: "success", - }) - _ = filename - _ = ai.GenerateWithFallback - _ = result +func logTranscription(userID uint) { + ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindAudioTranscribe, Success: true}) } diff --git a/eai_agentplatform/backend-go/internal/api/auth.go b/eai_agentplatform/backend-go/internal/api/auth.go index 7f19ea8..992ef68 100644 --- a/eai_agentplatform/backend-go/internal/api/auth.go +++ b/eai_agentplatform/backend-go/internal/api/auth.go @@ -5,8 +5,6 @@ import ( "eai_agentplatform/backend/internal/auth" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -23,8 +21,8 @@ func Login(c *gin.Context) { return } - var user model.User - if err := store.DB.Where("username = ?", req.Username).First(&user).Error; err != nil { + user, found := userRepo.GetByUsername(req.Username) + if !found { web.Fail(c, web.NewAuthError("用户名或密码错误")) return } diff --git a/eai_agentplatform/backend-go/internal/api/batch_extract.go b/eai_agentplatform/backend-go/internal/api/batch_extract.go index 58e1390..78fbf00 100644 --- a/eai_agentplatform/backend-go/internal/api/batch_extract.go +++ b/eai_agentplatform/backend-go/internal/api/batch_extract.go @@ -9,16 +9,14 @@ import ( "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) // BatchExtractRequest POST /api/batch/extract —— 批量字段提取请求 type BatchExtractRequest struct { - Content string `json:"content"` // 文档内容 - Fields []FieldSchema `json:"fields"` // 要提取的字段 - FileCount int `json:"file_count"` // 文件数量(用于前端提示) + Content string `json:"content"` // 文档内容 + Fields []FieldSchema `json:"fields"` // 要提取的字段 + FileCount int `json:"file_count"` // 文件数量(用于前端提示) } // FieldSchema 字段定义 @@ -30,9 +28,9 @@ type FieldSchema struct { // BatchExtractResult 批量提取结果 type BatchExtractResult struct { - Rows []map[string]string `json:"rows"` - Total int `json:"total"` - CreatedAt string `json:"created_at"` + Rows []map[string]string `json:"rows"` + Total int `json:"total"` + CreatedAt string `json:"created_at"` } // ExtractBatch POST /api/batch/extract —— 批量字段提取 @@ -49,7 +47,7 @@ func ExtractBatch(c *gin.Context) { } result := runBatchExtract(req) - logBatchExtract(user.ID, req.Content, result) + logBatchExtract(user.ID) web.OK(c, result) } @@ -89,7 +87,7 @@ func buildFieldDescription(fields []FieldSchema) string { var sb strings.Builder for i, f := range fields { sb.WriteString("- 字段") - sb.WriteString(string(rune('0'+i))) + sb.WriteString(string(rune('0' + i))) sb.WriteString(": ") sb.WriteString(f.Name) sb.WriteString(" (类型: ") @@ -145,9 +143,8 @@ func fixJSON(s string) string { } // logBatchExtract 记录批量提取日志 -func logBatchExtract(userID uint, content string, result BatchExtractResult) { - (json.Marshal(result)) - store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "batch_extract", Status: "success"}) +func logBatchExtract(userID uint) { + ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindBatchExtract, Success: true}) } // ExtractFromFiles 从文件中提取字段 @@ -164,6 +161,6 @@ func ExtractFromFiles(c *gin.Context) { } result := runBatchExtract(req) - logBatchExtract(user.ID, req.Content, result) + logBatchExtract(user.ID) web.OK(c, result) } diff --git a/eai_agentplatform/backend-go/internal/api/smart_assistant.go b/eai_agentplatform/backend-go/internal/api/chat_message.go similarity index 55% rename from eai_agentplatform/backend-go/internal/api/smart_assistant.go rename to eai_agentplatform/backend-go/internal/api/chat_message.go index 7fe86f4..1262274 100644 --- a/eai_agentplatform/backend-go/internal/api/smart_assistant.go +++ b/eai_agentplatform/backend-go/internal/api/chat_message.go @@ -8,13 +8,12 @@ import ( "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" "eai_agentplatform/backend/internal/web" ) -// SmartAssistantRequest POST /api/assistant/chat —— 通用助手请求 -type SmartAssistantRequest struct { +// ChatMessageRequest POST /api/chat/message —— 工作台对话请求 +type ChatMessageRequest struct { Message string `json:"message"` Context string `json:"context"` Mode string `json:"mode"` @@ -29,8 +28,8 @@ type SmartAssistantRequest struct { Stream bool `json:"stream"` } -// AssistantMessage 助手消息 -type AssistantMessage struct { +// ChatReplyMessage 工作台对话返回消息 +type ChatReplyMessage struct { Role string `json:"role"` Content string `json:"content"` Timestamp string `json:"timestamp"` @@ -55,23 +54,28 @@ type Step struct { Order int `json:"order"` } -// SmartAssistantResult 通用助手响应 -type SmartAssistantResult struct { - Message AssistantMessage `json:"message"` +// ChatMessageResult 工作台对话响应 +type ChatMessageResult struct { + Message ChatReplyMessage `json:"message"` TaskPlan *TaskPlan `json:"task_plan,omitempty"` IsPlan bool `json:"is_plan"` IsExpert bool `json:"is_expert"` Stream bool `json:"stream"` } -// Chat POST /api/assistant/chat —— 通用助手对话入口 -func Chat(c *gin.Context) { +type chatExecution struct { + result ChatMessageResult + logEntry ai.LogEntry +} + +// HandleChatMessage POST /api/chat/message —— 工作台对话入口 +func HandleChatMessage(c *gin.Context) { user := middleware.CurrentUser(c) if user == nil { web.Fail(c, web.NewAuthError("未登录")) return } - var req SmartAssistantRequest + var req ChatMessageRequest if err := c.ShouldBindJSON(&req); err != nil || req.Message == "" { web.Fail(c, web.NewBadRequest("message 必填")) return @@ -86,13 +90,13 @@ func Chat(c *gin.Context) { // 专员只解析一次,供 prompt 组装与审计日志共用 specialist := resolveSpecialist(req) - result := processAssistantMessage(user.ID, req, specialist) - logAssistantMessage(user.ID, req.Message, result, specialist) - web.OK(c, result) + execution := processChatMessage(user.ID, req, specialist) + ai.LogCall(execution.logEntry) + web.OK(c, execution.result) } -// processAssistantMessage 处理通用助手消息 -func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult { +// processChatMessage 处理工作台对话消息 +func processChatMessage(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist) chatExecution { switch req.Mode { case "quick": return handleQuick(userID, req, specialist) @@ -104,64 +108,91 @@ func processAssistantMessage(userID uint, req SmartAssistantRequest, specialist } // handleQuick 快速模式:直出回答,不开启 thinking 和多步规划 -func handleQuick(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult { - aiResponse := callAssistantAI(userID, req, specialist, false) - return SmartAssistantResult{ - Message: AssistantMessage{ - Role: "assistant", - Content: aiResponse, - Timestamp: time.Now().Format("2006-01-02 15:04:05"), - Type: "text", +func handleQuick(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist) chatExecution { + aiResponse, logEntry := callChatModel(userID, req, specialist, false) + return chatExecution{ + result: ChatMessageResult{ + Message: ChatReplyMessage{ + Role: "assistant", + Content: aiResponse, + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Type: "text", + }, + IsPlan: false, + IsExpert: false, + Stream: req.Stream, }, - IsPlan: false, - IsExpert: false, - Stream: req.Stream, + logEntry: logEntry, } } // handleExpert 专家模式:开启 thinking,进行多步智能体规划 -func handleExpert(userID uint, req SmartAssistantRequest, specialist *model.Specialist) SmartAssistantResult { +func handleExpert(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist) chatExecution { // 先用 expert system prompt 调用 LLM 生成多步规划和回复 - aiResponse := callAssistantAI(userID, req, specialist, true) + aiResponse, logEntry := callChatModel(userID, req, specialist, true) plan := generateTaskPlan(req.Message) - return SmartAssistantResult{ - Message: AssistantMessage{ - Role: "assistant", - Content: aiResponse, - Timestamp: time.Now().Format("2006-01-02 15:04:05"), - Type: "text", + return chatExecution{ + result: ChatMessageResult{ + Message: ChatReplyMessage{ + Role: "assistant", + Content: aiResponse, + Timestamp: time.Now().Format("2006-01-02 15:04:05"), + Type: "text", + }, + TaskPlan: plan, + IsPlan: true, + IsExpert: true, + Stream: req.Stream, }, - TaskPlan: plan, - IsPlan: true, - IsExpert: true, - Stream: req.Stream, + logEntry: logEntry, } } -// callAssistantAI 调用AI生成对话回复,enableThinking 控制是否使用 expert prompt +// callChatModel 调用 AI 生成对话回复,enableThinking 控制是否使用 expert prompt // // system prompt 由 buildAssistantSystemPrompt 组装:基础角色 + 专员岗位说明书。 // 没解析到专员时(未指定 / key 失效)与改动前逐字一致。 -func callAssistantAI(userID uint, req SmartAssistantRequest, specialist *model.Specialist, enableThinking bool) string { +func callChatModel(userID uint, req ChatMessageRequest, specialist *specialistmodel.Specialist, enableThinking bool) (string, ai.LogEntry) { systemPrompt := buildAssistantSystemPrompt(specialist, enableThinking) + logEntry := ai.LogEntry{ + UserID: userID, + UsageKind: ai.UsageKindAIChat, + SpecialistKey: specialistKey(specialist), + Success: false, + } aiRouteID := req.AIRouteID if aiRouteID == "" { aiRouteID = "path_coach" } + logEntry.AIRouteID = aiRouteID aiRoute, err := config.GetRoute(aiRouteID) if err != nil { - return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。" + logEntry.ErrorMessage = err.Error() + return "抱歉,当前后台AI模型不可用,请检查 AI 路由配置。", logEntry } + logEntry.Provider = aiRoute.Provider + logEntry.Model = aiRoute.Model - content, err := ai.GenerateWithFallback(aiRoute, []ai.Message{ + start := time.Now() + result, usedRoute, err := ai.GenerateFullWithFallback(aiRoute, []ai.Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: req.Message}, }) + logEntry.LatencyMs = int(time.Since(start).Milliseconds()) if err != nil { - return "抱歉,AI 服务暂时不可用,请稍后重试。" + logEntry.ErrorMessage = err.Error() + return "抱歉,AI 服务暂时不可用,请稍后重试。", logEntry } - return content + if usedRoute != nil { + logEntry.Provider = usedRoute.Provider + logEntry.AIRouteID = usedRoute.RouteID + logEntry.Model = usedRoute.Model + } + logEntry.TokensInput = result.Usage.PromptTokens + logEntry.TokensOutput = result.Usage.CompletionTokens + logEntry.Success = true + return result.Content, logEntry } // generateTaskPlan 生成任务拆解 @@ -179,16 +210,9 @@ func generateTaskPlan(prompt string) *TaskPlan { return plan } -// logAssistantMessage 记录助手消息 -func logAssistantMessage(userID uint, message string, result SmartAssistantResult, specialist *model.Specialist) { - specialistKey := "" - if specialist != nil { - specialistKey = specialist.Key +func specialistKey(specialist *specialistmodel.Specialist) string { + if specialist == nil { + return "" } - store.DB.Create(&model.AiCallLog{ - UserID: userID, - Capability: "assistant_chat", - SpecialistKey: specialistKey, - Status: "success", - }) + return specialist.Key } diff --git a/eai_agentplatform/backend-go/internal/api/company_train.go b/eai_agentplatform/backend-go/internal/api/company_train.go index 92f5ca3..1112ad4 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 !isUploadableExt(ext) { + if !isUploadableExt(ext) { web.Fail(c, web.NewBadRequest("不支持的文件类型")) return } - if isVideoExt(ext) && 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/contract_review.go b/eai_agentplatform/backend-go/internal/api/contract_review.go index ba2ab99..b55b492 100644 --- a/eai_agentplatform/backend-go/internal/api/contract_review.go +++ b/eai_agentplatform/backend-go/internal/api/contract_review.go @@ -8,15 +8,13 @@ import ( "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) // ContractReviewRequest POST /api/contract/review type ContractReviewRequest struct { - Content string `json:"content"` // 合同正文 - TemplateKey string `json:"template_key"` // 参考模板key + Content string `json:"content"` // 合同正文 + TemplateKey string `json:"template_key"` // 参考模板key RiskStandard string `json:"risk_standard"` // standard/strict } @@ -39,19 +37,19 @@ type ContractSection struct { // ReviewResult 审查结果 type ReviewResult struct { - ContractTitle string `json:"contract_title"` - TotalSections int `json:"total_sections"` - TotalRisks int `json:"total_risks"` - CriticalRisks int `json:"critical_risks"` - HighRisks int `json:"high_risks"` - MediumRisks int `json:"medium_risks"` - LowRisks int `json:"low_risks"` - Score int `json:"score"` - RiskSummary string `json:"risk_summary"` - Suggestions string `json:"suggestions"` - Risks []RiskItem `json:"risks"` + ContractTitle string `json:"contract_title"` + TotalSections int `json:"total_sections"` + TotalRisks int `json:"total_risks"` + CriticalRisks int `json:"critical_risks"` + HighRisks int `json:"high_risks"` + MediumRisks int `json:"medium_risks"` + LowRisks int `json:"low_risks"` + Score int `json:"score"` + RiskSummary string `json:"risk_summary"` + Suggestions string `json:"suggestions"` + Risks []RiskItem `json:"risks"` Sections []ContractSection `json:"sections"` - ReviewedAt string `json:"reviewed_at"` + ReviewedAt string `json:"reviewed_at"` } // ReviewContract POST /api/contract/review —— 合同审查 @@ -71,7 +69,7 @@ func ReviewContract(c *gin.Context) { } result := runContractReview(req) - logContractReview(user.ID, req.Content, result) + logContractReview(user.ID) web.OK(c, result) } @@ -181,9 +179,9 @@ func checkClausesCompleteness(sections []ContractSection) []RiskItem { pattern string level string }{ - "合同主体": {"甲方", "critical"}, - "合同标的": {"标的", "critical"}, - "价款": {"价款", "critical"}, + "合同主体": {"甲方", "critical"}, + "合同标的": {"标的", "critical"}, + "价款": {"价款", "critical"}, "履行期限": {"期限", "high"}, "支付方式": {"支付", "high"}, "违约责任": {"违约", "high"}, @@ -458,10 +456,6 @@ func parseAIRisks(content string) []RiskItem { } // logContractReview 记录审查日志 -func logContractReview(userID uint, content string, result ReviewResult) { - store.DB.Create(&model.AiCallLog{ - UserID: userID, - Capability: "contract_review", - Status: "success", - }) +func logContractReview(userID uint) { + ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindContractReview, Success: true}) } diff --git a/eai_agentplatform/backend-go/internal/api/copy_proofreading.go b/eai_agentplatform/backend-go/internal/api/copy_proofreading.go index f0ff0f8..882ba45 100644 --- a/eai_agentplatform/backend-go/internal/api/copy_proofreading.go +++ b/eai_agentplatform/backend-go/internal/api/copy_proofreading.go @@ -1,46 +1,44 @@ package api import ( - "encoding/json" "strings" "time" "github.com/gin-gonic/gin" + "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) // CopyProofreadingRequest POST /api/copy/proofread 文案校对请求 type CopyProofreadingRequest struct { - Text string `json:"text"` // 待校对的文案 - Mode string `json:"mode"` // 模式:basic(基础)/advanced(高级)/strict(严格) - CheckTypos bool `json:"check_typos"` // 检查错别字 - CheckGrammar bool `json:"check_grammar"` // 检查语病 - CheckStyle bool `json:"check_style"` // 检查风格 - CheckNumbers bool `json:"check_numbers"` // 检查数据 + Text string `json:"text"` // 待校对的文案 + Mode string `json:"mode"` // 模式:basic(基础)/advanced(高级)/strict(严格) + CheckTypos bool `json:"check_typos"` // 检查错别字 + CheckGrammar bool `json:"check_grammar"` // 检查语病 + CheckStyle bool `json:"check_style"` // 检查风格 + CheckNumbers bool `json:"check_numbers"` // 检查数据 } // ProofreadingItem 校对结果项 type ProofreadingItem struct { - Type string `json:"type"` // typo/grammar/style/number - Level string `json:"level"` // error/warning/info - Position string `json:"position"` // 位置 - BadText string `json:"bad_text"` // 错误内容 - GoodText string `json:"good_text"` // 建议内容 + Type string `json:"type"` // typo/grammar/style/number + Level string `json:"level"` // error/warning/info + Position string `json:"position"` // 位置 + BadText string `json:"bad_text"` // 错误内容 + GoodText string `json:"good_text"` // 建议内容 Explanation string `json:"explanation"` // 说明 } // CopyProofreadingResult 校对结果 type CopyProofreadingResult struct { Items []ProofreadingItem `json:"items"` - TotalIssues int `json:"total_issues"` - ErrorCount int `json:"error_count"` - WarningCount int `json:"warning_count"` - Score int `json:"score"` // 0-100 质量评分 - CreatedAt string `json:"created_at"` + TotalIssues int `json:"total_issues"` + ErrorCount int `json:"error_count"` + WarningCount int `json:"warning_count"` + Score int `json:"score"` // 0-100 质量评分 + CreatedAt string `json:"created_at"` } // ProofreadCopy POST /api/copy/proofread —— 文案校对 @@ -60,7 +58,7 @@ func ProofreadCopy(c *gin.Context) { } result := runProofreading(req) - logProofreading(user.ID, req.Text, result) + logProofreading(user.ID) web.OK(c, result) } @@ -122,28 +120,28 @@ func runProofreading(req CopyProofreadingRequest) CopyProofreadingResult { func checkTypos(text string) []ProofreadingItem { // 常见错别字对 typoPairs := map[string]string{ - "做用": "作用", - "做为": "作为", - "需改": "修改", - "按排": "安排", - "份内": "份内", - "其它": "其他", - "其它们": "其他", - "帐号": "账号", - "象限": "向限", - "截止": "截至", - "以致": "以至", - "截至": "截止", - "权利": "权力", - "权力": "权利", - "必须": "必需", - "必需": "必须", - "报筹": "报复", - "报效": "报效", - "布暑": "布暑", - "不管": "不管", - "不常": "不常", - "不订": "不定", + "做用": "作用", + "做为": "作为", + "需改": "修改", + "按排": "安排", + "份内": "份内", + "其它": "其他", + "其它们": "其他", + "帐号": "账号", + "象限": "向限", + "截止": "截至", + "以致": "以至", + "截至": "截止", + "权利": "权力", + "权力": "权利", + "必须": "必需", + "必需": "必须", + "报筹": "报复", + "报效": "报效", + "布暑": "布暑", + "不管": "不管", + "不常": "不常", + "不订": "不定", } items := make([]ProofreadingItem, 0) @@ -184,11 +182,11 @@ func checkGrammar(text string) []ProofreadingItem { if strings.Contains(text, rule.pattern) { idx := strings.Index(text, rule.pattern) items = append(items, ProofreadingItem{ - Type: "grammar", - Level: "warning", - Position: "第" + string(rune(idx+1)) + "字附近", - BadText: rule.pattern, - GoodText: rule.solution, + Type: "grammar", + Level: "warning", + Position: "第" + string(rune(idx+1)) + "字附近", + BadText: rule.pattern, + GoodText: rule.solution, Explanation: rule.solution, }) } @@ -203,19 +201,19 @@ func checkStyle(text string) []ProofreadingItem { // 重复词汇检查 repeatWords := map[string]string{ - "非常非常": "非常", - "特别特别": "特别", - "十分十分": "十分", + "非常非常": "非常", + "特别特别": "特别", + "十分十分": "十分", "大大地提高": "大大提高", "快速地发展": "快速发展", } for bad, good := range repeatWords { if strings.Contains(text, bad) { items = append(items, ProofreadingItem{ - Type: "style", - Level: "warning", - BadText: bad, - GoodText: good, + Type: "style", + Level: "warning", + BadText: bad, + GoodText: good, Explanation: "用词重复,建议精简", }) } @@ -226,9 +224,9 @@ func checkStyle(text string) []ProofreadingItem { for _, word := range colloquial { if strings.Contains(text, word) { items = append(items, ProofreadingItem{ - Type: "style", - Level: "info", - BadText: word, + Type: "style", + Level: "info", + BadText: word, Explanation: "口语化表达,正式文档可优化", }) } @@ -244,10 +242,10 @@ func checkNumbers(text string) []ProofreadingItem { // 例如:100% 和 100% 混用(全角/半角) if strings.Contains(text, "%") { items = append(items, ProofreadingItem{ - Type: "number", - Level: "warning", - BadText: "%", - GoodText: "%", + Type: "number", + Level: "warning", + BadText: "%", + GoodText: "%", Explanation: "检测到全角百分号,建议统一为半角%", }) } @@ -255,9 +253,8 @@ func checkNumbers(text string) []ProofreadingItem { } // logProofreading 记录校对日志 -func logProofreading(userID uint, content string, result CopyProofreadingResult) { - (json.Marshal(result)) - store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "copy_proofread", Status: "success"}) +func logProofreading(userID uint) { + ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindCopyProofread, Success: true}) } // QuickProofread GET /api/copy/quick —— 快速校对(简单输入) @@ -275,11 +272,11 @@ func QuickProofread(c *gin.Context) { } result := runProofreading(CopyProofreadingRequest{ - Text: text, - Mode: "basic", + Text: text, + Mode: "basic", CheckTypos: true, }) - logProofreading(user.ID, text, result) + logProofreading(user.ID) web.OK(c, result) } diff --git a/eai_agentplatform/backend-go/internal/api/courses.go b/eai_agentplatform/backend-go/internal/api/courses.go index b74f237..129dfd2 100644 --- a/eai_agentplatform/backend-go/internal/api/courses.go +++ b/eai_agentplatform/backend-go/internal/api/courses.go @@ -6,10 +6,12 @@ import ( "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" ) +// 本文件的仓库实例:courseRepo 声明在 position.go,mediaRepo 声明在 media.go。 + // courseView 课程详情视图(含绑定产品) func courseView(c *gin.Context, co model.Course) { out := gin.H{ @@ -30,15 +32,12 @@ func courseView(c *gin.Context, co model.Course) { "updated_at": co.UpdatedAt, } if co.RelatedProductID != nil { - var p model.Product - if store.DB.Where("id = ? AND status != ?", *co.RelatedProductID, "inactive").First(&p).Error == nil { + if p, ok := productRepo.GetVisibleByID(*co.RelatedProductID); ok { out["product"] = gin.H{"id": p.ID, "code": p.Code, "name": p.Name, "category": p.Category} } } - var medias []model.MediaFile - store.DB.Where("bind_type = ? AND bind_id = ? AND status = ?", "course", co.ID, "approved"). - Order("id ASC").Find(&medias) + medias := mediaRepo.ListByBind("course", co.ID) if len(medias) > 0 { items := make([]gin.H, 0, len(medias)) for _, m := range medias { @@ -56,23 +55,7 @@ func courseView(c *gin.Context, co model.Course) { // ListCourses GET /api/courses?category=&status= func ListCourses(c *gin.Context) { - q := store.DB.Model(&model.Course{}) - if cat := c.Query("category"); cat != "" { - q = q.Where("category = ?", cat) - } - switch st := c.Query("status"); st { - case "": // 默认仅 active(员工浏览) - q = q.Where("status = ?", "active") - case "all": // 管理员维护全量 - default: - q = q.Where("status = ?", st) - } - var items []model.Course - if err := q.Order("id ASC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询课程失败")) - return - } - web.OK(c, items) + web.OK(c, courseRepo.List(c.Query("category"), c.Query("status"))) } // GetCourse GET /api/courses/{id} @@ -81,8 +64,8 @@ func GetCourse(c *gin.Context) { if !ok { return } - var co model.Course - if err := store.DB.First(&co, id).Error; err != nil { + co, found := courseRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("课程不存在")) return } @@ -100,16 +83,14 @@ func CreateCourse(c *gin.Context) { web.Fail(c, web.NewBadRequest("编号、名称、分类为必填")) return } - var count int64 - store.DB.Model(&model.Course{}).Where("code = ?", co.Code).Count(&count) - if count > 0 { + if courseRepo.CountByCode(co.Code, nil) > 0 { web.Fail(c, web.NewConflictError("课程编号已存在")) return } if co.Status == "" { co.Status = "active" } - if err := store.DB.Create(&co).Error; err != nil { + if !courseRepo.Insert(&co) { web.Fail(c, web.NewBadRequest("创建课程失败")) return } @@ -122,8 +103,8 @@ func UpdateCourse(c *gin.Context) { if !ok { return } - var co model.Course - if err := store.DB.First(&co, id).Error; err != nil { + co, found := courseRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("课程不存在")) return } @@ -141,9 +122,7 @@ func UpdateCourse(c *gin.Context) { } if req.Code != "" && req.Code != co.Code { - var count int64 - store.DB.Model(&model.Course{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count) - if count > 0 { + if courseRepo.CountByCode(req.Code, &id) > 0 { web.Fail(c, web.NewConflictError("课程编号已存在")) return } @@ -161,7 +140,7 @@ func UpdateCourse(c *gin.Context) { co.RelatedProductID = req.RelatedProductID co.Status = req.Status - if err := store.DB.Save(&co).Error; err != nil { + if !courseRepo.Update(&co) { web.Fail(c, web.NewBadRequest("更新课程失败")) return } diff --git a/eai_agentplatform/backend-go/internal/api/document_translate.go b/eai_agentplatform/backend-go/internal/api/document_translate.go index 6d40668..5c81661 100644 --- a/eai_agentplatform/backend-go/internal/api/document_translate.go +++ b/eai_agentplatform/backend-go/internal/api/document_translate.go @@ -3,7 +3,6 @@ package api import ( "archive/zip" "bytes" - "encoding/json" "fmt" "strings" "time" @@ -13,8 +12,6 @@ import ( "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -88,7 +85,7 @@ func TranslateDocument(c *gin.Context) { } // 保存日志 - logTranslation(user.ID, req.Content, result) + logTranslation(user.ID) web.OK(c, gin.H{ "result": result, @@ -258,7 +255,6 @@ func translateToPDF(_ string, result DocumentTranslateResult) ([]byte, error) { } // logTranslation 记录翻译日志 -func logTranslation(userID uint, content string, result DocumentTranslateResult) { - (json.Marshal(result)) - store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "document_translate", Status: "success"}) +func logTranslation(userID uint) { + ai.LogCall(ai.LogEntry{UserID: userID, UsageKind: ai.UsageKindDocumentTranslate, Success: true}) } diff --git a/eai_agentplatform/backend-go/internal/api/essay_grade.go b/eai_agentplatform/backend-go/internal/api/essay_grade.go index a36018a..fc04b4d 100644 --- a/eai_agentplatform/backend-go/internal/api/essay_grade.go +++ b/eai_agentplatform/backend-go/internal/api/essay_grade.go @@ -30,14 +30,14 @@ func gradeEssay(userID uint, stem, rubric, userAnswer string) (float64, string, result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages) if err != nil { ai.LogCall(ai.LogEntry{ - UserID: userID, Capability: ai.CapabilityEssayGrade, Provider: aiRoute.Provider, + UserID: userID, UsageKind: ai.UsageKindEssayGrade, 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: usedAiRoute.Provider, + UserID: userID, UsageKind: ai.UsageKindEssayGrade, 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/exam.go b/eai_agentplatform/backend-go/internal/api/exam.go index fd74cbf..bf51e00 100644 --- a/eai_agentplatform/backend-go/internal/api/exam.go +++ b/eai_agentplatform/backend-go/internal/api/exam.go @@ -16,10 +16,13 @@ import ( "eai_agentplatform/backend/internal/auth" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) +var mistakeRepo repository.MistakeRecordRepo + // Option 题目选项 type Option struct { Key string `json:"key"` @@ -213,11 +216,6 @@ func CreatePaper(c *gin.Context) { web.Fail(c, web.NewBadRequest("创建考试失败")) return } - // 发布正式考试:通知全员(提醒及时参加) - if p.Type == "formal" { - notifyAllEmployees("exam_publish", "新考试发布", - fmt.Sprintf("管理员发布了正式考试「%s」,请及时参加", p.Name), "/exam/formal") - } web.OK(c, p) } @@ -306,12 +304,8 @@ func ExamList(c *gin.Context) { out := make([]row, 0, len(items)) for _, p := range items { st := "available" - if p.Type == "formal" && u != nil { - var n int64 - store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n) - if n > 0 { - st = "completed" - } + if p.Type == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) { + st = "completed" } out = append(out, row{ ID: p.ID, Name: p.Name, Type: p.Type, Domain: p.Domain, @@ -322,9 +316,9 @@ func ExamList(c *gin.Context) { web.OK(c, out) } -// ExamCover GET /api/exam/cover?id={paperId} +// ExamCover GET /api/exam/cover?paper_id={paper_id} func ExamCover(c *gin.Context) { - id64, err := strconv.ParseUint(c.Query("id"), 10, 64) + id64, err := strconv.ParseUint(c.Query("paper_id"), 10, 64) if err != nil || id64 == 0 { web.Fail(c, web.NewBadRequest("无效的 id")) return @@ -564,13 +558,9 @@ func ExamStart(c *gin.Context) { web.Fail(c, web.NewNotFoundError("考试不存在或已停用")) return } - if p.Type == "formal" && u != nil { - var n int64 - store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n) - if n > 0 { - web.Fail(c, web.NewConflictError("已参加过该正式考试")) - return - } + if p.Type == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) { + web.Fail(c, web.NewConflictError("已参加过该正式考试")) + return } questions, err := pickQuestions(p) if err != nil { @@ -817,13 +807,9 @@ func ExamSubmit(c *gin.Context) { } // 正式考不可重复交卷 - if stype == "formal" && u != nil { - var n int64 - store.DB.Model(&model.ExamRecord{}).Where("user_id = ? AND paper_id = ?", u.ID, p.ID).Count(&n) - if n > 0 { - web.Fail(c, web.NewConflictError("已参加过该正式考试")) - return - } + if stype == "formal" && u != nil && examRecordRepo.HasTaken(u.ID, p.ID) { + web.Fail(c, web.NewConflictError("已参加过该正式考试")) + return } ids := splitIDs(qidsStr) @@ -931,9 +917,6 @@ func ExamSubmit(c *gin.Context) { if passed { awardPoints(u.ID, "formal_pass", ptFormalPass, "paper", p.ID) issueCertificate(u, rec) - notifyUser(u.ID, "exam_pass", "考试通过", - fmt.Sprintf("恭喜通过「%s」考试,成绩 %d 分,已颁发合格证书", p.Name, score), - "/exam/my-certificates") } } else if stype == "self_test" && u != nil { // 自测不落分、不存档(P03),仅计一次练习积分 @@ -960,19 +943,17 @@ func splitIDs(s string) []uint { // ExamRecordList GET /api/exam/record?page=&size= —— 我的考试记录 func ExamRecordList(c *gin.Context) { u := middleware.CurrentUser(c) - q := store.DB.Model(&model.ExamRecord{}).Where("user_id = ?", u.ID) - var items []model.ExamRecord - if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询考试记录失败")) - return + items := examRecordRepo.ListByUser(u.ID) + if items == nil { + items = []model.ExamRecord{} } web.OK(c, items) } -// ExamRecordDetail GET /api/exam/record/{recordId} —— 记录详情回溯 +// ExamRecordDetail GET /api/exam/record/{record_id} —— 记录详情回溯 func ExamRecordDetail(c *gin.Context) { u := middleware.CurrentUser(c) - id, ok := parseID(c, "recordId") + id, ok := parseID(c, "record_id") if !ok { return } @@ -997,28 +978,22 @@ func ExamRecordDetail(c *gin.Context) { // ============ 错题本(学员自助) ============ -// recordMistake 答错入本:按 (user_id, question_id, source) 去重,再次答错则更新并重置为未掌握。 -func recordMistake(userID, questionID uint, q model.Question, userAns any, correct []string, source string) { +// mistakePayload 组装错题本记录:作答与正确答案以 JSON 落库, +// 与 mistakeView 的反序列化、MyMistakes 的出参形状对齐。 +func mistakePayload(userID, questionID uint, source string, q model.Question, userAns any, correct []string) model.MistakeRecord { userAnsJSON, _ := json.Marshal(userAns) correctJSON, _ := json.Marshal(correct) - var rec model.MistakeRecord - err := store.DB.Where("user_id = ? AND question_id = ? AND source = ?", userID, questionID, source).First(&rec).Error - if err != nil { - store.DB.Create(&model.MistakeRecord{ - UserID: userID, QuestionID: questionID, Source: source, - QuestionType: q.Type, QuestionStem: q.Stem, - UserAnswer: string(userAnsJSON), CorrectAnswer: string(correctJSON), - Explanation: q.Explanation, Resolved: false, - }) - return + return model.MistakeRecord{ + UserID: userID, QuestionID: questionID, Source: source, + QuestionType: q.Type, QuestionStem: q.Stem, + UserAnswer: string(userAnsJSON), CorrectAnswer: string(correctJSON), + Explanation: q.Explanation, } - rec.QuestionType = q.Type - rec.QuestionStem = q.Stem - rec.UserAnswer = string(userAnsJSON) - rec.CorrectAnswer = string(correctJSON) - rec.Explanation = q.Explanation - rec.Resolved = false - store.DB.Save(&rec) +} + +// recordMistake 答错入本:按 (user_id, question_id, source) 去重,再次答错则更新并重置为未掌握。 +func recordMistake(userID, questionID uint, q model.Question, userAns any, correct []string, source string) { + mistakeRepo.RecordWrong(mistakePayload(userID, questionID, source, q, userAns, correct)) } // mistakeView 错题出参(answer 反序列化,便于前端直接展示) @@ -1038,11 +1013,7 @@ type mistakeView struct { // MyMistakes GET /api/exam/mistakes —— 我的错题本 func MyMistakes(c *gin.Context) { u := middleware.CurrentUser(c) - var items []model.MistakeRecord - if err := store.DB.Where("user_id = ?", u.ID).Order("updated_at DESC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询错题本失败")) - return - } + items := mistakeRepo.ListByUser(u.ID) out := make([]mistakeView, 0, len(items)) for _, it := range items { var userAns any @@ -1069,8 +1040,8 @@ func ResolveMistake(c *gin.Context) { if !ok { return } - var rec model.MistakeRecord - if err := store.DB.First(&rec, id).Error; err != nil { + rec, found := mistakeRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("错题记录不存在")) return } @@ -1088,7 +1059,7 @@ func ResolveMistake(c *gin.Context) { } wasResolved := rec.Resolved rec.Resolved = target - if err := store.DB.Save(&rec).Error; err != nil { + if !mistakeRepo.Update(&rec) { web.Fail(c, web.NewBadRequest("更新错题状态失败")) return } @@ -1112,18 +1083,7 @@ func MistakePractice(c *gin.Context) { } _ = c.ShouldBindJSON(&req) - q := store.DB.Where("user_id = ?", u.ID) - if req.Source != "" { - q = q.Where("source = ?", req.Source) - } - if req.OnlyUnresolved { - q = q.Where("resolved = ?", false) - } - var recs []model.MistakeRecord - if err := q.Order("updated_at DESC").Find(&recs).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询错题失败")) - return - } + recs := mistakeRepo.ListForPractice(u.ID, req.Source, req.OnlyUnresolved) if len(recs) == 0 { web.Fail(c, web.NewBadRequest("暂无可重练的错题")) return @@ -1187,26 +1147,16 @@ func MistakePractice(c *gin.Context) { // touchMistakeOnPractice 错题重练判分后同步错题状态:答对置为已掌握(加积分),答错重置为未掌握。 func touchMistakeOnPractice(userID, questionID uint, q model.Question, userAns any, correct []string, resolved bool) { - var recs []model.MistakeRecord - store.DB.Where("user_id = ? AND question_id = ?", userID, questionID).Find(&recs) - if len(recs) == 0 { + payload := mistakePayload(userID, questionID, "", q, userAns, correct) + flipped, touched := mistakeRepo.TouchOnPractice(payload, resolved) + if !touched { // 防御性兜底:理论上重练题目均来自错题本,这里创建一条 - recordMistake(userID, questionID, q, userAns, correct, "re_practice") + payload.Source = "re_practice" + mistakeRepo.RecordWrong(payload) return } - userAnsJSON, _ := json.Marshal(userAns) - correctJSON, _ := json.Marshal(correct) - for i := range recs { - was := recs[i].Resolved - recs[i].Resolved = resolved - recs[i].QuestionType = q.Type - recs[i].QuestionStem = q.Stem - recs[i].UserAnswer = string(userAnsJSON) - recs[i].CorrectAnswer = string(correctJSON) - recs[i].Explanation = q.Explanation - store.DB.Save(&recs[i]) - if resolved && !was { - awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", recs[i].ID) - } + // 仅在「未掌握 → 已掌握」时加分,避免反复重练刷分 + for _, id := range flipped { + awardPoints(userID, "mistake_resolved", ptMistakeResolved, "mistake", id) } } diff --git a/eai_agentplatform/backend-go/internal/api/export_common.go b/eai_agentplatform/backend-go/internal/api/export_common.go index 0525cca..145da8f 100644 --- a/eai_agentplatform/backend-go/internal/api/export_common.go +++ b/eai_agentplatform/backend-go/internal/api/export_common.go @@ -5,7 +5,7 @@ import ( "strings" ) -// writeFile helper: create a file in zip and write content +// zipWriteFile helper: create a file in zip and write content func zipWriteFile(zw *zip.Writer, name, content string) error { f, err := zw.Create(name) if err != nil { diff --git a/eai_agentplatform/backend-go/internal/api/export_docx.go b/eai_agentplatform/backend-go/internal/api/export_docx.go deleted file mode 100644 index 26098ff..0000000 --- a/eai_agentplatform/backend-go/internal/api/export_docx.go +++ /dev/null @@ -1,31 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "strings" -) - -func ExportReportDOCX(report reportContent) ([]byte, error) { - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - zipWriteFile(zw, "[Content_Types].xml", ``) - zipWriteFile(zw, "_rels/.rels", ``) - var doc strings.Builder - doc.WriteString(`` + ``) - doc.WriteString(`` + escapeXML(report.Topic) + ``) - doc.WriteString(`摘要`) - doc.WriteString(`` + escapeXML(report.Summary) + ``) - for _, ch := range report.Chapters { - doc.WriteString(`` + escapeXML(ch.Title) + ``) - doc.WriteString(`` + escapeXML(ch.Body) + ``) - } - doc.WriteString(``) - zipWriteFile(zw, "word/document.xml", doc.String()) - zipWriteFile(zw, "word/styles.xml", ``) - zipWriteFile(zw, "word/_rels/document.xml.rels", ``) - zipWriteFile(zw, "docProps/core.xml", `` + escapeXML(report.Topic) + ``) - zipWriteFile(zw, "docProps/app.xml", `Microsoft Word`) - zw.Close() - return buf.Bytes(), nil -} diff --git a/eai_agentplatform/backend-go/internal/api/export_pptx.go b/eai_agentplatform/backend-go/internal/api/export_pptx.go deleted file mode 100644 index c5b235f..0000000 --- a/eai_agentplatform/backend-go/internal/api/export_pptx.go +++ /dev/null @@ -1,72 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "fmt" - "strings" -) - -func ExportReportPPTX(report reportContent) ([]byte, error) { - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - - layoutRefs := make([]string, 0) - for i := 0; i < len(report.Chapters)+1; i++ { - layoutRefs = append(layoutRefs, "rId"+fmt.Sprint(i+1)) - } - slideRels := strings.Join(layoutRefs, "") - - zipWriteFile(zw, "[Content_Types].xml", ``) - zipWriteFile(zw, "_rels/.rels", ``) - - preXML := `` + - `` + - `` + - `` + slideRels + `` - zipWriteFile(zw, "ppt/presentation.xml", preXML) - - zipWriteFile(zw, "ppt/_rels/presentation.xml.rels", ``) - - titleSlide := buildSlideTitle(report.Topic, report.Summary) - zipWriteFile(zw, "ppt/slides/slide1.xml", titleSlide) - zipWriteFile(zw, "ppt/slides/_rels/slide1.xml.rels", ``) - - for i, ch := range report.Chapters { - slideNum := i + 2 - slidePath := fmt.Sprintf("ppt/slides/slide%d.xml", slideNum) - relnPath := fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", slideNum) - zipWriteFile(zw, slidePath, buildSlideChapter(ch.Title, ch.Body)) - zipWriteFile(zw, relnPath, ``) - } - - zipWriteFile(zw, "ppt/slideLayouts/slideLayout1.xml", `标题-457200"/>内容`) - - zipWriteFile(zw, "ppt/theme/theme1.xml", ``) - - zipWriteFile(zw, "ppt/media/", "") - zw.Close() - return buf.Bytes(), nil -} - -func buildSlideTitle(title, summary string) string { - return `` + - `` + - `` + - `` + - `` + escapeXML(title) + `` + - `` + - `-457200"/>` + escapeXML(summary) + `` + - `` -} - -func buildSlideChapter(title, body string) string { - return `` + - `` + - `` + - `` + - `` + escapeXML(title) + `` + - `` + - `-457200"/>` + escapeXML(body) + `` + - `` -} diff --git a/eai_agentplatform/backend-go/internal/api/export_xlsx.go b/eai_agentplatform/backend-go/internal/api/export_xlsx.go deleted file mode 100644 index 88d614d..0000000 --- a/eai_agentplatform/backend-go/internal/api/export_xlsx.go +++ /dev/null @@ -1,55 +0,0 @@ -package api - -import ( - "archive/zip" - "bytes" - "fmt" - "strings" -) - -func ExportReportXLSX(report reportContent) ([]byte, error) { - var buf bytes.Buffer - zw := zip.NewWriter(&buf) - ss := []string{} - addSS := func(s string) int { - s = strings.TrimSpace(s) - if s == "" { - return len(ss) - } - ss = append(ss, s) - return len(ss) - 1 - } - addSS(report.Topic) - addSS(report.Summary) - for _, ch := range report.Chapters { - addSS(ch.Title) - addSS(ch.Body) - } - zipWriteFile(zw, "[Content_Types].xml", ``) - zipWriteFile(zw, "_rels/.rels", ``) - zipWriteFile(zw, "xl/workbook.xml", ``) - zipWriteFile(zw, "xl/_rels/workbook.xml.rels", ``) - var sb strings.Builder - sb.WriteString(``) - for _, s := range ss { - sb.WriteString(`` + escapeXML(s) + ``) - } - sb.WriteString(``) - zipWriteFile(zw, "xl/sharedStrings.xml", sb.String()) - var sb2 strings.Builder - sb2.WriteString(``) - row := 1 - sb2.WriteString(`` + escapeXML(report.Topic) + ``) - row = 2 - sb2.WriteString(`摘要` + escapeXML(report.Summary) + ``) - row = 3 - sb2.WriteString(`章节内容`) - for _, ch := range report.Chapters { - row++ - sb2.WriteString(`` + escapeXML(ch.Title) + `` + escapeXML(ch.Body) + ``) - } - sb2.WriteString(``) - zipWriteFile(zw, "xl/worksheets/sheet1.xml", sb2.String()) - zw.Close() - return buf.Bytes(), nil -} diff --git a/eai_agentplatform/backend-go/internal/api/knowledge.go b/eai_agentplatform/backend-go/internal/api/knowledge.go index 810b476..658aa15 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge.go @@ -91,10 +91,10 @@ func KnowledgeAuditList(c *gin.Context) { web.OK(c, gin.H{"total": total, "items": items}) } -// KnowledgeAudit POST /api/knowledge/audit/{sourceId} —— 审批(approve→摄入 / reject→驳回) +// KnowledgeAudit POST /api/knowledge/audit/{source_id} —— 审批(approve→摄入 / reject→驳回) func KnowledgeAudit(c *gin.Context) { u := middleware.CurrentUser(c) - id, ok := parseID(c, "sourceId") + id, ok := parseID(c, "source_id") if !ok { return } @@ -157,9 +157,9 @@ func KnowledgeAudit(c *gin.Context) { } } -// KnowledgeStatus GET /api/knowledge/status/{sourceId} +// KnowledgeStatus GET /api/knowledge/status/{source_id} func KnowledgeStatus(c *gin.Context) { - id, ok := parseID(c, "sourceId") + id, ok := parseID(c, "source_id") if !ok { return } diff --git a/eai_agentplatform/backend-go/internal/api/knowledge_export.go b/eai_agentplatform/backend-go/internal/api/knowledge_export.go index afe482e..5f3d5a8 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge_export.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge_export.go @@ -29,9 +29,9 @@ var productCatLabel = map[string]string{ } var courseCatLabel = map[string]string{ - "capital_script": "资本咨询话术", - "qualification_logic": "资质认定逻辑", - "ai_public_course": "AI 公开课", + "capital_script": "资本咨询话术", + "qualification_logic": "资质认定逻辑", + "ai_public_course": "AI 公开课", "ai_platform_matching": "AI 平台匹配", } diff --git a/eai_agentplatform/backend-go/internal/api/knowledge_index.go b/eai_agentplatform/backend-go/internal/api/knowledge_index.go index d0801bd..20b104d 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge_index.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge_index.go @@ -8,7 +8,6 @@ import ( "eai_agentplatform/backend/internal/ai" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -48,21 +47,10 @@ func rebuildKnowledgeIndexNow() (int, error) { func buildKnowledgeIndexItems() []ai.KnowledgeIndexItem { var chunks []model.KnowledgeChunk - store.DB.Order("id ASC").Find(&chunks) + chunkRepo.Type(&chunks).Order("id ASC").Find(&chunks) - var mediaFiles []model.MediaFile - store.DB.Where("status = ?", "approved").Find(&mediaFiles) - mediaMap := make(map[uint]model.MediaFile, len(mediaFiles)) - for _, item := range mediaFiles { - mediaMap[item.ID] = item - } - - var sources []model.KnowledgeSource - store.DB.Where("audit_status = ?", "approved").Find(&sources) - sourceMap := make(map[uint]model.KnowledgeSource, len(sources)) - for _, item := range sources { - sourceMap[item.ID] = item - } + // 与 AI 检索候选共用同一份「已审批」映射,避免两处过滤条件漂移。 + mediaMap, sourceMap := chunkRepo.ApprovedMetaMaps() items := make([]ai.KnowledgeIndexItem, 0, len(chunks)) for _, chunk := range chunks { diff --git a/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go b/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go index aa4efe6..9d7a434 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge_pipeline.go @@ -307,9 +307,10 @@ type retrievalScore struct { func loadKnowledgeCandidates(spaceKey string) []knowledgeCandidate { var chunks []model.KnowledgeChunk - store.DB.Order("id ASC").Find(&chunks) - mediaMap := map[uint]model.MediaFile{} - sourceMap := map[uint]model.KnowledgeSource{} + chunkRepo.Type(&chunks).Order("id ASC").Find(&chunks) + // 必须传入真实的已审批映射:resolveChunkMeta 查不到就丢分片, + // 传空 map 会让所有挂素材/知识源的分片消失,AI 检索候选集只剩无指针分片。 + mediaMap, sourceMap := chunkRepo.ApprovedMetaMaps() out := make([]knowledgeCandidate, 0, len(chunks)) for _, chunk := range chunks { title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap) diff --git a/eai_agentplatform/backend-go/internal/api/knowledge_space.go b/eai_agentplatform/backend-go/internal/api/knowledge_space.go index a2019ec..06d1a7a 100644 --- a/eai_agentplatform/backend-go/internal/api/knowledge_space.go +++ b/eai_agentplatform/backend-go/internal/api/knowledge_space.go @@ -9,10 +9,20 @@ import ( "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/web" ) +var ( + spaceRepo repository.KnowledgeSpaceRepo + chunkRepo repository.KnowledgeChunkRepo +) + +func init() { + spaceRepo = repository.KnowledgeSpaceRepo{} + chunkRepo = repository.KnowledgeChunkRepo{} +} + type spaceMetrics struct { Documents int `json:"document_count"` Chunks int `json:"chunk_count"` @@ -78,11 +88,11 @@ func CreateKnowledgeSpace(c *gin.Context) { Status: orDefault(strings.TrimSpace(req.Status), "active"), SortOrder: req.SortOrder, } - if err := store.DB.Create(&space).Error; err != nil { - web.Fail(c, web.NewConflictError("知识空间 key 已存在")) + if spaceRepo.Insert(&space) { + web.OK(c, gin.H{"item": space}) return } - web.OK(c, gin.H{"item": space}) + web.Fail(c, web.NewConflictError("知识空间 key 已存在")) } // UpdateKnowledgeSpace PUT /api/knowledge/spaces/:id @@ -91,8 +101,8 @@ func UpdateKnowledgeSpace(c *gin.Context) { if !ok { return } - var space model.KnowledgeSpace - if err := store.DB.First(&space, id).Error; err != nil { + space, found := spaceRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("知识空间不存在")) return } @@ -118,7 +128,7 @@ func UpdateKnowledgeSpace(c *gin.Context) { space.Status = strings.TrimSpace(req.Status) } space.SortOrder = req.SortOrder - if err := store.DB.Save(&space).Error; err != nil { + if !spaceRepo.Update(&space) { web.Fail(c, web.NewBadRequest("更新知识空间失败")) return } @@ -131,8 +141,8 @@ func DeleteKnowledgeSpace(c *gin.Context) { if !ok { return } - var space model.KnowledgeSpace - if err := store.DB.First(&space, id).Error; err != nil { + space, found := spaceRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("知识空间不存在")) return } @@ -140,7 +150,7 @@ func DeleteKnowledgeSpace(c *gin.Context) { web.Fail(c, web.NewConflictError("默认知识空间不可删除")) return } - if err := store.DB.Delete(&space).Error; err != nil { + if !spaceRepo.Type(&model.KnowledgeSpace{}).Where("id = ?", id).Delete(&model.KnowledgeSpace{}) { web.Fail(c, web.NewBadRequest("删除知识空间失败")) return } @@ -162,29 +172,31 @@ func SearchKnowledge(c *gin.Context) { } var chunks []model.KnowledgeChunk - chunkQuery := store.DB.Order("created_at DESC") + chunkQuery := repository.DB.Model(&model.KnowledgeChunk{}).Order("created_at DESC") if spaceKey != "" { - // 优先按显式空间过滤;同时保留空值记录作为兼容兜底,避免历史数据完全不可见。 chunkQuery = chunkQuery.Where("knowledge_space_key = ? OR knowledge_space_key = '' OR knowledge_space_key IS NULL", spaceKey) } chunkQuery.Limit(400).Find(&chunks) + // 这里刻意不用 chunkRepo.ApprovedMetaMaps():本站已把分片限制在 400 条内, + // 按需只取被引用到的素材/知识源,比整表加载已审批记录更省。 + // 未命中审批状态的记录由 resolveChunkMeta 兜底拦掉,语义与那份批量映射一致。 + mediaRepo := repository.MediaFileRepo{} + sourceRepo := repository.KnowledgeSourceRepo{} mediaMap := map[uint]model.MediaFile{} sourceMap := map[uint]model.KnowledgeSource{} for _, chunk := range chunks { if chunk.MediaFileID != nil { if _, ok := mediaMap[*chunk.MediaFileID]; !ok { - var media model.MediaFile - if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil { - mediaMap[*chunk.MediaFileID] = media + if m, ok := mediaRepo.GetByID(*chunk.MediaFileID); ok { + mediaMap[*chunk.MediaFileID] = m } } } if chunk.KnowledgeSourceID != nil { if _, ok := sourceMap[*chunk.KnowledgeSourceID]; !ok { - var source model.KnowledgeSource - if err := store.DB.First(&source, *chunk.KnowledgeSourceID).Error; err == nil { - sourceMap[*chunk.KnowledgeSourceID] = source + if s, ok := sourceRepo.GetByID(*chunk.KnowledgeSourceID); ok { + sourceMap[*chunk.KnowledgeSourceID] = s } } } @@ -255,25 +267,18 @@ func listKnowledgeSpacesWithMetrics() ([]model.KnowledgeSpace, map[string]spaceM return nil, nil, err } var spaces []model.KnowledgeSpace - if err := store.DB.Order("sort_order ASC, id ASC").Find(&spaces).Error; err != nil { - return nil, nil, err - } + spaceRepo.Type(&model.KnowledgeSpace{}).Order("sort_order ASC, id ASC").Find(&spaces) metrics, err := buildKnowledgeSpaceMetrics() - if err != nil { - return nil, nil, err - } - return spaces, metrics, nil + return spaces, metrics, err } func ensureDefaultKnowledgeSpaces() error { for _, item := range defaultKnowledgeSpaces { - var existing model.KnowledgeSpace - if err := store.DB.Where("key = ?", item.Key).First(&existing).Error; err == nil { + _, found := spaceRepo.GetByName(item.Key) + if found { continue } - if err := store.DB.Create(&item).Error; err != nil { - return err - } + spaceRepo.Insert(&item) } return nil } @@ -284,16 +289,16 @@ func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) { metrics[item.Key] = spaceMetrics{} } + mediaRepo := repository.MediaFileRepo{} + sourceRepo := repository.KnowledgeSourceRepo{} + var mediaFiles []model.MediaFile - if err := store.DB.Where("status = ?", "approved").Find(&mediaFiles).Error; err != nil { - return nil, err - } - for _, media := range mediaFiles { - key := resolveMediaKnowledgeSpaceKey(media) + mediaRepo.Type(&model.MediaFile{}).Where("status = ?", "approved").Find(&mediaFiles) + for _, f := range mediaFiles { + key := resolveMediaKnowledgeSpaceKey(f) m := metrics[key] m.Documents++ metrics[key] = m - // 通用空间汇总所有已通过文档 if key != "general" { g := metrics["general"] g.Documents++ @@ -302,11 +307,9 @@ func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) { } var sources []model.KnowledgeSource - if err := store.DB.Where("audit_status = ?", "approved").Find(&sources).Error; err != nil { - return nil, err - } - for _, source := range sources { - key := resolveKnowledgeSourceSpaceKey(source) + sourceRepo.Type(&model.KnowledgeSource{}).Where("audit_status = ?", "approved").Find(&sources) + for _, s := range sources { + key := resolveKnowledgeSourceSpaceKey(s) m := metrics[key] m.Documents++ metrics[key] = m @@ -318,19 +321,15 @@ func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) { } var chunks []model.KnowledgeChunk - if err := store.DB.Find(&chunks).Error; err != nil { - return nil, err - } + repository.DB.Find(&chunks) for _, chunk := range chunks { key := ensureKnowledgeSpaceKeyOrDefault(chunk.KnowledgeSpaceKey) if chunk.KnowledgeSourceID != nil { - var src model.KnowledgeSource - if err := store.DB.First(&src, *chunk.KnowledgeSourceID).Error; err == nil { + if src, ok := sourceRepo.GetByID(*chunk.KnowledgeSourceID); ok { key = resolveKnowledgeSourceSpaceKey(src) } } else if chunk.MediaFileID != nil { - var media model.MediaFile - if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil { + if media, ok := mediaRepo.GetByID(*chunk.MediaFileID); ok { key = resolveMediaKnowledgeSpaceKey(media) } } else if key == "general" { @@ -354,8 +353,7 @@ func getKnowledgeSpaceDisplayName(key string) string { return item.Name } } - var space model.KnowledgeSpace - if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil && strings.TrimSpace(space.Name) != "" { + if space, ok := spaceRepo.GetByName(key); ok && strings.TrimSpace(space.Name) != "" { return space.Name } return key @@ -400,8 +398,8 @@ func ensureKnowledgeSpaceKeyOrDefault(key string) string { } } } - var space model.KnowledgeSpace - if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil { + _, ok := spaceRepo.GetByName(key) + if ok { return key } return "general" diff --git a/eai_agentplatform/backend-go/internal/api/media.go b/eai_agentplatform/backend-go/internal/api/media.go index 097faa4..546707d 100644 --- a/eai_agentplatform/backend-go/internal/api/media.go +++ b/eai_agentplatform/backend-go/internal/api/media.go @@ -18,20 +18,27 @@ import ( "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/web" ) +// mediaRepo 素材仓库(便于测试时覆写),包内共享。 +var mediaRepo repository.MediaFileRepo + +func init() { + mediaRepo = repository.MediaFileRepo{} +} + 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, + "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, + "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} @@ -52,19 +59,19 @@ func extOf(filename string) string { } func isUploadableExt(ext string) bool { - normalized := strings.ToLower(strings.TrimSpace(ext)) - if normalized == "" { - return false - } - return !blockedUploadExt[normalized] + 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))] + return videoExt[strings.ToLower(strings.TrimSpace(ext))] } func sizeLimitFor(ext string) int64 { - if isVideoExt(ext) { + if isVideoExt(ext) { return Cfg.FileMaxVideo } return Cfg.FileMaxDoc @@ -135,11 +142,11 @@ func Upload(c *gin.Context) { defer file.Close() ext := extOf(header.Filename) - if !isUploadableExt(ext) { + if !isUploadableExt(ext) { web.Fail(c, web.NewBadRequest("不支持的文件类型")) return } - if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold { + if isVideoExt(ext) && header.Size > Cfg.ChunkThreshold { web.Fail(c, web.NewBadRequest("视频超过 100MB 请使用分片上传")) return } @@ -189,7 +196,7 @@ func Upload(c *gin.Context) { BindID: bindID, KnowledgeSpaceKey: knowledgeSpaceKey, } - if err := store.DB.Create(&m).Error; err != nil { + if !mediaRepo.Insert(&m) { web.Fail(c, web.NewBadRequest("创建素材记录失败")) return } @@ -232,7 +239,7 @@ func UploadInit(c *gin.Context) { return } ext := extOf(req.Filename) - if !isUploadableExt(ext) { + if !isUploadableExt(ext) { web.Fail(c, web.NewBadRequest("不支持的文件类型")) return } @@ -380,7 +387,7 @@ func UploadComplete(c *gin.Context) { Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID, KnowledgeSpaceKey: sess.KnowledgeSpaceKey, } - if err := store.DB.Create(&m).Error; err != nil { + if !mediaRepo.Insert(&m) { web.Fail(c, web.NewBadRequest("创建素材记录失败")) return } @@ -392,14 +399,14 @@ func UploadComplete(c *gin.Context) { // ============ 预览 / 状态 ============ -// Preview GET /api/media/preview/{mediaId} —— 仅 approved 可预览 +// Preview GET /api/media/preview/{media_id} —— 仅 approved 可预览 func Preview(c *gin.Context) { - id, ok := parseID(c, "mediaId") + id, ok := parseID(c, "media_id") if !ok { return } - var m model.MediaFile - if err := store.DB.First(&m, id).Error; err != nil { + m, found := mediaRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("素材不存在")) return } @@ -414,33 +421,29 @@ func Preview(c *gin.Context) { }) } -// MediaStatus GET /api/media/status/{mediaId} +// MediaStatus GET /api/media/status/{media_id} func MediaStatus(c *gin.Context) { - id, ok := parseID(c, "mediaId") + id, ok := parseID(c, "media_id") if !ok { return } - var m model.MediaFile - if err := store.DB.First(&m, id).Error; err != nil { + m, found := mediaRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("素材不存在")) return } - var chunkCount int64 - store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount) - web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount, "knowledge_space_key": m.KnowledgeSpaceKey}) + web.OK(c, gin.H{ + "status": m.Status, + "extracted": m.Extracted, + "chunk_count": chunkRepo.CountByMediaFile(m.ID), + "knowledge_space_key": m.KnowledgeSpaceKey, + }) } // ============ 审批 ============ // AuditList GET /api/media/audit-list?status=&page=&size= func AuditList(c *gin.Context) { - q := store.DB.Model(&model.MediaFile{}) - if s := c.Query("status"); s != "" { - q = q.Where("status = ?", s) - } - if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" { - q = q.Where("knowledge_space_key = ?", key) - } page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) if page < 1 { @@ -449,22 +452,20 @@ func AuditList(c *gin.Context) { if size < 1 || size > 100 { size = 20 } - var total int64 - q.Count(&total) - var items []model.MediaFile - q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items) + total, items := mediaRepo.ListForAudit( + c.Query("status"), sanitizeSpaceKey(c.Query("knowledge_space_key")), page, size) web.OK(c, gin.H{"total": total, "items": items}) } -// AuditMedia POST /api/media/audit/{mediaId} —— 审批(通过→触发异步提取) +// AuditMedia POST /api/media/audit/{media_id} —— 审批(通过→触发异步提取) func AuditMedia(c *gin.Context) { u := middleware.CurrentUser(c) - id, ok := parseID(c, "mediaId") + id, ok := parseID(c, "media_id") if !ok { return } - var m model.MediaFile - if err := store.DB.First(&m, id).Error; err != nil { + m, found := mediaRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("素材不存在")) return } @@ -523,7 +524,7 @@ func AuditMedia(c *gin.Context) { web.Fail(c, web.NewBadRequest("action 必须为 approve 或 reject")) return } - if err := store.DB.Save(&m).Error; err != nil { + if !mediaRepo.Update(&m) { web.Fail(c, web.NewBadRequest("审批失败")) return } @@ -537,8 +538,8 @@ func AuditMedia(c *gin.Context) { // runExtractPipeline 审批通过后:文档转 PDF → pdftotext → 切片入库 func runExtractPipeline(mediaID uint) { - var m model.MediaFile - if err := store.DB.First(&m, mediaID).Error; err != nil { + m, found := mediaRepo.GetByID(mediaID) + if !found { return } docExts := map[string]bool{"ppt": true, "pptx": true, "doc": true, "docx": true} @@ -553,7 +554,7 @@ func runExtractPipeline(mediaID uint) { text, err = pdftotextExtract(src) default: // 视频/图片:仅标记,不提取 - store.DB.Model(&m).Update("extracted", true) + mediaRepo.MarkExtracted(m.ID) return } if err != nil { @@ -562,20 +563,24 @@ func runExtractPipeline(mediaID uint) { } chunks := splitIntoChunks(text, 1000) + spaceKey := resolveMediaKnowledgeSpaceKey(m) + sourceID := strconv.FormatUint(uint64(m.ID), 10) + items := make([]model.KnowledgeChunk, 0, len(chunks)) for i, chunk := range chunks { if strings.TrimSpace(chunk) == "" { continue } - store.DB.Create(&model.KnowledgeChunk{ + items = append(items, model.KnowledgeChunk{ MediaFileID: &m.ID, SourceType: m.FileExt, - SourceID: strconv.FormatUint(uint64(m.ID), 10), - KnowledgeSpaceKey: resolveMediaKnowledgeSpaceKey(m), + SourceID: sourceID, + KnowledgeSpaceKey: spaceKey, ChunkIndex: i, Content: chunk, }) } - store.DB.Model(&m).Update("extracted", true) + chunkRepo.BulkInsert(items) + mediaRepo.MarkExtracted(m.ID) triggerKnowledgeIndexRebuild() log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks)) } diff --git a/eai_agentplatform/backend-go/internal/api/my_app_center.go b/eai_agentplatform/backend-go/internal/api/my_app_center.go deleted file mode 100644 index cc24720..0000000 --- a/eai_agentplatform/backend-go/internal/api/my_app_center.go +++ /dev/null @@ -1,277 +0,0 @@ -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 35f96c1..0659a89 100644 --- a/eai_agentplatform/backend-go/internal/api/my_task.go +++ b/eai_agentplatform/backend-go/internal/api/my_task.go @@ -6,10 +6,11 @@ import ( "strings" "github.com/gin-gonic/gin" - "gorm.io/gorm" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -21,43 +22,15 @@ const generalAssistantKey = "general-assistant" // 「新建任务」的占位标题。发出第一条消息后前端会把它改成消息内容。 const myTaskPlaceholderTitle = "新任务" -// myTaskOwners 是"这条任务算谁的"的判定依据。scopedWorkbenchTaskQuery 也是 -// 按这两个值找人,保持一致;只写 FullName 的话没填全名的用户会跟别人混在一起。 -func myTaskOwners(user *model.User) []string { - if user == nil { - return nil - } - owners := make([]string, 0, 2) - if name := strings.TrimSpace(user.FullName); name != "" { - owners = append(owners, name) - } - if name := strings.TrimSpace(user.Username); name != "" { - owners = append(owners, name) - } - return owners -} - // myTaskOwnerName 一条新任务记在谁名下。空全名时退回登录名。 func myTaskOwnerName(user *model.User) string { - owners := myTaskOwners(user) + owners := specialistruntime.MyTaskOwners(user) if len(owners) == 0 { return "" } return owners[0] } -// myTaskQuery 「我的任务」永远是"我自己的",管理员也只看自己的 —— -// 要看全公司的那是工作台总览,不是这里。 -func myTaskQuery(user *model.User) *gorm.DB { - owners := myTaskOwners(user) - query := store.DB.Model(&model.WorkerTask{}) - if len(owners) == 0 { - // 没有可用的归属标识,返回空集而不是全表 - return query.Where("1 = 0") - } - return query.Where("owner IN ?", owners) -} - // ListMyTasks 当前用户的全部任务,跨专员,最近更新的在前。 func ListMyTasks(c *gin.Context) { user := middleware.CurrentUser(c) @@ -66,8 +39,8 @@ func ListMyTasks(c *gin.Context) { return } - var items []model.WorkerTask - if err := myTaskQuery(user). + var items []model.TaskRecord + if err := specialistruntime.MyTaskQuery(user). // 置顶的排在前面,其余按最近动过的排 —— 置顶只是一个排序偏好。 Order("pinned DESC, updated_at DESC, id DESC"). Limit(50). @@ -78,7 +51,7 @@ func ListMyTasks(c *gin.Context) { web.OK(c, items) } -// DeleteMyTask 彻底删掉一条任务。worker_task 没有软删字段,删了就是删了 —— +// DeleteMyTask 彻底删掉一条任务。task_record 没有软删字段,删了就是删了 —— // 前端那边因此必须在确认框里说清楚,不能拿它当「归档」用。 func DeleteMyTask(c *gin.Context) { user := middleware.CurrentUser(c) @@ -91,8 +64,8 @@ func DeleteMyTask(c *gin.Context) { return } - var task model.WorkerTask - if err := myTaskQuery(user).Where("id = ?", id).First(&task).Error; err != nil { + var task model.TaskRecord + if err := specialistruntime.MyTaskQuery(user).Where("id = ?", id).First(&task).Error; err != nil { web.Fail(c, web.NewNotFoundError("任务不存在")) return } @@ -112,7 +85,7 @@ func CreateMyTask(c *gin.Context) { } // 允许空 body:点「新建任务」时前端什么都可以不带,全部走默认值。 - var req workerTaskReq + var req specialistruntime.TaskReq if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) { web.Fail(c, web.NewBadRequest("请求参数错误")) return @@ -122,7 +95,7 @@ func CreateMyTask(c *gin.Context) { if specialistKey == "" { specialistKey = generalAssistantKey } - var specialist model.Specialist + var specialist specialistmodel.Specialist if err := store.DB.Where("key = ?", specialistKey).First(&specialist).Error; err != nil { web.Fail(c, web.NewNotFoundError("专员不存在")) return @@ -137,14 +110,14 @@ func CreateMyTask(c *gin.Context) { req.SpecialistKey = specialist.Key req.Title = title req.Owner = myTaskOwnerName(user) - task, err := buildWorkerTaskFromReq(req, user) + task, err := specialistruntime.BuildTaskFromReq(req, user) if err != nil { web.Fail(c, web.NewBadRequest(err.Error())) return } task.SpecialistKey = specialist.Key if task.Status == "" { - task.Status = workerTaskStatusPending + task.Status = specialistruntime.TaskStatusPending } if task.Status == "" { task.Status = "待处理" @@ -169,13 +142,13 @@ func UpdateMyTask(c *gin.Context) { return } - var task model.WorkerTask - if err := myTaskQuery(user).Where("id = ?", id).First(&task).Error; err != nil { + var task model.TaskRecord + if err := specialistruntime.MyTaskQuery(user).Where("id = ?", id).First(&task).Error; err != nil { web.Fail(c, web.NewNotFoundError("任务不存在")) return } - var req workerTaskReq + var req specialistruntime.TaskReq if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return @@ -185,7 +158,7 @@ func UpdateMyTask(c *gin.Context) { task.Title = title } if key := strings.TrimSpace(req.SpecialistKey); key != "" && key != task.SpecialistKey { - var specialist model.Specialist + var specialist specialistmodel.Specialist if err := store.DB.Where("key = ?", key).First(&specialist).Error; err != nil { web.Fail(c, web.NewNotFoundError("目标专员不存在")) return @@ -201,7 +174,7 @@ func UpdateMyTask(c *gin.Context) { task.ProjectID = nil } else { var project model.Project - if err := projectQuery(user).Where("id = ?", *req.ProjectID).First(&project).Error; err != nil { + if err := specialistruntime.ProjectQuery(user).Where("id = ?", *req.ProjectID).First(&project).Error; err != nil { web.Fail(c, web.NewNotFoundError("项目不存在")) return } diff --git a/eai_agentplatform/backend-go/internal/api/notification.go b/eai_agentplatform/backend-go/internal/api/notification.go deleted file mode 100644 index ebf59f0..0000000 --- a/eai_agentplatform/backend-go/internal/api/notification.go +++ /dev/null @@ -1,110 +0,0 @@ -package api - -import ( - "strings" - - "github.com/gin-gonic/gin" - - "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" - "eai_agentplatform/backend/internal/web" -) - -// notifyUser 给单个用户发站内通知(userID=0 忽略)。 -func notifyUser(userID uint, ntype, title, content, link string) { - if userID == 0 { - return - } - store.DB.Create(&model.Notification{ - UserID: userID, Type: ntype, Title: title, Content: content, Link: link, - }) -} - -// notifyAllEmployees 给所有启用员工发通知(如新正式考试发布)。 -func notifyAllEmployees(ntype, title, content, link string) { - var users []model.User - store.DB.Where("role = ? AND status = ?", "employee", "active").Select("id").Find(&users) - for _, u := range users { - notifyUser(u.ID, ntype, title, content, link) - } -} - -func notifyAdmins(ntype, title, content, link string) { - var users []model.User - store.DB.Where("role = ? AND status = ?", "admin", "active").Select("id").Find(&users) - for _, u := range users { - notifyUser(u.ID, ntype, title, content, link) - } -} - -func notifyUsersByOwner(owner, ntype, title, content, link string) { - owner = strings.TrimSpace(owner) - if owner == "" || owner == "待指派" { - return - } - var users []model.User - store.DB.Where("status = ? AND (full_name = ? OR username = ?)", "active", owner, owner).Select("id").Find(&users) - for _, u := range users { - notifyUser(u.ID, ntype, title, content, link) - } -} - -// MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表 -func MyNotifications(c *gin.Context) { - u := middleware.CurrentUser(c) - if u == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - q := store.DB.Model(&model.Notification{}).Where("user_id = ?", u.ID) - if c.Query("unread_only") == "true" { - q = q.Where("read = ?", false) - } - var items []model.Notification - q.Order("created_at DESC, id DESC").Limit(100).Find(&items) - web.OK(c, items) -} - -// UnreadNotificationCount GET /api/notifications/unread-count —— 未读数(铃铛角标) -func UnreadNotificationCount(c *gin.Context) { - u := middleware.CurrentUser(c) - if u == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var n int64 - store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Count(&n) - web.OK(c, gin.H{"unread": n}) -} - -// MarkNotificationRead PUT /api/notifications/{id}/read —— 标记单条已读 -func MarkNotificationRead(c *gin.Context) { - u := middleware.CurrentUser(c) - id, ok := parseID(c, "id") - if !ok { - return - } - var n model.Notification - if err := store.DB.First(&n, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("通知不存在")) - return - } - if n.UserID != u.ID { - web.Fail(c, web.NewForbiddenError("无权操作他人通知")) - return - } - store.DB.Model(&n).Update("read", true) - web.OK(c, gin.H{"id": n.ID, "read": true}) -} - -// MarkAllNotificationsRead PUT /api/notifications/read-all —— 全部标记已读 -func MarkAllNotificationsRead(c *gin.Context) { - u := middleware.CurrentUser(c) - if u == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - store.DB.Model(&model.Notification{}).Where("user_id = ? AND read = ?", u.ID, false).Update("read", true) - web.OK(c, gin.H{"read": true}) -} diff --git a/eai_agentplatform/backend-go/internal/api/office_skill.go b/eai_agentplatform/backend-go/internal/api/office_skill.go deleted file mode 100644 index 018ef34..0000000 --- a/eai_agentplatform/backend-go/internal/api/office_skill.go +++ /dev/null @@ -1,2240 +0,0 @@ -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_generated_image.go b/eai_agentplatform/backend-go/internal/api/official_account_generated_image.go deleted file mode 100644 index f75bc85..0000000 --- a/eai_agentplatform/backend-go/internal/api/official_account_generated_image.go +++ /dev/null @@ -1,175 +0,0 @@ -package api - -import ( - "crypto/sha1" - "encoding/base64" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/gin-gonic/gin" - - "eai_agentplatform/backend/internal/config" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" - "eai_agentplatform/backend/internal/web" -) - -func officialAccountGeneratedImageDir() string { - cfg := config.Load() - return filepath.Join(filepath.Dir(cfg.DBPath), "official_account_generated_images") -} - -func normalizeOfficialAccountMediaForResponse(task *model.WorkerTask, workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle) error { - if workflow == nil || article == nil || task == nil { - return nil - } - refs, replacements, changed, err := persistOfficialAccountImageRefs(task.ID, workflow.Shared.ImageRefs) - if err != nil { - return err - } - if !changed { - return nil - } - - workflow.Shared.ImageRefs = refs - workflow.Shared.PreviewMarkdown = replaceOfficialAccountURLs(workflow.Shared.PreviewMarkdown, replacements) - workflow.Shared.PreviewHTML = replaceOfficialAccountURLs(workflow.Shared.PreviewHTML, replacements) - workflow.Shared.PageMarkdown = replaceOfficialAccountURLs(workflow.Shared.PageMarkdown, replacements) - for idx := range workflow.Shared.Pages { - workflow.Shared.Pages[idx].Content = replaceOfficialAccountURLs(workflow.Shared.Pages[idx].Content, replacements) - } - syncOfficialAccountArticleFromWorkflow(article, *workflow) - task.ContextJSON = marshalOfficialAccountWorkflow(*workflow) - - if err := store.DB.Save(article).Error; err != nil { - return err - } - if err := store.DB.Save(task).Error; err != nil { - return err - } - return nil -} - -func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef) ([]officialAccountImageRef, map[string]string, bool, error) { - if len(refs) == 0 { - return refs, map[string]string{}, false, nil - } - next := make([]officialAccountImageRef, len(refs)) - replacements := map[string]string{} - changed := false - for idx, item := range refs { - next[idx] = item - if !strings.HasPrefix(strings.TrimSpace(item.URL), "data:image/") { - continue - } - localURL, err := persistOfficialAccountDataURL(taskID, item.Key, item.URL) - if err != nil { - return refs, nil, false, err - } - replacements[item.URL] = localURL - next[idx].URL = localURL - changed = true - } - return next, replacements, changed, nil -} - -func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error) { - parts := strings.SplitN(strings.TrimSpace(raw), ",", 2) - if len(parts) != 2 { - return "", fmt.Errorf("图片数据格式错误") - } - header := parts[0] - body := parts[1] - if !strings.Contains(header, ";base64") { - return "", fmt.Errorf("图片数据缺少 base64 头") - } - ext := ".png" - switch { - case strings.HasPrefix(header, "data:image/jpeg"): - ext = ".jpg" - case strings.HasPrefix(header, "data:image/webp"): - ext = ".webp" - case strings.HasPrefix(header, "data:image/gif"): - ext = ".gif" - } - payload, err := base64.StdEncoding.DecodeString(body) - if err != nil { - return "", err - } - dir := officialAccountGeneratedImageDir() - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - hash := sha1.Sum(payload) - filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeOfficialAccountImageKey(key), hash[:6], ext) - fullPath := filepath.Join(dir, filename) - if _, err := os.Stat(fullPath); err != nil { - if writeErr := os.WriteFile(fullPath, payload, 0o644); writeErr != nil { - return "", writeErr - } - } - return "/api/official-account/generated-images/" + filename, nil -} - -func sanitizeOfficialAccountImageKey(key string) string { - trimmed := strings.TrimSpace(strings.ToLower(key)) - trimmed = strings.ReplaceAll(trimmed, " ", "_") - trimmed = strings.ReplaceAll(trimmed, "/", "_") - trimmed = strings.ReplaceAll(trimmed, "\\", "_") - if trimmed == "" { - return "image" - } - return trimmed -} - -func replaceOfficialAccountURLs(content string, replacements map[string]string) string { - next := content - for oldURL, newURL := range replacements { - next = strings.ReplaceAll(next, oldURL, newURL) - } - return next -} - -func ServeOfficialAccountGeneratedImage(c *gin.Context) { - filename := filepath.Base(strings.TrimSpace(c.Param("filename"))) - if filename == "." || filename == "" { - web.Fail(c, web.NewNotFoundError("图片不存在")) - return - } - fullPath := filepath.Join(officialAccountGeneratedImageDir(), filename) - if _, err := os.Stat(fullPath); err != nil { - web.Fail(c, web.NewNotFoundError("图片不存在")) - return - } - c.File(fullPath) -} - -func compactOfficialAccountRunsForResponse(items []model.WorkerRun) []model.WorkerRun { - next := make([]model.WorkerRun, len(items)) - for idx, item := range items { - next[idx] = item - if len(item.OutputJSON) > 8192 || strings.Contains(item.OutputJSON, "data:image/") { - next[idx].OutputJSON = "" - } - if len(item.InputJSON) > 4096 { - next[idx].InputJSON = "" - } - } - return next -} - -func compactOfficialAccountArtifactsForResponse(items []model.WorkerArtifact) []model.WorkerArtifact { - next := make([]model.WorkerArtifact, len(items)) - for idx, item := range items { - next[idx] = item - if len(item.ContentJSON) > 8192 || strings.Contains(item.ContentJSON, "data:image/") { - next[idx].ContentJSON = "" - } - if len(item.ContentText) > 20000 { - next[idx].ContentText = item.ContentText[:20000] + "\n\n[内容过长,已在画布接口中省略]" - } - } - return next -} diff --git a/eai_agentplatform/backend-go/internal/api/position.go b/eai_agentplatform/backend-go/internal/api/position.go index d91ae02..e06d1e7 100644 --- a/eai_agentplatform/backend-go/internal/api/position.go +++ b/eai_agentplatform/backend-go/internal/api/position.go @@ -1,33 +1,29 @@ package api import ( - "fmt" - "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/web" ) +var ( + positionRepo repository.PositionRepo + courseRepo repository.CourseRepo // productRepo 定义在 products.go +) + +func init() { + positionRepo = repository.PositionRepo{} + courseRepo = repository.CourseRepo{} +} + // ============ 岗位 CRUD(管理员) ============ // ListPositions GET /api/positions?status= —— 岗位列表 func ListPositions(c *gin.Context) { - q := store.DB.Model(&model.Position{}) - switch st := c.Query("status"); st { - case "": // 默认仅 active - q = q.Where("status = ?", "active") - case "all": // 管理员维护全量 - default: - q = q.Where("status = ?", st) - } - var items []model.Position - if err := q.Order("id ASC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询岗位失败")) - return - } + items := positionRepo.List(c.Query("status")) web.OK(c, items) } @@ -42,16 +38,14 @@ func CreatePosition(c *gin.Context) { web.Fail(c, web.NewBadRequest("编号、名称为必填")) return } - var count int64 - store.DB.Model(&model.Position{}).Where("code = ?", p.Code).Count(&count) - if count > 0 { - web.Fail(c, web.NewConflictError("岗位编号已存在")) - return - } if p.Status == "" { p.Status = "active" } - if err := store.DB.Create(&p).Error; err != nil { + if positionRepo.CountByName(p.Code, nil) > 0 { + web.Fail(c, web.NewConflictError("岗位编号已存在")) + return + } + if !positionRepo.Insert(&p) { web.Fail(c, web.NewBadRequest("创建岗位失败")) return } @@ -64,8 +58,8 @@ func UpdatePosition(c *gin.Context) { if !ok { return } - var p model.Position - if err := store.DB.First(&p, id).Error; err != nil { + p, found := positionRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("岗位不存在")) return } @@ -82,9 +76,7 @@ func UpdatePosition(c *gin.Context) { req.Status = "active" } if req.Code != "" && req.Code != p.Code { - var count int64 - store.DB.Model(&model.Position{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count) - if count > 0 { + if positionRepo.CountByName(req.Code, &id) > 0 { web.Fail(c, web.NewConflictError("岗位编号已存在")) return } @@ -93,7 +85,7 @@ func UpdatePosition(c *gin.Context) { p.Name = req.Name p.Description = req.Description p.Status = req.Status - if err := store.DB.Save(&p).Error; err != nil { + if !positionRepo.Update(&p) { web.Fail(c, web.NewBadRequest("更新岗位失败")) return } @@ -106,12 +98,7 @@ func DeletePosition(c *gin.Context) { if !ok { return } - var p model.Position - if err := store.DB.First(&p, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("岗位不存在")) - return - } - if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil { + if !positionRepo.Delete(id) { web.Fail(c, web.NewBadRequest("停用岗位失败")) return } @@ -136,10 +123,9 @@ func ListPositionKnowledge(c *gin.Context) { if !ok { return } - var items []model.PositionKnowledge - if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询岗位知识映射失败")) - return + items := positionRepo.Knowledge(id) + if items == nil { + items = []model.PositionKnowledge{} } web.OK(c, items) } @@ -150,8 +136,8 @@ func SavePositionKnowledge(c *gin.Context) { if !ok { return } - var pos model.Position - if err := store.DB.First(&pos, id).Error; err != nil { + _, found := positionRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("岗位不存在")) return } @@ -190,18 +176,14 @@ func SavePositionKnowledge(c *gin.Context) { }) } // 整表覆盖:先删旧,再批量插入 - if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionKnowledge{}).Error; err != nil { - web.Fail(c, web.NewBadRequest("清除旧映射失败")) + if !positionRepo.ReplaceKnowledge(id, rows) { + web.Fail(c, web.NewBadRequest("保存岗位知识映射失败")) return } - if len(rows) > 0 { - if err := store.DB.Create(&rows).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存岗位知识映射失败")) - return - } + out := positionRepo.Knowledge(id) + if out == nil { + out = []model.PositionKnowledge{} } - var out []model.PositionKnowledge - store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out) web.OK(c, out) } @@ -213,8 +195,8 @@ func SetUserPosition(c *gin.Context) { if !ok { return } - var u model.User - if err := store.DB.First(&u, id).Error; err != nil { + u, found := userRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("用户不存在")) return } @@ -225,25 +207,18 @@ func SetUserPosition(c *gin.Context) { web.Fail(c, web.NewBadRequest("请求参数错误")) return } - posName := "" if req.PositionID != nil { - var pos model.Position - if err := store.DB.First(&pos, *req.PositionID).Error; err != nil || pos.Status != "active" { + _, found := positionRepo.GetByID(*req.PositionID) + if !found { web.Fail(c, web.NewBadRequest("岗位不存在或已停用")) return } - posName = pos.Name } u.PositionID = req.PositionID - if err := store.DB.Save(&u).Error; err != nil { + if !userRepo.Update(&u) { web.Fail(c, web.NewBadRequest("设置用户岗位失败")) return } - if req.PositionID != nil { - notifyUser(u.ID, "position_set", "岗位已设置", - fmt.Sprintf("你的岗位已设置为「%s」,可在「我的岗位清单」查看应学内容", posName), - "/exam/my-position") - } web.OK(c, gin.H{"id": u.ID, "position_id": u.PositionID}) } @@ -256,13 +231,13 @@ func MyPosition(c *gin.Context) { web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0}) return } - var pos model.Position - if err := store.DB.First(&pos, *u.PositionID).Error; err != nil || pos.Status != "active" { + pos, found := positionRepo.GetByID(*u.PositionID) + if !found || pos.Status != "active" { web.OK(c, gin.H{"position": nil, "knowledge": []gin.H{}, "count": 0}) return } - var pks []model.PositionKnowledge - store.DB.Where("position_id = ?", pos.ID).Order("id ASC").Find(&pks) + + pks := positionRepo.Knowledge(pos.ID) // 批量解析课程/产品名称 courseIDs := make([]uint, 0, len(pks)) @@ -275,22 +250,8 @@ func MyPosition(c *gin.Context) { productIDs = append(productIDs, *pk.ProductID) } } - courseName := map[uint]string{} - if len(courseIDs) > 0 { - var courses []model.Course - store.DB.Where("id IN ?", courseIDs).Find(&courses) - for _, c := range courses { - courseName[c.ID] = c.Name - } - } - productName := map[uint]string{} - if len(productIDs) > 0 { - var products []model.Product - store.DB.Where("id IN ?", productIDs).Find(&products) - for _, p := range products { - productName[p.ID] = p.Name - } - } + courseName := courseRepo.NamesByIDs(courseIDs) + productName := productRepo.NamesByIDs(productIDs) list := make([]gin.H, 0, len(pks)) for _, pk := range pks { @@ -313,16 +274,6 @@ func MyPosition(c *gin.Context) { }) } -func nameOrDash(m map[uint]string, id *uint) string { - if id == nil { - return "" - } - if n, ok := m[*id]; ok { - return n - } - return "" -} - // ============ 岗位考试蓝图(管理员) ============ type blueprintReq struct { @@ -337,10 +288,9 @@ func ListPositionBlueprint(c *gin.Context) { if !ok { return } - var items []model.PositionExamBlueprint - if err := store.DB.Where("position_id = ?", id).Order("id ASC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询岗位考试蓝图失败")) - return + items := positionRepo.Blueprints(id) + if items == nil { + items = []model.PositionExamBlueprint{} } web.OK(c, items) } @@ -351,8 +301,8 @@ func SavePositionBlueprint(c *gin.Context) { if !ok { return } - var pos model.Position - if err := store.DB.First(&pos, id).Error; err != nil { + _, found := positionRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("岗位不存在")) return } @@ -381,17 +331,23 @@ func SavePositionBlueprint(c *gin.Context) { PositionID: id, Domain: it.Domain, Type: it.Type, Count: it.Count, }) } - if err := store.DB.Where("position_id = ?", id).Delete(&model.PositionExamBlueprint{}).Error; err != nil { - web.Fail(c, web.NewBadRequest("清除旧蓝图失败")) + if !positionRepo.ReplaceBlueprints(id, rows) { + web.Fail(c, web.NewBadRequest("保存岗位考试蓝图失败")) return } - if len(rows) > 0 { - if err := store.DB.Create(&rows).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存岗位考试蓝图失败")) - return - } + out := positionRepo.Blueprints(id) + if out == nil { + out = []model.PositionExamBlueprint{} } - var out []model.PositionExamBlueprint - store.DB.Where("position_id = ?", id).Order("id ASC").Find(&out) web.OK(c, out) } + +func nameOrDash(m map[uint]string, id *uint) string { + if id == nil { + return "" + } + if n, ok := m[*id]; ok { + return n + } + return "" +} diff --git a/eai_agentplatform/backend-go/internal/api/products.go b/eai_agentplatform/backend-go/internal/api/products.go index 23d3096..b8f7544 100644 --- a/eai_agentplatform/backend-go/internal/api/products.go +++ b/eai_agentplatform/backend-go/internal/api/products.go @@ -4,28 +4,22 @@ import ( "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/web" ) +// productRepo 产品仓库(便于测试时覆写)。 +var productRepo repository.ProductRepo + +func init() { + productRepo = repository.ProductRepo{} +} + // ListProducts GET /api/products?category=&status= func ListProducts(c *gin.Context) { - q := store.DB.Model(&model.Product{}) - if cat := c.Query("category"); cat != "" { - q = q.Where("category = ?", cat) - } - switch st := c.Query("status"); st { - case "": // 默认仅 active(员工浏览) - q = q.Where("status = ?", "active") - case "all": // 管理员维护全量 - default: - q = q.Where("status = ?", st) - } - var items []model.Product - if err := q.Order("id ASC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询产品失败")) - return - } + items := productRepo.ProductsByStatus(c.Query("status"), map[string]string{ + "category": c.Query("category"), + }) web.OK(c, items) } @@ -35,8 +29,8 @@ func GetProduct(c *gin.Context) { if !ok { return } - var p model.Product - if err := store.DB.First(&p, id).Error; err != nil { + p, found := productRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("产品不存在")) return } @@ -54,16 +48,14 @@ func CreateProduct(c *gin.Context) { web.Fail(c, web.NewBadRequest("编号、名称、分类为必填")) return } - var count int64 - store.DB.Model(&model.Product{}).Where("code = ?", p.Code).Count(&count) - if count > 0 { - web.Fail(c, web.NewConflictError("产品编号已存在")) - return - } if p.Status == "" { p.Status = "active" } - if err := store.DB.Create(&p).Error; err != nil { + if productRepo.CountByCode(p.Code, nil) > 0 { + web.Fail(c, web.NewConflictError("产品编号已存在")) + return + } + if !productRepo.Insert(&p) { web.Fail(c, web.NewBadRequest("创建产品失败")) return } @@ -76,8 +68,8 @@ func UpdateProduct(c *gin.Context) { if !ok { return } - var p model.Product - if err := store.DB.First(&p, id).Error; err != nil { + p, found := productRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("产品不存在")) return } @@ -95,9 +87,7 @@ func UpdateProduct(c *gin.Context) { } if req.Code != "" && req.Code != p.Code { - var count int64 - store.DB.Model(&model.Product{}).Where("code = ? AND id <> ?", req.Code, id).Count(&count) - if count > 0 { + if productRepo.CountByCode(req.Code, &id) > 0 { web.Fail(c, web.NewConflictError("产品编号已存在")) return } @@ -115,7 +105,7 @@ func UpdateProduct(c *gin.Context) { p.ReportRules = req.ReportRules p.Status = req.Status - if err := store.DB.Save(&p).Error; err != nil { + if !productRepo.Update(&p) { web.Fail(c, web.NewBadRequest("更新产品失败")) return } @@ -128,12 +118,7 @@ func DeleteProduct(c *gin.Context) { if !ok { return } - var p model.Product - if err := store.DB.First(&p, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("产品不存在")) - return - } - if err := store.DB.Model(&p).Update("status", "inactive").Error; err != nil { + if !productRepo.Delete(id) { web.Fail(c, web.NewBadRequest("停用产品失败")) return } @@ -159,10 +144,10 @@ func ImportProducts(c *gin.Context) { if it.Status == "" { it.Status = "active" } - var existing model.Product - err := store.DB.Where("code = ?", it.Code).First(&existing).Error - if err != nil { - if store.DB.Create(&it).Error == nil { + // 检查是否存在 + existing, found := productRepo.GetByCode(it.Code) + if !found { + if productRepo.Insert(&it) { created++ } continue @@ -199,7 +184,7 @@ func ImportProducts(c *gin.Context) { existing.ReportRules = it.ReportRules } existing.Status = it.Status - if store.DB.Save(&existing).Error == nil { + if productRepo.Update(&existing) { updated++ } } diff --git a/eai_agentplatform/backend-go/internal/api/profile.go b/eai_agentplatform/backend-go/internal/api/profile.go index 0cd8826..1579e78 100644 --- a/eai_agentplatform/backend-go/internal/api/profile.go +++ b/eai_agentplatform/backend-go/internal/api/profile.go @@ -54,9 +54,8 @@ func MyProfile(c *gin.Context) { } // 错题统计 - var mistakeTotal, mistakeResolved int64 - store.DB.Model(&model.MistakeRecord{}).Where("user_id = ?", u.ID).Count(&mistakeTotal) - store.DB.Model(&model.MistakeRecord{}).Where("user_id = ? AND resolved = ?", u.ID, true).Count(&mistakeResolved) + mistakeTotal := mistakeRepo.CountByUser(u.ID) + mistakeResolved := mistakeRepo.CountResolvedByUser(u.ID) // 自测次数(自测不落 exam_record,改由积分流水统计) var selfTestCount int64 diff --git a/eai_agentplatform/backend-go/internal/api/project.go b/eai_agentplatform/backend-go/internal/api/project.go index 9d503bc..c4a139d 100644 --- a/eai_agentplatform/backend-go/internal/api/project.go +++ b/eai_agentplatform/backend-go/internal/api/project.go @@ -7,10 +7,11 @@ import ( "strings" "github.com/gin-gonic/gin" - "gorm.io/gorm" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -27,22 +28,12 @@ type projectReq struct { // Pinned 同理(分不出「没传」和「传了 false」,取消置顶永远取消不掉)。 Instruction *string `json:"instruction"` SpecialistKeys []string `json:"specialist_keys"` - SkillKeys []string `json:"skill_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 字符串。空列表存空串而不是 "[]", // 读的时候一眼能看出「没配」和「配了但为空」的区别不大,但空串更省。 @@ -72,7 +63,7 @@ func ListProjects(c *gin.Context) { } var items []model.Project - if err := projectQuery(user). + if err := specialistruntime.ProjectQuery(user). Order("pinned DESC, updated_at DESC, id DESC"). Limit(50). Find(&items).Error; err != nil { @@ -123,7 +114,7 @@ func CreateProject(c *gin.Context) { // 归属由服务端定死,不读请求里的 owner —— 否则谁都能替别人建项目。 Owner: myTaskOwnerName(user), SpecialistKeys: encodeKeys(req.SpecialistKeys), - SkillKeys: encodeKeys(req.SkillKeys), + SkillKeys: encodeKeys(req.SkillKeys), ConnectorKeys: encodeKeys(req.ConnectorKeys), } if err := store.DB.Create(&project).Error; err != nil { @@ -147,7 +138,7 @@ func UpdateProject(c *gin.Context) { } var project model.Project - if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { + if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { web.Fail(c, web.NewNotFoundError("项目不存在")) return } @@ -174,7 +165,7 @@ func UpdateProject(c *gin.Context) { if req.Pinned != nil { project.Pinned = *req.Pinned } - // 三个能力列表:给了就用给的(空数组也算给了,表示清空), + // 三个对象列表:给了就用给的(空数组也算给了,表示清空), // 没给就保持原样。用 nil 判断,跟 Pinned 是同一套规矩。 if req.SpecialistKeys != nil { if err := validateSpecialistKeys(req.SpecialistKeys); err != nil { @@ -183,8 +174,8 @@ func UpdateProject(c *gin.Context) { } project.SpecialistKeys = encodeKeys(req.SpecialistKeys) } - if req.SkillKeys != nil { - project.SkillKeys = encodeKeys(req.SkillKeys) + if req.SkillKeys != nil { + project.SkillKeys = encodeKeys(req.SkillKeys) } if req.ConnectorKeys != nil { project.ConnectorKeys = encodeKeys(req.ConnectorKeys) @@ -214,11 +205,11 @@ func DeleteProject(c *gin.Context) { } var project model.Project - if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { + if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { web.Fail(c, web.NewNotFoundError("项目不存在")) return } - if err := store.DB.Model(&model.WorkerTask{}). + if err := store.DB.Model(&model.TaskRecord{}). Where("project_id = ?", project.ID). Update("project_id", nil).Error; err != nil { web.Fail(c, web.NewBadRequest("解除任务归属失败")) @@ -245,12 +236,12 @@ func ListProjectTasks(c *gin.Context) { } var project model.Project - if err := projectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { + if err := specialistruntime.ProjectQuery(user).Where("id = ?", id).First(&project).Error; err != nil { web.Fail(c, web.NewNotFoundError("项目不存在")) return } - var items []model.WorkerTask + var items []model.TaskRecord if err := store.DB.Where("project_id = ?", project.ID). Order("updated_at DESC, id DESC"). Limit(100). @@ -271,7 +262,7 @@ func validateSpecialistKeys(keys []string) error { if trimmed == "" { continue } - var specialist model.Specialist + var specialist specialistmodel.Specialist if err := store.DB.Where("key = ?", trimmed).First(&specialist).Error; err != nil { return errors.New("专员不存在:" + trimmed) } diff --git a/eai_agentplatform/backend-go/internal/api/report_pdf.go b/eai_agentplatform/backend-go/internal/api/report_pdf.go deleted file mode 100644 index 21971a6..0000000 --- a/eai_agentplatform/backend-go/internal/api/report_pdf.go +++ /dev/null @@ -1,96 +0,0 @@ -package api - -import ( - "bytes" - "os" - "strings" - - "github.com/jung-kurt/gofpdf/v2" -) - -// ExportReportPDF 导出报告为 PDF 字节 -func ExportReportPDF(report reportContent) ([]byte, error) { - pdf := gofpdf.New("P", "mm", "A4", "") - - fontLoaded := false - for _, candidate := range []string{ - "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", - "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", - "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", - } { - if _, err := os.Stat(candidate); err == nil { - pdf.AddUTF8Font("czk", "", candidate) - fontLoaded = true - break - } - } - - pdf.AddPage() - - // 标题 - if fontLoaded { - pdf.SetFont("czk", "B", 20) - } else { - pdf.SetFont("Helvetica", "B", 20) - } - pdf.CellFormat(170, 12, report.Topic, "", 1, "C", false, 0, "") - pdf.Ln(8) - - // 摘要 - if strings.TrimSpace(report.Summary) != "" { - if fontLoaded { - pdf.SetFont("czk", "", 10) - } else { - pdf.SetFont("Helvetica", "", 10) - } - pdf.MultiCell(170, 5, report.Summary, "", "L", false) - pdf.Ln(6) - } - - // 目录 - if fontLoaded { - pdf.SetFont("czk", "B", 14) - } else { - pdf.SetFont("Helvetica", "B", 14) - } - pdf.Cell(0, 8, "目录") - pdf.Ln(4) - for _, ch := range report.Chapters { - if fontLoaded { - pdf.SetFont("czk", "", 10) - } else { - pdf.SetFont("Helvetica", "", 10) - } - pdf.Cell(0, 6, " " + ch.Title) - pdf.Ln(5) - } - pdf.Ln(6) - - // 章节 - for _, ch := range report.Chapters { - pdf.AddPage() - - if fontLoaded { - pdf.SetFont("czk", "B", 14) - } else { - pdf.SetFont("Helvetica", "B", 14) - } - pdf.Cell(0, 8, ch.Title) - pdf.Ln(2) - - if fontLoaded { - pdf.SetFont("czk", "", 10) - } else { - pdf.SetFont("Helvetica", "", 10) - } - pdf.MultiCell(170, 5, ch.Body, "", "L", false) - pdf.Ln(6) - } - - var buf bytes.Buffer - if err := pdf.Output(&buf); err != nil { - return nil, err - } - return buf.Bytes(), nil -} diff --git a/eai_agentplatform/backend-go/internal/api/router.go b/eai_agentplatform/backend-go/internal/api/router.go index e9e7e5a..bad7582 100644 --- a/eai_agentplatform/backend-go/internal/api/router.go +++ b/eai_agentplatform/backend-go/internal/api/router.go @@ -7,7 +7,13 @@ import ( "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/config" + connectorapi "eai_agentplatform/backend/internal/connectors/api" "eai_agentplatform/backend/internal/middleware" + skillapi "eai_agentplatform/backend/internal/skills/api" + specialistapi "eai_agentplatform/backend/internal/specialists/api" + "eai_agentplatform/backend/internal/specialists/runtime" + wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api" + xappapi "eai_agentplatform/backend/internal/xapps/api" ) // Cfg 全局配置,RegisterRoutes 时注入 @@ -16,6 +22,8 @@ var Cfg *config.Config // RegisterRoutes 注册所有路由 func RegisterRoutes(r *gin.Engine, cfg *config.Config) { Cfg = cfg + specialistruntime.Cfg = cfg + connectorapi.UseConfig(cfg) // 素材静态预览(只读):仅公开 approved/ 子目录(已审批通过文件), // pending/ 未审批文件不对外暴露,落实「审批前置」。 @@ -40,41 +48,41 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { // 员工可读 r.GET("/api/company-train", middleware.Auth(cfg), GetCompanyTrain) r.POST("/api/company-train/suggest-material", middleware.Auth(cfg), SuggestMaterial) - 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/apps", middleware.Auth(cfg), ListAppDefinitions) - r.GET("/api/apps/by-key/:key", middleware.Auth(cfg), GetAppDefinitionByKey) + r.GET("/api/specialists", middleware.Auth(cfg), specialistapi.ListSpecialists) + r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), specialistapi.GetSpecialistByKey) + r.GET("/api/specialists/summary", middleware.Auth(cfg), specialistapi.SpecialistSummary) + r.GET("/api/skills", middleware.Auth(cfg), skillapi.ListSkillDefinitions) + r.GET("/api/skills/by-key/:key", middleware.Auth(cfg), skillapi.GetSkillDefinitionByKey) + r.GET("/api/xapps", middleware.Auth(cfg), xappapi.ListXAppDefinitions) + r.GET("/api/xapps/by-key/:key", middleware.Auth(cfg), xappapi.GetXAppDefinitionByKey) 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) + r.GET("/api/tasks", middleware.Auth(cfg), ListTasks) + r.GET("/api/tasks/:id", middleware.Auth(cfg), GetTaskDetail) // 员工自己的工作区:「新建任务」和侧边栏的任务列表走这三个。 // 归属由服务端按登录用户定,请求里的 owner 一律不读。 - r.GET("/api/worker/my-tasks", middleware.Auth(cfg), ListMyTasks) - 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/my/tasks", middleware.Auth(cfg), ListMyTasks) + r.POST("/api/my/tasks", middleware.Auth(cfg), CreateMyTask) + r.PUT("/api/my/tasks/:id", middleware.Auth(cfg), UpdateMyTask) + r.DELETE("/api/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.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) + r.GET("/api/projects", middleware.Auth(cfg), ListProjects) + r.POST("/api/projects", middleware.Auth(cfg), CreateProject) + r.PUT("/api/projects/:id", middleware.Auth(cfg), UpdateProject) + r.DELETE("/api/projects/:id", middleware.Auth(cfg), DeleteProject) + r.GET("/api/projects/:id/tasks", middleware.Auth(cfg), ListProjectTasks) + r.GET("/api/artifacts/:id", middleware.Auth(cfg), GetArtifactDetail) + r.POST("/api/official-account/tasks", middleware.Auth(cfg), wechatofficialaccountapi.CreateOfficialAccountTask) + r.GET("/api/official-account/tasks/:id/workflow", middleware.Auth(cfg), wechatofficialaccountapi.GetOfficialAccountWorkflow) + r.PUT("/api/official-account/tasks/:id", middleware.Auth(cfg), wechatofficialaccountapi.UpdateOfficialAccountTask) + r.POST("/api/official-account/tasks/:id/steps/:stepKey", middleware.Auth(cfg), wechatofficialaccountapi.ExecuteOfficialAccountWorkflowStep) + r.POST("/api/official-account/tasks/:id/images/:imageKey/regenerate", middleware.Auth(cfg), wechatofficialaccountapi.RegenerateOfficialAccountImage) + r.GET("/api/official-account/tasks/:id/export", middleware.Auth(cfg), wechatofficialaccountapi.ExportOfficialAccountDocument) + r.GET("/api/official-account/generated-images/:filename", middleware.Auth(cfg), wechatofficialaccountapi.ServeOfficialAccountGeneratedImage) + r.GET("/api/connectors", middleware.Auth(cfg), connectorapi.ListConnectors) + r.GET("/api/connectors/:key", middleware.Auth(cfg), connectorapi.GetConnector) + r.POST("/api/connectors/:key/query", middleware.Auth(cfg), connectorapi.QueryConnector) r.GET("/api/products", middleware.Auth(cfg), ListProducts) r.GET("/api/products/:id", middleware.Auth(cfg), GetProduct) r.GET("/api/courses", middleware.Auth(cfg), ListCourses) @@ -86,7 +94,7 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { r.POST("/api/exam/start", middleware.Auth(cfg), ExamStart) r.POST("/api/exam/submit", middleware.Auth(cfg), ExamSubmit) r.GET("/api/exam/record", middleware.Auth(cfg), ExamRecordList) - r.GET("/api/exam/record/:recordId", middleware.Auth(cfg), ExamRecordDetail) + r.GET("/api/exam/record/:record_id", middleware.Auth(cfg), ExamRecordDetail) r.GET("/api/exam/mistakes", middleware.Auth(cfg), MyMistakes) r.PUT("/api/exam/mistakes/:id/resolve", middleware.Auth(cfg), ResolveMistake) r.POST("/api/exam/mistakes/practice", middleware.Auth(cfg), MistakePractice) @@ -96,34 +104,27 @@ 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/my/xapp-center", middleware.Auth(cfg), xappapi.GetMyXAppCenter) + r.PUT("/api/my/xapp-center", middleware.Auth(cfg), xappapi.UpdateMyXAppCenter) r.GET("/api/points/me", middleware.Auth(cfg), MyPoints) r.GET("/api/points/leaderboard", middleware.Auth(cfg), Leaderboard) - // 站内消息通知 - r.GET("/api/notifications", middleware.Auth(cfg), MyNotifications) - r.GET("/api/notifications/unread-count", middleware.Auth(cfg), UnreadNotificationCount) - r.PUT("/api/notifications/:id/read", middleware.Auth(cfg), MarkNotificationRead) - r.PUT("/api/notifications/read-all", middleware.Auth(cfg), MarkAllNotificationsRead) - // 素材媒体(员工/管理员均可上传、预览) r.POST("/api/media/upload", middleware.Auth(cfg), Upload) r.POST("/api/media/upload-init", middleware.Auth(cfg), UploadInit) r.POST("/api/media/upload-chunk", middleware.Auth(cfg), UploadChunk) r.POST("/api/media/upload-complete", middleware.Auth(cfg), UploadComplete) - r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview) - r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus) + r.GET("/api/media/preview/:media_id", middleware.Auth(cfg), Preview) + r.GET("/api/media/status/:media_id", middleware.Auth(cfg), MediaStatus) r.GET("/api/knowledge/spaces", middleware.Auth(cfg), ListKnowledgeSpaces) r.GET("/api/knowledge/search", middleware.Auth(cfg), SearchKnowledge) - r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus) + r.GET("/api/knowledge/status/:source_id", middleware.Auth(cfg), KnowledgeStatus) // ── AI 对话(普通员工可访问,管理员访问)── r.POST("/api/ai-chat/message", middleware.Auth(cfg), ChatMessage) r.GET("/api/ai-chat/quick-actions", middleware.Auth(cfg), QuickActions) r.POST("/api/ai-chat/quick-action", middleware.Auth(cfg), QuickAction) - r.GET("/api/ai/me", middleware.Auth(cfg), AIUsageMe) r.GET("/api/ai/usage", middleware.Auth(cfg), AIUsage) r.GET("/api/ai/routes/chat", ListChatRoutes) r.GET("/api/ai/routes/embed", ListEmbedRoutes) @@ -139,12 +140,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote) // ── 报告生成(普通员工可访问)── - r.POST("/api/report/generate", middleware.Auth(cfg), ReportGenerate) - r.POST("/api/report/export/pdf", middleware.Auth(cfg), ReportExportPDF) - r.POST("/api/report/export/docx", middleware.Auth(cfg), ReportExportDOCX) - r.POST("/api/report/export/pptx", middleware.Auth(cfg), ReportExportPPTX) - r.POST("/api/report/export/xlsx", middleware.Auth(cfg), ReportExportXLSX) - r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview) + r.POST("/api/report/generate", middleware.Auth(cfg), skillapi.ReportGenerate) + r.POST("/api/report/export/pdf", middleware.Auth(cfg), skillapi.ReportExportPDF) + r.POST("/api/report/export/docx", middleware.Auth(cfg), skillapi.ReportExportDOCX) + r.POST("/api/report/export/pptx", middleware.Auth(cfg), skillapi.ReportExportPPTX) + r.POST("/api/report/export/xlsx", middleware.Auth(cfg), skillapi.ReportExportXLSX) + r.POST("/api/report/preview", middleware.Auth(cfg), skillapi.ReportPreview) r.POST("/api/contract/review", middleware.Auth(cfg), ReviewContract) // 文档工具 @@ -153,9 +154,9 @@ 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) + r.POST("/api/skills/office/execute", middleware.Auth(cfg), skillapi.ExecuteOfficeSkill) + r.POST("/api/skills/office/ocr", middleware.Auth(cfg), skillapi.ExecuteOfficeSkillOCR) + r.POST("/api/chat/message", middleware.Auth(cfg), HandleChatMessage) // 语音工具 r.POST("/api/audio/transcribe", middleware.Auth(cfg), TranscribeAudio) @@ -182,7 +183,7 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { admin.DELETE("/exam/papers/:id", DeletePaper) admin.GET("/media/audit-list", AuditList) - admin.POST("/media/audit/:mediaId", AuditMedia) + admin.POST("/media/audit/:media_id", AuditMedia) admin.POST("/knowledge/scan", KnowledgeScan) admin.POST("/knowledge/index/rebuild", RebuildKnowledgeIndex) @@ -194,31 +195,31 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { admin.PUT("/knowledge/faqs/:id", UpdateKnowledgeFAQ) admin.DELETE("/knowledge/faqs/:id", DeleteKnowledgeFAQ) admin.GET("/knowledge/audit-list", KnowledgeAuditList) - admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit) + admin.POST("/knowledge/audit/:source_id", KnowledgeAudit) admin.GET("/knowledge/export", ExportKnowledge) admin.GET("/positions", ListPositions) admin.POST("/positions", CreatePosition) admin.PUT("/positions/:id", UpdatePosition) admin.DELETE("/positions/:id", DeletePosition) - 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("/apps", CreateAppDefinition) - admin.PUT("/apps/:id", UpdateAppDefinition) - admin.DELETE("/apps/:id", DeleteAppDefinition) + admin.POST("/specialists", specialistapi.CreateSpecialist) + admin.PUT("/specialists/:id", specialistapi.UpdateSpecialist) + admin.DELETE("/specialists/:id", specialistapi.DeleteSpecialist) + admin.POST("/skills", skillapi.CreateSkillDefinition) + admin.PUT("/skills/:id", skillapi.UpdateSkillDefinition) + admin.DELETE("/skills/:id", skillapi.DeleteSkillDefinition) + admin.POST("/xapps", xappapi.CreateXAppDefinition) + admin.PUT("/xapps/:id", xappapi.UpdateXAppDefinition) + admin.DELETE("/xapps/:id", xappapi.DeleteXAppDefinition) 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) - admin.DELETE("/worker/tasks/:id", DeleteWorkerTask) - admin.POST("/worker/tasks/:id/actions", ExecuteWorkerTaskAction) - admin.PUT("/worker/artifacts/:id/status", UpdateWorkerArtifactStatus) + admin.POST("/tasks", CreateTask) + admin.PUT("/tasks/:id", UpdateTask) + admin.PUT("/tasks/:id/status", UpdateTaskStatus) + admin.DELETE("/tasks/:id", DeleteTask) + admin.POST("/tasks/:id/actions", ExecuteTaskAction) + admin.PUT("/artifacts/:id/status", UpdateArtifactStatus) admin.GET("/positions/:id/knowledge", ListPositionKnowledge) admin.PUT("/positions/:id/knowledge", SavePositionKnowledge) admin.GET("/positions/:id/blueprint", ListPositionBlueprint) diff --git a/eai_agentplatform/backend-go/internal/api/skill_action_definition.go b/eai_agentplatform/backend-go/internal/api/skill_action_definition.go deleted file mode 100644 index 65ccd26..0000000 --- a/eai_agentplatform/backend-go/internal/api/skill_action_definition.go +++ /dev/null @@ -1,409 +0,0 @@ -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"` - ObjectKind string `json:"object_kind"` - Source string `json:"source"` - ObjectEntryRoute string `json:"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.ObjectKind = strings.TrimSpace(req.ObjectKind) - req.Source = strings.TrimSpace(req.Source) - req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute) - 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.ObjectKind == "" { - req.ObjectKind = "skill" - } - switch req.ObjectKind { - case "assistant", "specialist", "skill": - default: - return web.NewBadRequest("object_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, - ObjectKind: req.ObjectKind, - Source: req.Source, - ObjectEntryRoute: req.ObjectEntryRoute, - 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.ObjectKind = req.ObjectKind - item.Source = req.Source - item.ObjectEntryRoute = req.ObjectEntryRoute - 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/specialist.go b/eai_agentplatform/backend-go/internal/api/specialist.go deleted file mode 100644 index 168bd41..0000000 --- a/eai_agentplatform/backend-go/internal/api/specialist.go +++ /dev/null @@ -1,397 +0,0 @@ -package api - -import ( - "strings" - - "github.com/gin-gonic/gin" - - "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" - "eai_agentplatform/backend/internal/web" -) - -// ListSpecialists GET /api/specialists?tier=&state=&market_tag= -func ListSpecialists(c *gin.Context) { - q := store.DB.Model(&model.Specialist{}) - if tier := c.Query("tier"); tier != "" { - q = q.Where("tier = ?", tier) - } - if marketTag := c.Query("market_tag"); marketTag != "" { - q = q.Where("market_tag = ?", marketTag) - } - user := middleware.CurrentUser(c) - isAdmin := user != nil && user.Role == "admin" - switch state := c.Query("state"); state { - case "": - q = q.Where("state = ?", "active") - case "all": - if !isAdmin { - q = q.Where("state = ?", "active") - } - case "system": - q = q.Where("state = ?", "system") - default: - q = q.Where("state = ?", state) - } - // state = system 的内置记录(通用助手)是任务的默认归属,不是可安装的专员, - // 任何常规列表都不该带出它 —— 只有显式 state=system 才查得到。 - if c.Query("state") != "system" { - q = q.Where("state <> ?", "system") - } - - var items []model.Specialist - if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询专员目录失败")) - return - } - for i := range items { - store.EnsureSpecialistStructuredRecords(&items[i]) - } - web.OK(c, items) -} - -// GetSpecialistByKey GET /api/specialists/by-key/:key -func GetSpecialistByKey(c *gin.Context) { - key := strings.TrimSpace(c.Param("key")) - if key == "" { - web.Fail(c, web.NewBadRequest("专员 key 不能为空")) - return - } - - q := store.DB.Model(&model.Specialist{}).Where("key = ?", key) - user := middleware.CurrentUser(c) - isAdmin := user != nil && user.Role == "admin" - if !isAdmin { - q = q.Where("state = ?", "active") - } - - var item model.Specialist - if err := q.First(&item).Error; err != nil { - web.Fail(c, web.NewNotFoundError("专员不存在")) - return - } - store.EnsureSpecialistStructuredRecords(&item) - web.OK(c, item) -} - -// SpecialistSummary GET /api/specialists/summary -func SpecialistSummary(c *gin.Context) { - type summary struct { - Total int64 `json:"total"` - Generic int64 `json:"generic"` - Industry int64 `json:"industry"` - DW int64 `json:"dw"` - ADW int64 `json:"adw"` - Upgradeable int64 `json:"upgradeable"` - Trial int64 `json:"trial"` - } - - var resp summary - count := func(column string, value string) int64 { - var total int64 - q := store.DB.Model(&model.Specialist{}).Where("state = ?", "active") - if column != "" { - q = q.Where(column+" = ?", value) - } - q.Count(&total) - return total - } - - resp.Total = count("", "") - resp.Generic = count("tier", "generic") - resp.Industry = count("tier", "industry") - resp.DW = count("worker_type", "dw") - resp.ADW = count("worker_type", "adw") - resp.Upgradeable = count("market_tag", "可升级") - resp.Trial = count("market_tag", "试用") - - web.OK(c, resp) -} - -type specialistReq struct { - Key string `json:"key"` - Label string `json:"label"` - DisplayCode string `json:"display_code"` - EAILogicCode string `json:"eailogic_code"` - Tier string `json:"tier"` - WorkerType string `json:"worker_type"` - ObjectEntryRoute string `json:"object_entry_route"` - Summary string `json:"summary"` - InteractionCardJSON string `json:"interaction_card_json"` - WorkStatus string `json:"work_status"` - RiskLabel string `json:"risk_label"` - Color string `json:"color"` - Stage string `json:"stage"` - Progress int `json:"progress"` - MarketTag string `json:"market_tag"` - Version string `json:"version"` - ConnectorScope string `json:"connector_scope"` - PermissionScope string `json:"permission_scope"` - ResourceBindings string `json:"resource_bindings"` - InfoSources string `json:"info_sources"` - BaseSkills string `json:"base_skills"` - AIAssistance string `json:"ai_assistance"` - GeneratedSkills string `json:"generated_skills"` - // RuleFileMarkdown 岗位说明书正文(Markdown),会话创建时注入 System Prompt - RuleFileMarkdown string `json:"rule_file_markdown"` - // AllowedSkills 绑定技能 key 的 JSON 数组字符串,顺序即优先级(首个为主技能)。 - // 元素须在 model.ValidSkillKeys 内;写入前会被规范化成紧凑 JSON。 - AllowedSkills string `json:"allowed_skills"` - InputsRecordsJSON string `json:"inputs_records_json"` - PermissionRecordsJSON string `json:"permission_records_json"` - ActionRecordsJSON string `json:"action_records_json"` - ResultRecordsJSON string `json:"result_records_json"` - State string `json:"state"` - SortOrder int `json:"sort_order"` -} - -func normalizeSpecialistReq(req *specialistReq) { - req.Key = strings.TrimSpace(req.Key) - req.Label = strings.TrimSpace(req.Label) - req.DisplayCode = strings.TrimSpace(req.DisplayCode) - req.EAILogicCode = strings.TrimSpace(req.EAILogicCode) - req.Tier = strings.TrimSpace(req.Tier) - req.WorkerType = strings.TrimSpace(req.WorkerType) - req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute) - req.Summary = strings.TrimSpace(req.Summary) - req.InteractionCardJSON = strings.TrimSpace(req.InteractionCardJSON) - req.WorkStatus = strings.TrimSpace(req.WorkStatus) - req.RiskLabel = strings.TrimSpace(req.RiskLabel) - req.Color = strings.TrimSpace(req.Color) - req.Stage = strings.TrimSpace(req.Stage) - req.MarketTag = strings.TrimSpace(req.MarketTag) - req.Version = strings.TrimSpace(req.Version) - req.ConnectorScope = strings.TrimSpace(req.ConnectorScope) - req.PermissionScope = strings.TrimSpace(req.PermissionScope) - req.ResourceBindings = strings.TrimSpace(req.ResourceBindings) - req.InfoSources = strings.TrimSpace(req.InfoSources) - req.BaseSkills = strings.TrimSpace(req.BaseSkills) - req.AIAssistance = strings.TrimSpace(req.AIAssistance) - req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills) - req.RuleFileMarkdown = strings.TrimSpace(req.RuleFileMarkdown) - req.AllowedSkills = strings.TrimSpace(req.AllowedSkills) - req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON) - req.PermissionRecordsJSON = strings.TrimSpace(req.PermissionRecordsJSON) - req.ActionRecordsJSON = strings.TrimSpace(req.ActionRecordsJSON) - req.ResultRecordsJSON = strings.TrimSpace(req.ResultRecordsJSON) - req.State = strings.TrimSpace(req.State) -} - -func validateSpecialistReq(req *specialistReq) *web.AppError { - normalizeSpecialistReq(req) - if req.Key == "" || req.Label == "" || req.Tier == "" || req.ObjectEntryRoute == "" { - return web.NewBadRequest("key、label、tier、object_entry_route 为必填") - } - if req.Tier != "generic" && req.Tier != "industry" { - return web.NewBadRequest("tier 只能是 generic 或 industry") - } - if req.WorkerType == "" { - req.WorkerType = "dw" - } - if req.WorkerType != "dw" && req.WorkerType != "adw" { - return web.NewBadRequest("worker_type 只能是 dw 或 adw") - } - if req.State == "" { - req.State = "active" - } - if req.State != "active" && req.State != "inactive" { - return web.NewBadRequest("state 只能是 active 或 inactive") - } - if req.MarketTag == "" { - req.MarketTag = "已安装" - } - if req.Progress < 0 || req.Progress > 100 { - return web.NewBadRequest("progress 必须在 0 到 100 之间") - } - if !store.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) { - return web.NewBadRequest("inputs_records_json 必须是 JSON 数组") - } - if !store.ValidateJSONObjectJSON(req.InteractionCardJSON) { - return web.NewBadRequest("interaction_card_json 必须是 JSON 对象") - } - if !store.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) { - return web.NewBadRequest("permission_records_json 必须是 JSON 数组") - } - if !store.ValidateStructuredRecordsJSON(req.ActionRecordsJSON) { - return web.NewBadRequest("action_records_json 必须是 JSON 数组") - } - if !store.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) { - return web.NewBadRequest("result_records_json 必须是 JSON 数组") - } - // 绑定技能:非法 key / 重复项在录入侧就挡住(见 SY23 §2.4)。 - // 顺带把值规范化成紧凑 JSON,避免 "[ \"a\" , \"b\" ]" 这类写法进库后 - // 让 ParseAllowedSkills 之外的下游(如直接字符串比较)踩坑。 - if normalized, appErr := normalizeAllowedSkills(req.AllowedSkills); appErr != nil { - return appErr - } else { - req.AllowedSkills = normalized - } - return nil -} - -// normalizeAllowedSkills 校验绑定技能列表并回写规范化后的 JSON 字符串。 -// 空值合法(表示未绑定技能),统一存空串而不是 "[]"。 -func normalizeAllowedSkills(raw string) (string, *web.AppError) { - keys := model.ParseAllowedSkills(raw) - // ParseAllowedSkills 对非法 JSON 返回 nil,这里要与「空数组」区分开: - // 传了内容但解析不出来,说明格式错了,应报错而不是静默丢成未绑定。 - if strings.TrimSpace(raw) != "" && keys == nil { - return "", web.NewBadRequest(`allowed_skills 必须是 JSON 字符串数组,例如 ["contract-review","batch-extract"]`) - } - if err := model.ValidateAllowedSkills(keys); err != nil { - return "", web.NewBadRequest(err.Error()) - } - normalized, err := model.MarshalAllowedSkills(keys) - if err != nil { - return "", web.NewBadRequest("allowed_skills 序列化失败") - } - return normalized, nil -} - -// CreateSpecialist POST /api/specialists (admin) -func CreateSpecialist(c *gin.Context) { - var req specialistReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - if appErr := validateSpecialistReq(&req); appErr != nil { - web.Fail(c, appErr) - return - } - - var count int64 - store.DB.Model(&model.Specialist{}).Where("key = ?", req.Key).Count(&count) - if count > 0 { - web.Fail(c, web.NewConflictError("专员 key 已存在")) - return - } - - item := model.Specialist{ - Key: req.Key, - Label: req.Label, - DisplayCode: req.DisplayCode, - EAILogicCode: req.EAILogicCode, - Tier: req.Tier, - WorkerType: req.WorkerType, - ObjectEntryRoute: req.ObjectEntryRoute, - Summary: req.Summary, - InteractionCardJSON: req.InteractionCardJSON, - WorkStatus: req.WorkStatus, - RiskLabel: req.RiskLabel, - Color: req.Color, - Stage: req.Stage, - Progress: req.Progress, - MarketTag: req.MarketTag, - Version: req.Version, - ConnectorScope: req.ConnectorScope, - PermissionScope: req.PermissionScope, - ResourceBindings: req.ResourceBindings, - InfoSources: req.InfoSources, - BaseSkills: req.BaseSkills, - AIAssistance: req.AIAssistance, - GeneratedSkills: req.GeneratedSkills, - RuleFileMarkdown: req.RuleFileMarkdown, - AllowedSkills: req.AllowedSkills, - InputsRecordsJSON: req.InputsRecordsJSON, - PermissionRecordsJSON: req.PermissionRecordsJSON, - ActionRecordsJSON: req.ActionRecordsJSON, - ResultRecordsJSON: req.ResultRecordsJSON, - State: req.State, - SortOrder: req.SortOrder, - } - store.EnsureSpecialistStructuredRecords(&item) - if err := store.DB.Create(&item).Error; err != nil { - web.Fail(c, web.NewBadRequest("创建专员失败")) - return - } - web.OK(c, item) -} - -// UpdateSpecialist PUT /api/specialists/:id (admin) -func UpdateSpecialist(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - var item model.Specialist - if err := store.DB.First(&item, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("专员不存在")) - return - } - - var req specialistReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - if appErr := validateSpecialistReq(&req); appErr != nil { - web.Fail(c, appErr) - return - } - - var count int64 - store.DB.Model(&model.Specialist{}).Where("key = ? AND id <> ?", req.Key, id).Count(&count) - if count > 0 { - web.Fail(c, web.NewConflictError("专员 key 已存在")) - return - } - - item.Key = req.Key - item.Label = req.Label - item.DisplayCode = req.DisplayCode - item.EAILogicCode = req.EAILogicCode - item.Tier = req.Tier - item.WorkerType = req.WorkerType - item.ObjectEntryRoute = req.ObjectEntryRoute - item.Summary = req.Summary - item.InteractionCardJSON = req.InteractionCardJSON - item.WorkStatus = req.WorkStatus - item.RiskLabel = req.RiskLabel - item.Color = req.Color - item.Stage = req.Stage - item.Progress = req.Progress - item.MarketTag = req.MarketTag - item.Version = req.Version - item.ConnectorScope = req.ConnectorScope - item.PermissionScope = req.PermissionScope - item.ResourceBindings = req.ResourceBindings - item.InfoSources = req.InfoSources - item.BaseSkills = req.BaseSkills - item.AIAssistance = req.AIAssistance - item.GeneratedSkills = req.GeneratedSkills - item.RuleFileMarkdown = req.RuleFileMarkdown - item.AllowedSkills = req.AllowedSkills - item.InputsRecordsJSON = req.InputsRecordsJSON - item.PermissionRecordsJSON = req.PermissionRecordsJSON - item.ActionRecordsJSON = req.ActionRecordsJSON - item.ResultRecordsJSON = req.ResultRecordsJSON - item.State = req.State - item.SortOrder = req.SortOrder - store.EnsureSpecialistStructuredRecords(&item) - - if err := store.DB.Save(&item).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新专员失败")) - return - } - web.OK(c, item) -} - -// DeleteSpecialist DELETE /api/specialists/:id (admin) -func DeleteSpecialist(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - var item model.Specialist - 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}) -} diff --git a/eai_agentplatform/backend-go/internal/api/specialist_prompt.go b/eai_agentplatform/backend-go/internal/api/specialist_prompt.go index 940b870..0bfa17c 100644 --- a/eai_agentplatform/backend-go/internal/api/specialist_prompt.go +++ b/eai_agentplatform/backend-go/internal/api/specialist_prompt.go @@ -5,12 +5,14 @@ import ( "strings" "eai_agentplatform/backend/internal/model" + skillcore "eai_agentplatform/backend/internal/skills/core" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" "eai_agentplatform/backend/internal/store" ) // 本文件回答一个问题:一次对话/一次动作,到底该以哪个专员的身份说话。 // -// 在此之前,工作台对话走的是 smart_assistant.go 里两条写死的 prompt, +// 在此之前,工作台对话走的是 chat_message.go 里两条写死的 prompt, // 专员表根本不参与——所以选谁都是同一个声音。这里把「专员 → 岗位说明书 → 技能」 // 接进 system prompt,让专员真正决定怎么说话、怎么推进。 @@ -18,12 +20,12 @@ import ( // // 刻意不报错:专员查不到(key 写错、被下线、删了)应当退化成通用助手, // 而不是把用户的对话打断成 500。 -func loadSpecialistByKey(key string) *model.Specialist { +func loadSpecialistByKey(key string) *specialistmodel.Specialist { key = strings.TrimSpace(key) if key == "" { return nil } - var s model.Specialist + var s specialistmodel.Specialist if err := store.DB.Where("key = ?", key).First(&s).Error; err != nil { return nil } @@ -42,11 +44,11 @@ func loadSpecialistByKey(key string) *model.Specialist { // 2. 请求体里的 specialist_key —— 还没有任务时(新建会话早期)用这个。 // // 都取不到返回 nil,调用方退回通用助手。 -func resolveSpecialist(req SmartAssistantRequest) *model.Specialist { +func resolveSpecialist(req ChatMessageRequest) *specialistmodel.Specialist { if req.TaskID > 0 { - // 与 worker_task.go 其余读路径一致,不按 created_by 收窄: + // 与 task_runtime.go 其余读路径一致,不按 created_by 收窄: // 专员目录本身就是所有登录用户可读的,这里不构成新的信息暴露。 - var task model.WorkerTask + var task model.TaskRecord if err := store.DB.First(&task, req.TaskID).Error; err == nil { if s := loadSpecialistByKey(task.SpecialistKey); s != nil { return s @@ -65,7 +67,7 @@ func resolveSpecialist(req SmartAssistantRequest) *model.Specialist { // // 专员为 nil 或什么都没配时返回空串,调用方不做任何拼接—— // 这样「没选专员」与「选了但没配说明书」都不改变原有行为。 -func specialistPromptSection(s *model.Specialist) string { +func specialistPromptSection(s *specialistmodel.Specialist) string { if s == nil { return "" } @@ -87,7 +89,7 @@ func specialistPromptSection(s *model.Specialist) string { } // 可用技能 - if skills := model.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 { + if skills := skillcore.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 { b.WriteString("\n【可用技能】\n") b.WriteString(strings.Join(skills, "、")) b.WriteString("\n首个是主技能。只在这些技能范围内承诺能力,范围外的需求如实说做不了。\n") @@ -107,7 +109,7 @@ func specialistPromptSection(s *model.Specialist) string { // // 结构:基础角色说明(随模式变化) + 专员段落。 // 没有专员时结果与改动前完全一致,保证这是纯增量。 -func buildAssistantSystemPrompt(s *model.Specialist, enableThinking bool) string { +func buildAssistantSystemPrompt(s *specialistmodel.Specialist, enableThinking bool) string { var base string if enableThinking { base = "你是一位专家级AI数字员工,使用 thinking 模式。在回答前先进行多步思考,将复杂任务拆解为可执行的步骤,给出深度分析。回答要专业、结构化、有洞察力。" diff --git a/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go b/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go index 1723958..7b02306 100644 --- a/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go +++ b/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go @@ -10,11 +10,12 @@ import ( "gorm.io/gorm/logger" "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" "eai_agentplatform/backend/internal/store" ) -func sampleSpecialist() *model.Specialist { - return &model.Specialist{ +func sampleSpecialist() *specialistmodel.Specialist { + return &specialistmodel.Specialist{ Key: "contract-review", Label: "合同审查专员", Summary: "审合同、找风险、给红线", @@ -35,7 +36,7 @@ func TestSpecialistPromptSectionNil(t *testing.T) { // TestSpecialistPromptSectionEmptySpecialist 专员存在但什么都没配,同样不该产出段落, // 否则 prompt 里会多出一个空壳的【当前专员】把模型带偏。 func TestSpecialistPromptSectionEmptySpecialist(t *testing.T) { - empty := &model.Specialist{Key: "bare", Label: "空壳专员"} + empty := &specialistmodel.Specialist{Key: "bare", Label: "空壳专员"} got := specialistPromptSection(empty) // 身份块本身是有内容的(key + label),所以这里只要求不含技能/说明书两节 if strings.Contains(got, "【可用技能】") { @@ -116,14 +117,14 @@ func setupAPITestDB(t *testing.T) { if err != nil { t.Fatalf("打开测试库失败: %v", err) } - if err := db.AutoMigrate(&model.Specialist{}, &model.WorkerTask{}); err != nil { + if err := db.AutoMigrate(&specialistmodel.Specialist{}, &model.TaskRecord{}); err != nil { t.Fatalf("迁移失败: %v", err) } prev := store.DB store.DB = db t.Cleanup(func() { store.DB = prev }) - for _, s := range []model.Specialist{ + for _, s := range []specialistmodel.Specialist{ {Key: "contract-review", Label: "合同审查专员", State: "active", Tier: "industry", ObjectEntryRoute: "/apps/contract-review"}, {Key: "report-generation", Label: "报告生成专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/report-generation"}, {Key: "retired-one", Label: "已下线专员", State: "inactive", Tier: "generic", ObjectEntryRoute: "/apps/retired"}, @@ -140,12 +141,12 @@ func setupAPITestDB(t *testing.T) { func TestResolveSpecialistFromTask(t *testing.T) { setupAPITestDB(t) - task := model.WorkerTask{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"} + task := model.TaskRecord{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"} if err := store.DB.Create(&task).Error; err != nil { t.Fatalf("建任务失败: %v", err) } - got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID}) + got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID}) if got == nil || got.Key != "contract-review" { t.Fatalf("应从任务解析出 contract-review,实际 %v", got) } @@ -155,12 +156,12 @@ func TestResolveSpecialistFromTask(t *testing.T) { func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) { setupAPITestDB(t) - task := model.WorkerTask{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"} + task := model.TaskRecord{SpecialistKey: "contract-review", Title: "审一份采购合同", Status: "待处理"} if err := store.DB.Create(&task).Error; err != nil { t.Fatalf("建任务失败: %v", err) } - got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID, SpecialistKey: "report-generation"}) + got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "report-generation"}) if got == nil || got.Key != "contract-review" { t.Fatalf("任务上的专员应优先,实际 %v", got) } @@ -170,7 +171,7 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) { func TestResolveSpecialistFallsBackToRequest(t *testing.T) { setupAPITestDB(t) - got := resolveSpecialist(SmartAssistantRequest{SpecialistKey: "report-generation"}) + got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "report-generation"}) if got == nil || got.Key != "report-generation" { t.Fatalf("应回退到请求里的 specialist_key,实际 %v", got) } @@ -183,12 +184,12 @@ func TestResolveSpecialistUnknownKeyIsNotFatal(t *testing.T) { cases := []struct { name string - req SmartAssistantRequest + req ChatMessageRequest }{ - {"不存在的 key", SmartAssistantRequest{SpecialistKey: "no-such-specialist"}}, - {"已下线专员", SmartAssistantRequest{SpecialistKey: "retired-one"}}, - {"任务不存在", SmartAssistantRequest{TaskID: 999999}}, - {"什么都没给", SmartAssistantRequest{}}, + {"不存在的 key", ChatMessageRequest{SpecialistKey: "no-such-specialist"}}, + {"已下线专员", ChatMessageRequest{SpecialistKey: "retired-one"}}, + {"任务不存在", ChatMessageRequest{TaskID: 999999}}, + {"什么都没给", ChatMessageRequest{}}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -204,7 +205,7 @@ func TestResolveSpecialistUnknownKeyIsNotFatal(t *testing.T) { func TestResolveSpecialistAllowsSystemAssistant(t *testing.T) { setupAPITestDB(t) - got := resolveSpecialist(SmartAssistantRequest{SpecialistKey: "general-assistant"}) + got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "general-assistant"}) if got == nil || got.Key != "general-assistant" { t.Fatalf("state=system 的通用助手应可解析,实际 %v", got) } @@ -215,12 +216,12 @@ func TestResolveSpecialistAllowsSystemAssistant(t *testing.T) { func TestResolveSpecialistTaskWithoutSpecialistFallsBack(t *testing.T) { setupAPITestDB(t) - task := model.WorkerTask{SpecialistKey: "no-such-specialist", Title: "孤儿任务", Status: "待处理"} + task := model.TaskRecord{SpecialistKey: "no-such-specialist", Title: "孤儿任务", Status: "待处理"} if err := store.DB.Create(&task).Error; err != nil { t.Fatalf("建任务失败: %v", err) } - got := resolveSpecialist(SmartAssistantRequest{TaskID: task.ID, SpecialistKey: "report-generation"}) + got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "report-generation"}) if got == nil || got.Key != "report-generation" { t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got) } diff --git a/eai_agentplatform/backend-go/internal/api/system.go b/eai_agentplatform/backend-go/internal/api/system.go index 4112790..08b23f2 100644 --- a/eai_agentplatform/backend-go/internal/api/system.go +++ b/eai_agentplatform/backend-go/internal/api/system.go @@ -11,21 +11,32 @@ import ( "eai_agentplatform/backend/internal/auth" "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/repository" "eai_agentplatform/backend/internal/web" ) +var ( + userRepo repository.UserRepo + examRecordRepo repository.ExamRecordRepo + configRepo repository.SystemConfigRepo +) + +func init() { + userRepo = repository.UserRepo{} + examRecordRepo = repository.ExamRecordRepo{} + configRepo = repository.SystemConfigRepo{} +} + // ============ 用户管理(管理员) ============ // ListUsers GET /api/system/users —— 含考试统计(考试次数/通过数/最近成绩) func ListUsers(c *gin.Context) { - var users []model.User - if err := store.DB.Order("id ASC").Find(&users).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询用户失败")) - return - } + var items []model.User + userRepo.Query().Type(&model.User{}).Order("id ASC").Find(&items) + + // 获取考试记录 var recs []model.ExamRecord - store.DB.Order("submitted_at DESC").Find(&recs) + examRecordRepo.Inner().Model(&model.ExamRecord{}).Order("submitted_at DESC").Find(&recs) type stat struct { ExamCount int @@ -55,15 +66,15 @@ func ListUsers(c *gin.Context) { } } - out := make([]gin.H, 0, len(users)) - for _, u := range users { + out := make([]gin.H, 0, len(items)) + for _, u := range items { h := gin.H{ "id": u.ID, "username": u.Username, "full_name": u.FullName, "role": u.Role, "status": u.Status, "ai_points": u.AiPoints, "department": u.Department, "position": u.Position, "hire_batch": u.HireBatch, "position_id": u.PositionID, - "created_at": u.CreatedAt, - "exam_count": 0, "passed_count": 0, + "created_at": u.CreatedAt, + "exam_count": 0, "passed_count": 0, "latest_score": nil, "latest_passed": nil, "latest_exam_name": "", "latest_submitted_at": nil, } if s := stats[u.ID]; s != nil { @@ -97,9 +108,8 @@ func CreateUser(c *gin.Context) { if req.Role != "admin" && req.Role != "employee" { req.Role = "employee" } - var count int64 - store.DB.Model(&model.User{}).Where("username = ?", req.Username).Count(&count) - if count > 0 { + _, found := userRepo.GetByUsername(req.Username) + if found { web.Fail(c, web.NewConflictError("用户名已存在")) return } @@ -116,7 +126,7 @@ func CreateUser(c *gin.Context) { if req.Role == "admin" { u.AiPoints = 999999 } - if err := store.DB.Create(&u).Error; err != nil { + if !userRepo.Insert(&u) { web.Fail(c, web.NewBadRequest("创建用户失败")) return } @@ -129,8 +139,8 @@ func UpdateUser(c *gin.Context) { if !ok { return } - var u model.User - if err := store.DB.First(&u, id).Error; err != nil { + u, found := userRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("用户不存在")) return } @@ -181,7 +191,7 @@ func UpdateUser(c *gin.Context) { } u.AiPoints = *req.AiPoints } - if err := store.DB.Save(&u).Error; err != nil { + if !userRepo.Update(&u) { web.Fail(c, web.NewBadRequest("更新用户失败")) return } @@ -190,11 +200,9 @@ func UpdateUser(c *gin.Context) { // defaultAiPoints 读取新用户默认 AI 算力点(system_config.ai_points_default,缺省 100) func defaultAiPoints() int { - var sc model.SystemConfig - if err := store.DB.Where("config_key = ?", "ai_points_default").First(&sc).Error; err == nil { - if v, err := strconv.Atoi(strings.TrimSpace(sc.ConfigValue)); err == nil { - return v - } + val := configRepo.GetByKey("ai_points_default") + if v, err := strconv.Atoi(strings.TrimSpace(val)); err == nil { + return v } return 100 } @@ -203,29 +211,30 @@ func defaultAiPoints() int { // ListExamRecords GET /api/system/exam-records?user_id=&paper_id= func ListExamRecords(c *gin.Context) { - q := store.DB.Model(&model.ExamRecord{}) - if uid := c.Query("user_id"); uid != "" { - q = q.Where("user_id = ?", uid) - } - if pid := c.Query("paper_id"); pid != "" { - q = q.Where("paper_id = ?", pid) - } - var items []model.ExamRecord - if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询成绩失败")) - return - } + items := examRecordRepo.List(c.Query("user_id"), c.Query("paper_id")) web.OK(c, items) } +// pointerUint 将字符串解析为 *uint;空串返回 nil。 +func pointerUint(s string) *uint { + if s == "" { + return nil + } + if v, err := strconv.ParseUint(s, 10, 32); err == nil { + uv := uint(v) + return &uv + } + return nil +} + // GetExamRecord GET /api/system/exam-records/{id} —— 详情 func GetExamRecord(c *gin.Context) { id, ok := parseID(c, "id") if !ok { return } - var rec model.ExamRecord - if err := store.DB.First(&rec, id).Error; err != nil { + rec, found := examRecordRepo.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("考试记录不存在")) return } @@ -245,12 +254,7 @@ func DeleteExamRecord(c *gin.Context) { if !ok { return } - var rec model.ExamRecord - if err := store.DB.First(&rec, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("考试记录不存在")) - return - } - if err := store.DB.Delete(&rec).Error; err != nil { + if !examRecordRepo.Remove(id) { web.Fail(c, web.NewBadRequest("删除考试记录失败")) return } @@ -259,14 +263,14 @@ func DeleteExamRecord(c *gin.Context) { // ExportExamRecords GET /api/system/exam-records/export —— 导出 CSV(支持 user_id/paper_id 过滤) func ExportExamRecords(c *gin.Context) { - q := store.DB.Model(&model.ExamRecord{}) + var items []model.ExamRecord + q := repository.DB.Model(&model.ExamRecord{}) if uid := c.Query("user_id"); uid != "" { q = q.Where("user_id = ?", uid) } if pid := c.Query("paper_id"); pid != "" { q = q.Where("paper_id = ?", pid) } - var items []model.ExamRecord if err := q.Order("submitted_at DESC").Find(&items).Error; err != nil { web.Fail(c, web.NewBadRequest("查询成绩失败")) return @@ -274,7 +278,7 @@ func ExportExamRecords(c *gin.Context) { // 用户名映射 var users []model.User - store.DB.Find(&users) + repository.DB.Find(&users) nameMap := map[uint]model.User{} for _, u := range users { nameMap[u.ID] = u @@ -312,8 +316,7 @@ func ExportExamRecords(c *gin.Context) { // GetConfig GET /api/system/config —— 所有系统参数 func GetConfig(c *gin.Context) { - var items []model.SystemConfig - store.DB.Order("id ASC").Find(&items) + items := configRepo.List() type cfgItem struct { Key string `json:"config_key"` Value string `json:"config_value"` @@ -335,14 +338,14 @@ func UpdateConfig(c *gin.Context) { web.Fail(c, web.NewBadRequest("configs 必填")) return } - for k, v := range req.Configs { - var sc model.SystemConfig - if err := store.DB.Where("config_key = ?", k).First(&sc).Error; err == nil { - sc.ConfigValue = v - store.DB.Save(&sc) - } else { - store.DB.Create(&model.SystemConfig{ConfigKey: k, ConfigValue: v}) - } - } + configRepo.BulkUpsert(toSystemConfigs(req.Configs)) web.OK(c, gin.H{"updated": len(req.Configs)}) } + +func toSystemConfigs(m map[string]string) []model.SystemConfig { + items := make([]model.SystemConfig, 0, len(m)) + for k, v := range m { + items = append(items, model.SystemConfig{ConfigKey: k, ConfigValue: v}) + } + return items +} diff --git a/eai_agentplatform/backend-go/internal/api/task_runtime.go b/eai_agentplatform/backend-go/internal/api/task_runtime.go new file mode 100644 index 0000000..3e6a42e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/api/task_runtime.go @@ -0,0 +1,454 @@ +package api + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "eai_agentplatform/backend/internal/middleware" + "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + wechatofficialaccountapi "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/api" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +func ListTasks(c *gin.Context) { + specialistKey := strings.TrimSpace(c.Query("specialist_key")) + if specialistKey == "" { + web.Fail(c, web.NewBadRequest("specialist_key 不能为空")) + return + } + + var items []model.TaskRecord + if err := store.DB.Where("specialist_key = ?", specialistKey).Order("updated_at DESC, id DESC").Find(&items).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询事项失败")) + return + } + web.OK(c, items) +} + +func GetTaskDetail(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.TaskRecord + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var artifacts []model.TaskArtifact + if err := store.DB.Where("task_id = ?", id).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询交付物失败")) + return + } + + var runs []model.TaskRun + if err := store.DB.Where("task_id = ?", id).Order("started_at DESC, id DESC").Find(&runs).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询运行记录失败")) + return + } + if task.SpecialistKey == wechatofficialaccountapi.OfficialAccountSpecialistKey { + artifacts = wechatofficialaccountapi.CompactOfficialAccountArtifactsForResponse(artifacts) + runs = wechatofficialaccountapi.CompactOfficialAccountRunsForResponse(runs) + } + + web.OK(c, gin.H{ + "task": task, + "artifacts": artifacts, + "runs": runs, + "bottom_panel": buildBottomPanel(runs), + }) +} + +func GetArtifactDetail(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var artifact model.TaskArtifact + if err := store.DB.First(&artifact, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("交付物不存在")) + return + } + + var task model.TaskRecord + if err := store.DB.First(&task, artifact.TaskID).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var run model.TaskRun + var runData any + if artifact.CreatedByRunID != nil { + if err := store.DB.First(&run, *artifact.CreatedByRunID).Error; err == nil { + runData = run + } + } + + web.OK(c, gin.H{ + "artifact": artifact, + "task": task, + "run": runData, + }) +} + +func CreateTask(c *gin.Context) { + var req specialistruntime.TaskReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if strings.TrimSpace(req.SpecialistKey) == "" || strings.TrimSpace(req.Title) == "" { + web.Fail(c, web.NewBadRequest("specialist_key、title 为必填")) + return + } + + var specialist specialistmodel.Specialist + if err := store.DB.Where("key = ?", strings.TrimSpace(req.SpecialistKey)).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + + task, err := specialistruntime.BuildTaskFromReq(req, middleware.CurrentUser(c)) + if err != nil { + web.Fail(c, web.NewBadRequest(err.Error())) + return + } + task.SpecialistKey = specialist.Key + if task.Status == "" { + task.Status = specialist.Stage + } + if task.Status == "" { + task.Status = "待处理" + } + + if err := store.DB.Create(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("创建事项失败")) + return + } + web.OK(c, task) +} + +func UpdateTask(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.TaskRecord + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var req specialistruntime.TaskReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + + updated, err := specialistruntime.BuildTaskFromReq(req, middleware.CurrentUser(c)) + if err != nil { + web.Fail(c, web.NewBadRequest(err.Error())) + return + } + + if strings.TrimSpace(req.SpecialistKey) != "" && strings.TrimSpace(req.SpecialistKey) != task.SpecialistKey { + var specialist specialistmodel.Specialist + if err := store.DB.Where("key = ?", strings.TrimSpace(req.SpecialistKey)).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("目标专员不存在")) + return + } + task.SpecialistKey = specialist.Key + } + task.Title = updated.Title + task.Summary = updated.Summary + task.Owner = updated.Owner + task.Priority = updated.Priority + task.Status = specialistruntime.NormalizeTaskStatus(updated.Status) + task.ContextJSON = updated.ContextJSON + task.DueAt = updated.DueAt + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项失败")) + return + } + web.OK(c, task) +} + +func UpdateTaskStatus(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.TaskRecord + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var req specialistruntime.TaskStatusReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + status := specialistruntime.NormalizeTaskStatus(req.Status) + if !specialistruntime.IsAllowedTaskStatus(status) { + web.Fail(c, web.NewBadRequest("事项状态不合法")) + return + } + task.Status = status + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项状态失败")) + return + } + web.OK(c, task) +} + +func DeleteTask(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.TaskRecord + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + if err := store.DB.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("task_id = ?", task.ID).Delete(&model.TaskArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("task_id = ?", task.ID).Delete(&model.TaskRun{}).Error; err != nil { + return err + } + if err := tx.Delete(&task).Error; err != nil { + return err + } + return nil + }); err != nil { + web.Fail(c, web.NewBadRequest("删除事项失败")) + return + } + + web.OK(c, gin.H{"id": id, "deleted": true, "specialist_key": task.SpecialistKey}) +} + +func UpdateArtifactStatus(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var artifact model.TaskArtifact + if err := store.DB.First(&artifact, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("交付物不存在")) + return + } + var task model.TaskRecord + if err := store.DB.First(&task, artifact.TaskID).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + + var req specialistruntime.ArtifactStatusReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + nextStatus := specialistruntime.NormalizeArtifactStatus(req.Status) + if !specialistruntime.CanTransitionArtifactStatus(artifact.Status, nextStatus) { + web.Fail(c, web.NewBadRequest("当前交付物状态不允许这样流转")) + return + } + artifact.Status = nextStatus + if err := store.DB.Save(&artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新交付物状态失败")) + return + } + + task.Status = specialistruntime.TaskStatusFromArtifactStatus(nextStatus) + task.CurrentResult = specialistruntime.BuildArtifactStatusSummary(artifact, strings.TrimSpace(req.Remark)) + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项状态失败")) + return + } + + web.OK(c, gin.H{ + "artifact": artifact, + "task": task, + }) +} + +func ExecuteTaskAction(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + var task model.TaskRecord + if err := store.DB.First(&task, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("事项不存在")) + return + } + var specialist specialistmodel.Specialist + if err := store.DB.Where("key = ?", task.SpecialistKey).First(&specialist).Error; err != nil { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + user := middleware.CurrentUser(c) + + var req specialistruntime.TaskActionReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + req.ActionKey = specialistruntime.NormalizeTaskActionKey(req.ActionKey, req.ActionTitle) + req.ActionTitle = strings.TrimSpace(req.ActionTitle) + req.ActionType = strings.TrimSpace(req.ActionType) + req.ActionDetail = strings.TrimSpace(req.ActionDetail) + req.Approval = strings.TrimSpace(req.Approval) + if req.ActionKey == "" || req.ActionTitle == "" { + web.Fail(c, web.NewBadRequest("action_key、action_title 为必填")) + return + } + + now := time.Now() + runOutput := specialistruntime.BuildTaskRunOutput(task, specialist, req, user, now) + outputJSON, _ := json.Marshal(runOutput) + logsJSON, _ := json.Marshal(runOutput.Logs) + inputJSON, _ := json.Marshal(gin.H{ + "task_title": task.Title, + "task_status": task.Status, + "inputs": req.Inputs, + "outputs": req.Outputs, + "approval": req.Approval, + }) + + run := model.TaskRun{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + ActionKey: req.ActionKey, + ActionTitle: req.ActionTitle, + ActionType: req.ActionType, + 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 + } + + var artifact *model.TaskArtifact + if runOutput.ArtifactTitle != "" { + sourceRefsJSON, _ := json.Marshal(runOutput.SourceRefs) + artifact = &model.TaskArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: runOutput.ArtifactTitle, + ArtifactType: runOutput.ArtifactType, + Status: runOutput.ArtifactStatus, + ContentText: runOutput.ArtifactContent, + ContentJSON: string(outputJSON), + SourceRefsJSON: string(sourceRefsJSON), + CreatedByRunID: &run.ID, + } + if err := store.DB.Create(artifact).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存交付物失败")) + return + } + } + + task.Status = runOutput.TaskStatus + task.CurrentRunID = &run.ID + task.CurrentResult = runOutput.Summary + task.LastTriggeredAt = &now + if err := store.DB.Save(&task).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新事项状态失败")) + return + } + + web.OK(c, gin.H{ + "task": task, + "run": run, + "artifact": artifact, + }) +} + +func ensureBootstrapTask(specialistKey string, user *model.User) error { + var count int64 + if err := store.DB.Model(&model.TaskRecord{}).Where("specialist_key = ?", specialistKey).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + + var specialist specialistmodel.Specialist + if err := store.DB.Where("key = ?", specialistKey).First(&specialist).Error; err != nil { + return err + } + + now := time.Now() + dueAt := now.Add(24 * time.Hour) + contextJSON, _ := json.Marshal(gin.H{ + "bootstrap": true, + "specialist_key": specialist.Key, + "specialist": specialist.Label, + "object_entry_route": specialist.ObjectEntryRoute, + }) + + task := model.TaskRecord{ + SpecialistKey: specialist.Key, + Title: specialistruntime.DefaultTaskTitle(specialist), + Summary: specialistruntime.DefaultTaskSummary(specialist), + Owner: specialistruntime.BootstrapOwner(user), + Priority: "P1", + Status: specialistruntime.DefaultTaskStatus(specialist), + ContextJSON: string(contextJSON), + DueAt: &dueAt, + } + if user != nil { + task.CreatedBy = &user.ID + } + return store.DB.Create(&task).Error +} + +func buildBottomPanel(runs []model.TaskRun) gin.H { + panel := gin.H{ + "timeline": []string{}, + "replay": []string{}, + "trace": []string{}, + "logs": []string{}, + } + for _, run := range runs { + var output specialistruntime.TaskRunOutput + _ = json.Unmarshal([]byte(run.OutputJSON), &output) + var logs []string + _ = json.Unmarshal([]byte(run.LogsJSON), &logs) + + panel["timeline"] = specialistruntime.AppendStringSlice(panel["timeline"], output.Timeline) + panel["replay"] = specialistruntime.AppendStringSlice(panel["replay"], []string{ + fmt.Sprintf("%s · %s · %s", run.StartedAt.Format("01-02 15:04"), run.ActionTitle, output.Summary), + }) + panel["trace"] = specialistruntime.AppendStringSlice(panel["trace"], output.Trace) + panel["logs"] = specialistruntime.AppendStringSlice(panel["logs"], logs) + } + return panel +} diff --git a/eai_agentplatform/backend-go/internal/api/workbench_overview.go b/eai_agentplatform/backend-go/internal/api/workbench_overview.go index 89677f2..1eb1720 100644 --- a/eai_agentplatform/backend-go/internal/api/workbench_overview.go +++ b/eai_agentplatform/backend-go/internal/api/workbench_overview.go @@ -9,6 +9,8 @@ import ( "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) @@ -21,14 +23,14 @@ func WorkbenchOverview(c *gin.Context) { } openStatuses := []string{ - workerTaskStatusPending, - workerTaskStatusInProgress, - workerTaskStatusDraft, - workerTaskStatusPendingReview, + specialistruntime.TaskStatusPending, + specialistruntime.TaskStatusInProgress, + specialistruntime.TaskStatusDraft, + specialistruntime.TaskStatusPendingReview, } closedStatuses := []string{ - workerTaskStatusCompleted, - workerTaskStatusArchived, + specialistruntime.TaskStatusCompleted, + specialistruntime.TaskStatusArchived, } taskQuery := scopedWorkbenchTaskQuery(user) @@ -39,7 +41,7 @@ func WorkbenchOverview(c *gin.Context) { return } - var tasks []model.WorkerTask + var tasks []model.TaskRecord if err := scopedWorkbenchTaskQuery(user). Where("status IN ?", openStatuses). Order("CASE priority WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END"). @@ -50,9 +52,9 @@ func WorkbenchOverview(c *gin.Context) { return } - var riskTasks []model.WorkerTask + var riskTasks []model.TaskRecord if err := scopedWorkbenchTaskQuery(user). - Where("(status = ? OR priority = ?) AND status NOT IN ?", workerTaskStatusPendingReview, "P1", closedStatuses). + Where("(status = ? OR priority = ?) AND status NOT IN ?", specialistruntime.TaskStatusPendingReview, "P1", closedStatuses). Order("updated_at DESC, id DESC"). Limit(4). Find(&riskTasks).Error; err != nil { @@ -60,11 +62,11 @@ func WorkbenchOverview(c *gin.Context) { return } - artifactQuery := store.DB.Table("worker_artifact"). - Select("worker_artifact.id, worker_artifact.title, worker_artifact.status, worker_artifact.artifact_type, worker_artifact.created_at, worker_task.id as task_id, worker_task.title as task_title, worker_task.specialist_key"). - Joins("left join worker_task on worker_task.id = worker_artifact.task_id") + artifactQuery := store.DB.Table("task_artifact"). + Select("task_artifact.id, task_artifact.title, task_artifact.status, task_artifact.artifact_type, task_artifact.created_at, task_record.id as task_id, task_record.title as task_title, task_record.specialist_key"). + Joins("left join task_record on task_record.id = task_artifact.task_id") if user.Role != "admin" { - artifactQuery = artifactQuery.Where("worker_task.owner IN ?", []string{user.FullName, user.Username}) + artifactQuery = artifactQuery.Where("task_record.owner IN ?", []string{user.FullName, user.Username}) } type artifactRow struct { @@ -78,7 +80,7 @@ func WorkbenchOverview(c *gin.Context) { CreatedAt time.Time `json:"created_at"` } var artifactRows []artifactRow - if err := artifactQuery.Order("worker_artifact.created_at DESC, worker_artifact.id DESC").Limit(6).Scan(&artifactRows).Error; err != nil { + if err := artifactQuery.Order("task_artifact.created_at DESC, task_artifact.id DESC").Limit(6).Scan(&artifactRows).Error; err != nil { web.Fail(c, web.NewBadRequest("查询最近交付物失败")) return } @@ -99,15 +101,15 @@ func WorkbenchOverview(c *gin.Context) { for _, item := range tasks { spec := specialistMap[item.SpecialistKey] todoItems = append(todoItems, gin.H{ - "id": item.ID, - "title": item.Title, - "summary": item.Summary, - "status": item.Status, - "priority": item.Priority, - "owner": item.Owner, - "specialist_key": item.SpecialistKey, - "specialist": spec.Label, - "object_entry_route": spec.ObjectEntryRoute, + "id": item.ID, + "title": item.Title, + "summary": item.Summary, + "status": item.Status, + "priority": item.Priority, + "owner": item.Owner, + "specialist_key": item.SpecialistKey, + "specialist": spec.Label, + "object_entry_route": spec.ObjectEntryRoute, }) } @@ -115,12 +117,12 @@ func WorkbenchOverview(c *gin.Context) { for _, item := range riskTasks { spec := specialistMap[item.SpecialistKey] riskItems = append(riskItems, gin.H{ - "id": item.ID, - "title": item.Title, - "sub": buildWorkbenchRiskText(item), - "status": item.Status, - "object_entry_route": spec.ObjectEntryRoute, - "specialist": spec.Label, + "id": item.ID, + "title": item.Title, + "sub": buildWorkbenchRiskText(item), + "status": item.Status, + "object_entry_route": spec.ObjectEntryRoute, + "specialist": spec.Label, }) } @@ -128,16 +130,16 @@ func WorkbenchOverview(c *gin.Context) { for _, item := range artifactRows { spec := specialistMap[item.SpecialistKey] recentArtifacts = append(recentArtifacts, gin.H{ - "id": item.ID, - "title": item.Title, - "sub": strings.TrimSpace(spec.Label + " / " + item.TaskTitle), - "tag": artifactStatusLabel(item.Status), - "status": item.Status, - "task_id": item.TaskID, - "specialist_key": item.SpecialistKey, - "object_entry_route": spec.ObjectEntryRoute, - "artifact_type": item.ArtifactType, - "created_at": item.CreatedAt, + "id": item.ID, + "title": item.Title, + "sub": strings.TrimSpace(spec.Label + " / " + item.TaskTitle), + "tag": artifactStatusLabel(item.Status), + "status": item.Status, + "task_id": item.TaskID, + "specialist_key": item.SpecialistKey, + "object_entry_route": spec.ObjectEntryRoute, + "artifact_type": item.ArtifactType, + "created_at": item.CreatedAt, }) } @@ -150,14 +152,14 @@ func WorkbenchOverview(c *gin.Context) { } func scopedWorkbenchTaskQuery(user *model.User) *gorm.DB { - query := store.DB.Model(&model.WorkerTask{}) + query := store.DB.Model(&model.TaskRecord{}) if user != nil && user.Role != "admin" { query = query.Where("owner IN ?", []string{user.FullName, user.Username}) } return query } -func loadSpecialistMap(keys []string) map[string]model.Specialist { +func loadSpecialistMap(keys []string) map[string]specialistmodel.Specialist { filtered := make([]string, 0, len(keys)) seen := map[string]struct{}{} for _, key := range keys { @@ -172,22 +174,22 @@ func loadSpecialistMap(keys []string) map[string]model.Specialist { filtered = append(filtered, key) } if len(filtered) == 0 { - return map[string]model.Specialist{} + return map[string]specialistmodel.Specialist{} } - var items []model.Specialist + var items []specialistmodel.Specialist store.DB.Where("key IN ?", filtered).Find(&items) - out := make(map[string]model.Specialist, len(items)) + out := make(map[string]specialistmodel.Specialist, len(items)) for _, item := range items { out[item.Key] = item } return out } -func buildWorkbenchRiskText(task model.WorkerTask) string { +func buildWorkbenchRiskText(task model.TaskRecord) string { switch task.Status { - case workerTaskStatusPendingReview: + case specialistruntime.TaskStatusPendingReview: return "当前事项正在等待确认,建议优先处理。" - case workerTaskStatusDraft: + case specialistruntime.TaskStatusDraft: return "草案已生成,仍需确认和发布。" default: if task.Priority == "P1" { @@ -198,14 +200,14 @@ func buildWorkbenchRiskText(task model.WorkerTask) string { } func artifactStatusLabel(status string) string { - switch normalizeWorkerArtifactStatus(status) { - case workerArtifactStatusDraft: + switch specialistruntime.NormalizeArtifactStatus(status) { + case specialistruntime.ArtifactStatusDraft: return "草案" - case workerArtifactStatusReady: + case specialistruntime.ArtifactStatusReady: return "待确认" - case workerArtifactStatusApproved: + case specialistruntime.ArtifactStatusApproved: return "已确认" - case workerArtifactStatusPublished: + case specialistruntime.ArtifactStatusPublished: return "已发布" default: return "已生成" diff --git a/eai_agentplatform/backend-go/internal/api/worker_task.go b/eai_agentplatform/backend-go/internal/api/worker_task.go deleted file mode 100644 index 0db5e76..0000000 --- a/eai_agentplatform/backend-go/internal/api/worker_task.go +++ /dev/null @@ -1,1100 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - - "eai_agentplatform/backend/internal/ai" - "eai_agentplatform/backend/internal/config" - "eai_agentplatform/backend/internal/connector" - "eai_agentplatform/backend/internal/middleware" - "eai_agentplatform/backend/internal/model" - "eai_agentplatform/backend/internal/store" - "eai_agentplatform/backend/internal/web" -) - -type workerTaskReq struct { - SpecialistKey string `json:"specialist_key"` - Title string `json:"title"` - Summary string `json:"summary"` - Owner string `json:"owner"` - Priority string `json:"priority"` - 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"` -} - -type workerActionReq struct { - ActionKey string `json:"action_key"` - ActionTitle string `json:"action_title"` - ActionType string `json:"action_type"` - ActionDetail string `json:"action_detail"` - Inputs []string `json:"inputs"` - Outputs []string `json:"outputs"` - Approval string `json:"approval"` -} - -type workerTaskStatusReq struct { - Status string `json:"status"` -} - -type workerArtifactStatusReq struct { - Status string `json:"status"` - Remark string `json:"remark"` -} - -func ListWorkerTasks(c *gin.Context) { - specialistKey := strings.TrimSpace(c.Query("specialist_key")) - if specialistKey == "" { - web.Fail(c, web.NewBadRequest("specialist_key 不能为空")) - return - } - - var items []model.WorkerTask - if err := store.DB.Where("specialist_key = ?", specialistKey).Order("updated_at DESC, id DESC").Find(&items).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询事项失败")) - return - } - web.OK(c, items) -} - -func GetWorkerTaskDetail(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var task model.WorkerTask - if err := store.DB.First(&task, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - - var artifacts []model.WorkerArtifact - if err := store.DB.Where("task_id = ?", id).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询交付物失败")) - return - } - - var runs []model.WorkerRun - if err := store.DB.Where("task_id = ?", id).Order("started_at DESC, id DESC").Find(&runs).Error; err != nil { - web.Fail(c, web.NewBadRequest("查询运行记录失败")) - return - } - if task.SpecialistKey == officialAccountSpecialistKey { - artifacts = compactOfficialAccountArtifactsForResponse(artifacts) - runs = compactOfficialAccountRunsForResponse(runs) - } - - web.OK(c, gin.H{ - "task": task, - "artifacts": artifacts, - "runs": runs, - "bottom_panel": buildBottomPanel(runs), - }) -} - -func GetWorkerArtifactDetail(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var artifact model.WorkerArtifact - if err := store.DB.First(&artifact, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("交付物不存在")) - return - } - - var task model.WorkerTask - if err := store.DB.First(&task, artifact.TaskID).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - - var run model.WorkerRun - var runData any - if artifact.CreatedByRunID != nil { - if err := store.DB.First(&run, *artifact.CreatedByRunID).Error; err == nil { - runData = run - } - } - - web.OK(c, gin.H{ - "artifact": artifact, - "task": task, - "run": runData, - }) -} - -func CreateWorkerTask(c *gin.Context) { - var req workerTaskReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - if strings.TrimSpace(req.SpecialistKey) == "" || strings.TrimSpace(req.Title) == "" { - web.Fail(c, web.NewBadRequest("specialist_key、title 为必填")) - return - } - - var specialist model.Specialist - if err := store.DB.Where("key = ?", strings.TrimSpace(req.SpecialistKey)).First(&specialist).Error; err != nil { - web.Fail(c, web.NewNotFoundError("专员不存在")) - return - } - - task, err := buildWorkerTaskFromReq(req, middleware.CurrentUser(c)) - if err != nil { - web.Fail(c, web.NewBadRequest(err.Error())) - return - } - task.SpecialistKey = specialist.Key - if task.Status == "" { - task.Status = specialist.Stage - } - if task.Status == "" { - task.Status = "待处理" - } - - if err := store.DB.Create(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("创建事项失败")) - return - } - notifyUsersByOwner( - task.Owner, - "worker_task_assign", - fmt.Sprintf("你有新的待办事项:%s", task.Title), - fmt.Sprintf("专员 %s 已为你创建事项,请尽快查看。", specialist.Label), - specialist.ObjectEntryRoute, - ) - web.OK(c, task) -} - -func UpdateWorkerTask(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var task model.WorkerTask - if err := store.DB.First(&task, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - - var req workerTaskReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - - updated, err := buildWorkerTaskFromReq(req, middleware.CurrentUser(c)) - if err != nil { - web.Fail(c, web.NewBadRequest(err.Error())) - return - } - - if strings.TrimSpace(req.SpecialistKey) != "" && strings.TrimSpace(req.SpecialistKey) != task.SpecialistKey { - var specialist model.Specialist - if err := store.DB.Where("key = ?", strings.TrimSpace(req.SpecialistKey)).First(&specialist).Error; err != nil { - web.Fail(c, web.NewNotFoundError("目标专员不存在")) - return - } - task.SpecialistKey = specialist.Key - } - previousOwner := task.Owner - - task.Title = updated.Title - task.Summary = updated.Summary - task.Owner = updated.Owner - task.Priority = updated.Priority - task.Status = normalizeWorkerTaskStatus(updated.Status) - task.ContextJSON = updated.ContextJSON - task.DueAt = updated.DueAt - if err := store.DB.Save(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新事项失败")) - return - } - if strings.TrimSpace(task.Owner) != "" && strings.TrimSpace(task.Owner) != strings.TrimSpace(previousOwner) { - notifyUsersByOwner( - task.Owner, - "worker_task_assign", - fmt.Sprintf("你被指派了事项:%s", task.Title), - "请进入数字员工工作区查看最新事项安排。", - buildWorkerTaskLink(task.SpecialistKey), - ) - } - web.OK(c, task) -} - -func UpdateWorkerTaskStatus(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var task model.WorkerTask - if err := store.DB.First(&task, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - - var req workerTaskStatusReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - status := normalizeWorkerTaskStatus(req.Status) - if !isAllowedWorkerTaskStatus(status) { - web.Fail(c, web.NewBadRequest("事项状态不合法")) - return - } - task.Status = status - if err := store.DB.Save(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新事项状态失败")) - return - } - web.OK(c, task) -} - -func DeleteWorkerTask(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var task model.WorkerTask - if err := store.DB.First(&task, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - - if err := store.DB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("task_id = ?", task.ID).Delete(&model.WorkerArtifact{}).Error; err != nil { - return err - } - if err := tx.Where("task_id = ?", task.ID).Delete(&model.WorkerRun{}).Error; err != nil { - return err - } - if err := tx.Delete(&task).Error; err != nil { - return err - } - return nil - }); err != nil { - web.Fail(c, web.NewBadRequest("删除事项失败")) - return - } - - web.OK(c, gin.H{"id": id, "deleted": true, "specialist_key": task.SpecialistKey}) -} - -func UpdateWorkerArtifactStatus(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var artifact model.WorkerArtifact - if err := store.DB.First(&artifact, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("交付物不存在")) - return - } - var task model.WorkerTask - if err := store.DB.First(&task, artifact.TaskID).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - - var req workerArtifactStatusReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - nextStatus := normalizeWorkerArtifactStatus(req.Status) - if !canTransitionWorkerArtifactStatus(artifact.Status, nextStatus) { - web.Fail(c, web.NewBadRequest("当前交付物状态不允许这样流转")) - return - } - artifact.Status = nextStatus - if err := store.DB.Save(&artifact).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新交付物状态失败")) - return - } - - task.Status = taskStatusFromArtifactStatus(nextStatus) - task.CurrentResult = buildArtifactStatusSummary(artifact, strings.TrimSpace(req.Remark)) - if err := store.DB.Save(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新事项状态失败")) - return - } - - notifyOnArtifactStatusChange(task, artifact) - - web.OK(c, gin.H{ - "artifact": artifact, - "task": task, - }) -} - -func ExecuteWorkerTaskAction(c *gin.Context) { - id, ok := parseID(c, "id") - if !ok { - return - } - - var task model.WorkerTask - if err := store.DB.First(&task, id).Error; err != nil { - web.Fail(c, web.NewNotFoundError("事项不存在")) - return - } - var specialist model.Specialist - if err := store.DB.Where("key = ?", task.SpecialistKey).First(&specialist).Error; err != nil { - web.Fail(c, web.NewNotFoundError("专员不存在")) - return - } - user := middleware.CurrentUser(c) - - var req workerActionReq - if err := c.ShouldBindJSON(&req); err != nil { - web.Fail(c, web.NewBadRequest("请求参数错误")) - return - } - req.ActionKey = normalizeWorkerActionKey(req.ActionKey, req.ActionTitle) - req.ActionTitle = strings.TrimSpace(req.ActionTitle) - req.ActionType = strings.TrimSpace(req.ActionType) - req.ActionDetail = strings.TrimSpace(req.ActionDetail) - req.Approval = strings.TrimSpace(req.Approval) - if req.ActionKey == "" || req.ActionTitle == "" { - web.Fail(c, web.NewBadRequest("action_key、action_title 为必填")) - return - } - - now := time.Now() - runOutput := buildWorkerRunOutput(task, specialist, req, user, now) - outputJSON, _ := json.Marshal(runOutput) - logsJSON, _ := json.Marshal(runOutput.Logs) - inputJSON, _ := json.Marshal(gin.H{ - "task_title": task.Title, - "task_status": task.Status, - "inputs": req.Inputs, - "outputs": req.Outputs, - "approval": req.Approval, - }) - - run := model.WorkerRun{ - TaskID: task.ID, - SpecialistKey: task.SpecialistKey, - ActionKey: req.ActionKey, - ActionTitle: req.ActionTitle, - ActionType: req.ActionType, - 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 - } - - var artifact *model.WorkerArtifact - if runOutput.ArtifactTitle != "" { - sourceRefsJSON, _ := json.Marshal(runOutput.SourceRefs) - artifact = &model.WorkerArtifact{ - TaskID: task.ID, - SpecialistKey: task.SpecialistKey, - Title: runOutput.ArtifactTitle, - ArtifactType: runOutput.ArtifactType, - Status: runOutput.ArtifactStatus, - ContentText: runOutput.ArtifactContent, - ContentJSON: string(outputJSON), - SourceRefsJSON: string(sourceRefsJSON), - CreatedByRunID: &run.ID, - } - if err := store.DB.Create(artifact).Error; err != nil { - web.Fail(c, web.NewBadRequest("保存交付物失败")) - return - } - } - - task.Status = runOutput.TaskStatus - task.CurrentRunID = &run.ID - task.CurrentResult = runOutput.Summary - task.LastTriggeredAt = &now - if err := store.DB.Save(&task).Error; err != nil { - web.Fail(c, web.NewBadRequest("更新事项状态失败")) - return - } - - web.OK(c, gin.H{ - "task": task, - "run": run, - "artifact": artifact, - }) -} - -func ensureBootstrapWorkerTask(specialistKey string, user *model.User) error { - var count int64 - if err := store.DB.Model(&model.WorkerTask{}).Where("specialist_key = ?", specialistKey).Count(&count).Error; err != nil { - return err - } - if count > 0 { - return nil - } - - var specialist model.Specialist - if err := store.DB.Where("key = ?", specialistKey).First(&specialist).Error; err != nil { - return err - } - - now := time.Now() - dueAt := now.Add(24 * time.Hour) - contextJSON, _ := json.Marshal(gin.H{ - "bootstrap": true, - "specialist_key": specialist.Key, - "specialist": specialist.Label, - "object_entry_route": specialist.ObjectEntryRoute, - }) - - task := model.WorkerTask{ - SpecialistKey: specialist.Key, - Title: defaultWorkerTaskTitle(specialist), - Summary: defaultWorkerTaskSummary(specialist), - Owner: bootstrapOwner(user), - Priority: "P1", - Status: defaultWorkerTaskStatus(specialist), - ContextJSON: string(contextJSON), - DueAt: &dueAt, - } - if user != nil { - task.CreatedBy = &user.ID - } - return store.DB.Create(&task).Error -} - -func buildWorkerTaskFromReq(req workerTaskReq, user *model.User) (model.WorkerTask, error) { - task := model.WorkerTask{ - SpecialistKey: strings.TrimSpace(req.SpecialistKey), - Title: strings.TrimSpace(req.Title), - Summary: strings.TrimSpace(req.Summary), - Owner: strings.TrimSpace(req.Owner), - Priority: strings.TrimSpace(req.Priority), - Status: normalizeWorkerTaskStatus(req.Status), - } - if task.Owner == "" { - task.Owner = bootstrapOwner(user) - } - if task.Priority == "" { - task.Priority = "P2" - } - if task.Status == "" { - task.Status = workerTaskStatusPending - } - if len(req.Context) > 0 { - b, err := json.Marshal(req.Context) - if err != nil { - return task, fmt.Errorf("context 序列化失败") - } - task.ContextJSON = string(b) - } - if strings.TrimSpace(req.DueAt) != "" { - parsed, err := parseWorkerDueAt(req.DueAt) - if err != nil { - return task, fmt.Errorf("due_at 格式错误,支持 RFC3339 或 2006-01-02 15:04") - } - 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 - } - return task, nil -} - -func parseWorkerDueAt(value string) (time.Time, error) { - value = strings.TrimSpace(value) - layouts := []string{time.RFC3339, "2006-01-02 15:04", "2006-01-02"} - var lastErr error - for _, layout := range layouts { - parsed, err := time.Parse(layout, value) - if err == nil { - return parsed, nil - } - lastErr = err - } - return time.Time{}, lastErr -} - -type workerRunOutput struct { - Summary string `json:"summary"` - TaskStatus string `json:"task_status"` - ArtifactTitle string `json:"artifact_title"` - ArtifactType string `json:"artifact_type"` - ArtifactStatus string `json:"artifact_status"` - ArtifactContent string `json:"artifact_content"` - SourceRefs []string `json:"source_refs"` - Timeline []string `json:"timeline"` - Trace []string `json:"trace"` - Logs []string `json:"logs"` -} - -func buildWorkerRunOutput(task model.WorkerTask, specialist model.Specialist, req workerActionReq, user *model.User, now time.Time) workerRunOutput { - status := workerTaskStatusInProgress - artifactType := "text" - artifactStatus := workerArtifactStatusDraft - artifactTitle := fmt.Sprintf("%s - %s", task.Title, req.ActionTitle) - summary := fmt.Sprintf("%s已执行,事项已更新。", req.ActionTitle) - - switch { - case containsAny(req.ActionTitle, "生成", "汇总", "草案", "方案"): - status = workerTaskStatusDraft - artifactType = "draft" - artifactStatus = workerArtifactStatusDraft - summary = fmt.Sprintf("已生成《%s》草案,等待人工确认。", task.Title) - case containsAny(req.ActionTitle, "审批", "确认", "评审", "提交"): - status = workerTaskStatusPendingReview - artifactType = "approval_note" - artifactStatus = workerArtifactStatusReady - summary = fmt.Sprintf("已提交《%s》的确认请求。", task.Title) - case containsAny(req.ActionTitle, "发布", "归档", "同步"): - status = workerTaskStatusArchived - artifactType = "delivery" - artifactStatus = workerArtifactStatusPublished - summary = fmt.Sprintf("《%s》已进入发布/归档阶段。", task.Title) - } - - strategy := chooseWorkerStrategy(specialist, req) - connectorSummary, connectorSourceRefs, connectorTrace, connectorLogs := executeConnectorStep(specialist, task, req, strategy.ConnectorKey) - aiSummary, aiTrace, aiLogs := executeAIStep(task, specialist, req, user, strategy.UseAI) - - content := fmt.Sprintf( - "%s\n\n事项:%s\n当前摘要:%s\n动作说明:%s\n输入:%s\n预期输出:%s\n连接器结果:%s\nAI结果:%s\n执行时间:%s\n", - summary, - task.Title, - task.Summary, - fallbackText(req.ActionDetail, "根据当前事项上下文生成最小交付物。"), - joinWorkerList(req.Inputs, "当前事项上下文"), - joinWorkerList(req.Outputs, "交付草案"), - fallbackText(connectorSummary, "未触发"), - fallbackText(aiSummary, "未触发"), - now.Format("2006-01-02 15:04"), - ) - if aiSummary != "" { - summary = aiSummary - } - if connectorSummary != "" && !strings.Contains(summary, connectorSummary) { - summary = summary + " " + connectorSummary - } - - return workerRunOutput{ - Summary: summary, - TaskStatus: status, - ArtifactTitle: artifactTitle, - ArtifactType: artifactType, - ArtifactStatus: artifactStatus, - ArtifactContent: content, - SourceRefs: append(filterWorkerParts(append([]string{}, req.Inputs...)), connectorSourceRefs...), - Timeline: []string{ - fmt.Sprintf("%s 读取事项上下文", now.Format("15:04")), - fmt.Sprintf("%s 执行动作:%s", now.Format("15:04"), req.ActionTitle), - fmt.Sprintf("%s 事项状态更新为:%s", now.Format("15:04"), status), - }, - Trace: append([]string{ - fmt.Sprintf("worker.task.load(%d)", task.ID), - fmt.Sprintf("worker.action.execute(%s)", req.ActionKey), - fmt.Sprintf("worker.artifact.create(%s)", artifactType), - }, append(connectorTrace, aiTrace...)...), - Logs: append([]string{ - fmt.Sprintf("[INFO] task %d loaded", task.ID), - fmt.Sprintf("[INFO] action %s executed", req.ActionKey), - fmt.Sprintf("[INFO] task status updated to %s", status), - }, append(connectorLogs, aiLogs...)...), - } -} - -type workerExecutionStrategy struct { - UseAI bool - ConnectorKey string -} - -func chooseWorkerStrategy(specialist model.Specialist, req workerActionReq) workerExecutionStrategy { - strategy := workerExecutionStrategy{} - actionType := strings.ToLower(strings.TrimSpace(req.ActionType)) - if actionType == "generation" || actionType == "analysis" || containsAny(req.ActionTitle, "生成", "汇总", "分析", "对比", "方案", "草案", "总结") { - strategy.UseAI = true - } - strategy.ConnectorKey = selectWorkerConnector(specialist, req) - if actionType == "coordination" && strategy.ConnectorKey == "" { - strategy.UseAI = true - } - return strategy -} - -func executeConnectorStep(specialist model.Specialist, task model.WorkerTask, req workerActionReq, connectorKey string) (string, []string, []string, []string) { - if connectorKey == "" || Cfg == nil { - return "", nil, nil, nil - } - definition, ok := connector.GetDefinition(Cfg, connectorKey) - if !ok || definition.Direction != "input" || len(definition.Objects) == 0 { - return "", nil, nil, nil - } - object := definition.Objects[0] - fieldKeys := object.DefaultFields - if len(fieldKeys) > 5 { - fieldKeys = fieldKeys[:5] - } - result, err := connector.Query(context.Background(), Cfg, connectorKey, connector.QueryRequest{ - ObjectKey: object.Key, - FieldKeys: fieldKeys, - FilterString: task.Title, - Limit: 3, - UseDemo: true, - }) - if err != nil { - return "", nil, - []string{fmt.Sprintf("connector.query(%s,%s)", connectorKey, object.Key)}, - []string{fmt.Sprintf("[WARN] connector %s query failed: %v", connectorKey, err)} - } - count, _ := result.Outputs["count"].(int) - recordsJSON, _ := json.Marshal(result.Outputs["records"]) - summary := fmt.Sprintf("已从%s读取 %d 条%s数据。", definition.Label, count, object.Label) - sourceRef := fmt.Sprintf("%s / %s", definition.Label, object.Label) - if len(recordsJSON) > 0 { - summary = summary + " 样例:" + truncateWorkerText(string(recordsJSON), 120) - } - return summary, - []string{sourceRef}, - []string{fmt.Sprintf("connector.query(%s,%s)", connectorKey, object.Key)}, - []string{fmt.Sprintf("[INFO] connector %s returned %d records", connectorKey, count)} -} - -func executeAIStep(task model.WorkerTask, specialist model.Specialist, req workerActionReq, user *model.User, enabled bool) (string, []string, []string) { - if !enabled || user == nil || Cfg == nil { - return "", nil, nil - } - if user.AiPoints <= 0 { - return "AI 点数不足,未触发智能生成。", nil, []string{"[WARN] ai execution skipped because credits are insufficient"} - } - 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) - aiMessages := []ai.Message{ - {Role: "system", Content: systemPrompt}, - {Role: "user", Content: buildWorkerUserPrompt(task, req)}, - } - - start := time.Now() - result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages) - if err != nil { - ai.LogCall(ai.LogEntry{ - 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()), - }) - return "AI 生成失败,已回退到规则结果。", []string{"ai.generate(worker_action)"}, []string{fmt.Sprintf("[WARN] ai generation failed: %v", err)} - } - ai.LogCall(ai.LogEntry{ - 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()), - }) - return truncateWorkerText(strings.TrimSpace(result.Content), 240), - []string{"ai.generate(worker_action)"}, - []string{"[INFO] ai generation completed"} -} - -func buildBottomPanel(runs []model.WorkerRun) gin.H { - panel := gin.H{ - "timeline": []string{}, - "replay": []string{}, - "trace": []string{}, - "logs": []string{}, - } - for _, run := range runs { - var output workerRunOutput - _ = json.Unmarshal([]byte(run.OutputJSON), &output) - var logs []string - _ = json.Unmarshal([]byte(run.LogsJSON), &logs) - - panel["timeline"] = appendStringSlice(panel["timeline"], output.Timeline) - panel["replay"] = appendStringSlice(panel["replay"], []string{ - fmt.Sprintf("%s · %s · %s", run.StartedAt.Format("01-02 15:04"), run.ActionTitle, output.Summary), - }) - panel["trace"] = appendStringSlice(panel["trace"], output.Trace) - panel["logs"] = appendStringSlice(panel["logs"], logs) - } - return panel -} - -func appendStringSlice(current any, values []string) []string { - base, _ := current.([]string) - return append(base, values...) -} - -func defaultWorkerTaskTitle(specialist model.Specialist) string { - if strings.TrimSpace(specialist.Summary) != "" { - return strings.TrimSpace(specialist.Summary) - } - return specialist.Label + "默认事项" -} - -func defaultWorkerTaskSummary(specialist model.Specialist) string { - parts := []string{strings.TrimSpace(specialist.Summary), strings.TrimSpace(specialist.InfoSources), strings.TrimSpace(specialist.GeneratedSkills)} - joined := strings.Join(filterWorkerParts(parts), " · ") - if joined != "" { - return joined - } - return "基于当前专员定义生成的默认事项。" -} - -func defaultWorkerTaskStatus(specialist model.Specialist) string { - for _, value := range []string{specialist.Stage, specialist.WorkStatus} { - value = normalizeWorkerTaskStatus(value) - if value != "" { - return value - } - } - return workerTaskStatusPending -} - -func normalizeWorkerActionKey(key, title string) string { - key = strings.TrimSpace(key) - if key != "" { - return key - } - title = strings.TrimSpace(title) - replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", "·", "-", "_", "-") - title = replacer.Replace(title) - title = strings.ToLower(title) - title = strings.ReplaceAll(title, "--", "-") - if title == "" { - return "action" - } - return title -} - -func bootstrapOwner(user *model.User) string { - if user != nil && strings.TrimSpace(user.FullName) != "" { - return strings.TrimSpace(user.FullName) - } - return "待指派" -} - -func joinWorkerList(items []string, fallback string) string { - trimmed := filterWorkerParts(items) - if len(trimmed) == 0 { - return fallback - } - return strings.Join(trimmed, " / ") -} - -func filterWorkerParts(items []string) []string { - result := make([]string, 0, len(items)) - for _, item := range items { - item = strings.TrimSpace(item) - if item != "" { - result = append(result, item) - } - } - return result -} - -func fallbackText(value, fallback string) string { - value = strings.TrimSpace(value) - if value == "" { - return fallback - } - return value -} - -func containsAny(value string, items ...string) bool { - value = strings.TrimSpace(value) - for _, item := range items { - if strings.Contains(value, item) { - return true - } - } - return false -} - -func selectWorkerConnector(specialist model.Specialist, req workerActionReq) string { - type inputRecord struct { - InputType string `json:"input_type"` - ConnectorName string `json:"connector_name"` - } - var records []inputRecord - if err := json.Unmarshal([]byte(firstNonEmptyWorkerJSON(specialist.InputsRecordsJSON, "[]")), &records); err == nil { - for _, item := range records { - key := normalizeConnectorKey(item.ConnectorName) - if item.InputType == "connector" && key != "" && key != "workspace_context" { - if _, ok := connector.GetDefinition(Cfg, key); ok { - return key - } - } - } - } - - searchText := strings.Join([]string{ - specialist.ConnectorScope, - specialist.InfoSources, - req.ActionTitle, - req.ActionDetail, - joinWorkerList(req.Inputs, ""), - }, " ") - aliases := map[string]string{ - "邮件": "mail163_input", - "邮箱": "mail163_input", - "履约": "wecom_sheet_input", - "表格": "feishu_bitable_input", - "表单": "feishu_bitable_input", - "仓": "wms_input", - "库存": "wms_input", - "工单": "mes_input", - "产线": "mes_input", - "生产": "mes_input", - } - for keyword, key := range aliases { - if strings.Contains(searchText, keyword) { - return key - } - } - return "" -} - -func parseWorkerContextJSON(value string) map[string]any { - if strings.TrimSpace(value) == "" { - return map[string]any{} - } - var out map[string]any - if err := json.Unmarshal([]byte(value), &out); err != nil { - return map[string]any{} - } - return out -} - -func buildWorkerAITaskPrompt(task model.WorkerTask, specialist model.Specialist, req workerActionReq) string { - return strings.TrimSpace(fmt.Sprintf( - "你正在为数字员工平台执行专员动作。\n专员:%s\n事项:%s\n事项摘要:%s\n动作:%s\n动作类型:%s\n预期输出:%s\n请输出一段可以直接作为事项交付草案的中文结果,要求简洁、业务化、避免空话。", - specialist.Label, - task.Title, - task.Summary, - req.ActionTitle, - req.ActionType, - joinWorkerList(req.Outputs, "交付草案"), - )) -} - -func buildWorkerUserPrompt(task model.WorkerTask, req workerActionReq) string { - return strings.TrimSpace(fmt.Sprintf( - "请基于当前事项上下文执行动作。\n事项标题:%s\n当前状态:%s\n动作说明:%s\n输入信源:%s\n预期输出:%s", - task.Title, - task.Status, - fallbackText(req.ActionDetail, "根据当前事项生成结果"), - joinWorkerList(req.Inputs, "当前事项上下文"), - joinWorkerList(req.Outputs, "交付草案"), - )) -} - -func truncateWorkerText(value string, max int) string { - if max <= 0 { - return "" - } - runes := []rune(value) - if len(runes) <= max { - return value - } - return string(runes[:max]) + "..." -} - -func normalizeConnectorKey(value string) string { - value = strings.TrimSpace(strings.ToLower(value)) - if value == "" { - return "" - } - replacer := strings.NewReplacer(" ", "_", "-", "_", "/", "_", "\\", "_") - return replacer.Replace(value) -} - -func firstNonEmptyWorkerJSON(value, fallback string) string { - value = strings.TrimSpace(value) - if value == "" { - return fallback - } - return value -} - -const ( - workerTaskStatusPending = "待处理" - workerTaskStatusInProgress = "进行中" - workerTaskStatusDraft = "草案已生成" - workerTaskStatusPendingReview = "待确认" - workerTaskStatusCompleted = "已完成" - workerTaskStatusArchived = "已归档" - - workerArtifactStatusDraft = "draft" - workerArtifactStatusReady = "ready" - workerArtifactStatusApproved = "approved" - workerArtifactStatusPublished = "published" -) - -func normalizeWorkerTaskStatus(value string) string { - value = strings.TrimSpace(value) - switch { - case value == "", strings.Contains(value, "待处理"), strings.Contains(value, "待开始"): - return workerTaskStatusPending - case strings.Contains(value, "进行"), strings.Contains(value, "执行"), strings.Contains(value, "处理中"): - return workerTaskStatusInProgress - case strings.Contains(value, "草案"), strings.Contains(value, "方案生成"), strings.Contains(value, "人工复核"): - return workerTaskStatusDraft - case strings.Contains(value, "确认"), strings.Contains(value, "审批"), strings.Contains(value, "评审"): - return workerTaskStatusPendingReview - case strings.Contains(value, "完成"): - return workerTaskStatusCompleted - case strings.Contains(value, "归档"), strings.Contains(value, "发布"): - return workerTaskStatusArchived - default: - return value - } -} - -func isAllowedWorkerTaskStatus(value string) bool { - switch value { - case workerTaskStatusPending, workerTaskStatusInProgress, workerTaskStatusDraft, workerTaskStatusPendingReview, workerTaskStatusCompleted, workerTaskStatusArchived: - return true - default: - return false - } -} - -func normalizeWorkerArtifactStatus(value string) string { - value = strings.TrimSpace(strings.ToLower(value)) - switch value { - case workerArtifactStatusDraft, workerArtifactStatusReady, workerArtifactStatusApproved, workerArtifactStatusPublished: - return value - case "pending_review": - return workerArtifactStatusReady - case "done": - return workerArtifactStatusApproved - default: - return value - } -} - -func canTransitionWorkerArtifactStatus(current, next string) bool { - current = normalizeWorkerArtifactStatus(current) - next = normalizeWorkerArtifactStatus(next) - if current == next { - return true - } - switch current { - case workerArtifactStatusDraft: - return next == workerArtifactStatusReady - case workerArtifactStatusReady: - return next == workerArtifactStatusApproved || next == workerArtifactStatusDraft - case workerArtifactStatusApproved: - return next == workerArtifactStatusPublished || next == workerArtifactStatusDraft - case workerArtifactStatusPublished: - return false - default: - return false - } -} - -func taskStatusFromArtifactStatus(status string) string { - switch normalizeWorkerArtifactStatus(status) { - case workerArtifactStatusDraft: - return workerTaskStatusDraft - case workerArtifactStatusReady: - return workerTaskStatusPendingReview - case workerArtifactStatusApproved: - return workerTaskStatusCompleted - case workerArtifactStatusPublished: - return workerTaskStatusArchived - default: - return workerTaskStatusInProgress - } -} - -func buildArtifactStatusSummary(artifact model.WorkerArtifact, remark string) string { - base := fmt.Sprintf("交付物《%s》状态已更新为%s。", artifact.Title, taskStatusFromArtifactStatus(artifact.Status)) - if remark != "" { - return base + " 备注:" + remark - } - return base -} - -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.ObjectEntryRoute - } - return "" -} - -func notifyOnArtifactStatusChange(task model.WorkerTask, artifact model.WorkerArtifact) { - link := buildWorkerTaskLink(task.SpecialistKey) - switch normalizeWorkerArtifactStatus(artifact.Status) { - case workerArtifactStatusReady: - notifyAdmins( - "worker_artifact_review", - fmt.Sprintf("待确认交付物:%s", artifact.Title), - fmt.Sprintf("事项《%s》产出了待确认交付物,请及时审核。", task.Title), - link, - ) - case workerArtifactStatusApproved: - notifyUsersByOwner( - task.Owner, - "worker_artifact_approved", - fmt.Sprintf("交付物已确认:%s", artifact.Title), - fmt.Sprintf("事项《%s》的交付物已经确认通过。", task.Title), - link, - ) - case workerArtifactStatusPublished: - notifyUsersByOwner( - task.Owner, - "worker_artifact_published", - fmt.Sprintf("交付物已发布:%s", artifact.Title), - fmt.Sprintf("事项《%s》的交付物已进入发布/归档状态。", task.Title), - link, - ) - } -} diff --git a/eai_agentplatform/backend-go/internal/config/config.go b/eai_agentplatform/backend-go/internal/config/config.go index add905d..355f1c2 100644 --- a/eai_agentplatform/backend-go/internal/config/config.go +++ b/eai_agentplatform/backend-go/internal/config/config.go @@ -20,8 +20,10 @@ type Config struct { LLMModel string EmbedModel string - KBDataDir string - KnowledgeSourceDir string + AssetRootDir string + KBDataDir string + KnowledgeSourceDir string + TrainingMaterialsDir string // 定期备份。SQLite 是单文件、又没有任何外部主从,坏一份就是全丢, // 所以默认开着:启动补一次,之后每 BackupIntervalHours 小时一次,保留最近 N 份。 @@ -48,6 +50,7 @@ type Config struct { // Load 读取环境变量构造配置。缺失的用默认值(部署时通过 .env / systemd 注入)。 func Load() *Config { baseDir := backendBaseDir() + assetRootDir := getenv("ASSET_ROOT_DIR", filepath.Join(appRootDir(baseDir), "assets")) return &Config{ Port: getenv("PORT", "8080"), @@ -60,8 +63,10 @@ func Load() *Config { LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"), EmbedModel: getenv("EMBED_MODEL", "bge-m3"), - KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")), - KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")), + AssetRootDir: assetRootDir, + KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")), + KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(assetRootDir, "knowledge", "source")), + TrainingMaterialsDir: getenv("TRAINING_MATERIALS_DIR", filepath.Join(assetRootDir, "training", "materials")), // 备份目录必须落在 data/ 下:systemd 单元是 ProtectSystem=strict, // 只有 data/ 可写(ReadWritePaths),换到别处会静默失败。 @@ -96,6 +101,10 @@ func backendBaseDir() string { return "." } +func appRootDir(baseDir string) string { + return filepath.Clean(filepath.Join(baseDir, "..")) +} + func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v diff --git a/eai_agentplatform/backend-go/internal/config/json_loader.go b/eai_agentplatform/backend-go/internal/config/json_loader.go index 0ebf67a..9790444 100644 --- a/eai_agentplatform/backend-go/internal/config/json_loader.go +++ b/eai_agentplatform/backend-go/internal/config/json_loader.go @@ -408,56 +408,6 @@ func GetFallbackRoutes(primaryRouteID string) ([]*RouteConfig, error) { return result, nil } -// GetAllRoutes 返回所有定义的路由(跨分类) -func GetAllRoutes() ([]*RouteConfig, error) { - aiCfg, err := LoadAIConfig() - if err != nil { - return nil, err - } - var result []*RouteConfig - seen := make(map[string]bool) - - addRoutes := func(routes map[string]RouteInfo, category string) { - for rid := range routes { - if seen[rid] { - continue - } - seen[rid] = true - r, err := GetRoute(rid) - if err != nil { - continue - } - r.Category = category - result = append(result, r) - } - } - - addRoutes(aiCfg.ChatRoutes, "chat") - addRoutes(aiCfg.EmbedRoutes, "embed") - addRoutes(aiCfg.ImageRoutes, "image") - // 兼容旧版 - if aiCfg.Routes != nil { - for rid, info := range aiCfg.Routes { - if seen[rid] { - continue - } - seen[rid] = true - cat := info.Category - if cat == "" { - cat = "general" - } - r, err := GetRoute(rid) - if err != nil { - continue - } - r.Category = cat - result = append(result, r) - } - } - - return result, nil -} - // GetRoutesByCategory 按分类返回路由 func GetRoutesByCategory(category string) ([]*RouteConfig, error) { aiCfg, err := LoadAIConfig() diff --git a/eai_agentplatform/backend-go/internal/connector/kingdee_test.go b/eai_agentplatform/backend-go/internal/connector/kingdee_test.go deleted file mode 100644 index b90823b..0000000 --- a/eai_agentplatform/backend-go/internal/connector/kingdee_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package connector - -import ( - "context" - "testing" - - "eai_agentplatform/backend/internal/config" -) - -func TestKingdeeDefinitionDefaultsToDemo(t *testing.T) { - definition := NewKingdee(&config.Config{}).Definition() - if definition.Key != "kingdee" { - t.Fatalf("unexpected key: %s", definition.Key) - } - if definition.Direction != "input" { - t.Fatalf("expected input direction, got %s", definition.Direction) - } - if definition.Mode != "demo" { - t.Fatalf("expected demo mode, got %s", definition.Mode) - } - if len(definition.Objects) == 0 { - t.Fatal("expected predefined objects") - } -} - -func TestKingdeeQueryDemoSelectsFields(t *testing.T) { - result, err := NewKingdee(&config.Config{}).Query(context.Background(), QueryRequest{ - ObjectKey: "sales_order", - FieldKeys: []string{"FBillNo", "FCustId.FNumber"}, - Limit: 1, - }) - if err != nil { - t.Fatalf("query failed: %v", err) - } - if result.Mode != "demo" { - t.Fatalf("expected demo mode, got %s", result.Mode) - } - - records, ok := result.Outputs["records"].([]map[string]any) - if !ok { - t.Fatalf("records type mismatch: %T", result.Outputs["records"]) - } - if len(records) != 1 { - t.Fatalf("expected 1 record, got %d", len(records)) - } - if _, exists := records[0]["FBillNo"]; !exists { - t.Fatal("expected FBillNo field") - } - if _, exists := records[0]["FCustId.FNumber"]; !exists { - t.Fatal("expected FCustId.FNumber field") - } - if _, exists := records[0]["FDate"]; exists { - t.Fatal("did not expect FDate field in selected output") - } -} - -func TestListDefinitionsContainsInputAndOutputConnectors(t *testing.T) { - definitions := ListDefinitions(&config.Config{}) - if len(definitions) < 8 { - t.Fatalf("expected multiple connectors, got %d", len(definitions)) - } - - foundInput := false - foundOutput := false - for _, item := range definitions { - if item.Direction == "input" { - foundInput = true - } - if item.Direction == "output" { - foundOutput = true - } - } - if !foundInput || !foundOutput { - t.Fatalf("expected both input and output connectors, input=%v output=%v", foundInput, foundOutput) - } -} - -func TestStaticInputConnectorQueryDemo(t *testing.T) { - result, err := Query(context.Background(), &config.Config{}, "wecom_sheet_input", QueryRequest{ - ObjectKey: "sheet_record", - FieldKeys: []string{"record_id", "title"}, - Limit: 1, - }) - if err != nil { - t.Fatalf("query failed: %v", err) - } - if result.ConnectorKey != "wecom_sheet_input" { - t.Fatalf("unexpected connector key: %s", result.ConnectorKey) - } - if result.ObjectKey != "sheet_record" { - t.Fatalf("unexpected object key: %s", result.ObjectKey) - } -} diff --git a/eai_agentplatform/backend-go/internal/api/connectors.go b/eai_agentplatform/backend-go/internal/connectors/api/handlers.go similarity index 61% rename from eai_agentplatform/backend-go/internal/api/connectors.go rename to eai_agentplatform/backend-go/internal/connectors/api/handlers.go index b39e511..2296fec 100644 --- a/eai_agentplatform/backend-go/internal/api/connectors.go +++ b/eai_agentplatform/backend-go/internal/connectors/api/handlers.go @@ -1,4 +1,4 @@ -package api +package connectorapi import ( "errors" @@ -6,13 +6,21 @@ import ( "github.com/gin-gonic/gin" - "eai_agentplatform/backend/internal/connector" + "eai_agentplatform/backend/internal/config" + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" + connectorregistry "eai_agentplatform/backend/internal/connectors/registry" "eai_agentplatform/backend/internal/web" ) +var runtimeCfg *config.Config + +func UseConfig(cfg *config.Config) { + runtimeCfg = cfg +} + // ListConnectors GET /api/connectors func ListConnectors(c *gin.Context) { - web.OK(c, connector.ListDefinitions(Cfg)) + web.OK(c, connectorregistry.ListDefinitions(runtimeCfg)) } // GetConnector GET /api/connectors/:key @@ -22,7 +30,7 @@ func GetConnector(c *gin.Context) { web.Fail(c, web.NewBadRequest("连接器 key 不能为空")) return } - definition, ok := connector.GetDefinition(Cfg, key) + definition, ok := connectorregistry.GetDefinition(runtimeCfg, key) if !ok { web.Fail(c, web.NewNotFoundError("连接器不存在")) return @@ -38,17 +46,17 @@ func QueryConnector(c *gin.Context) { return } - var req connector.QueryRequest + var req connectorcontracts.QueryRequest if err := c.ShouldBindJSON(&req); err != nil { web.Fail(c, web.NewBadRequest("请求参数错误")) return } - result, err := connector.Query(c.Request.Context(), Cfg, key, req) + result, err := connectorregistry.Query(c.Request.Context(), runtimeCfg, key, req) if err != nil { switch { - case errors.Is(err, connector.ErrConnectorNotFound): + case errors.Is(err, connectorcontracts.ErrConnectorNotFound): web.Fail(c, web.NewNotFoundError("连接器不存在")) - case errors.Is(err, connector.ErrConnectorQueryUnsupported): + case errors.Is(err, connectorcontracts.ErrConnectorQueryUnsupported): web.Fail(c, web.NewBadRequest("该连接器当前不支持 query,请在动作面板中作为输出连接器使用")) default: web.Fail(c, web.NewBadRequest(err.Error())) diff --git a/eai_agentplatform/backend-go/internal/connectors/core/contracts.go b/eai_agentplatform/backend-go/internal/connectors/core/contracts.go new file mode 100644 index 0000000..dc2678a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/core/contracts.go @@ -0,0 +1,16 @@ +package connectorcore + +type SharedReferencePolicy string + +const ( + SharedReferenceReadonlyHistory SharedReferencePolicy = "readonly_history" + SharedReferenceTombstone SharedReferencePolicy = "convert_to_tombstone" + SharedReferenceDetach SharedReferencePolicy = "detach_reference" + SharedReferenceBlockUninstall SharedReferencePolicy = "block_uninstall" +) + +type UninstallMeta struct { + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} diff --git a/eai_agentplatform/backend-go/internal/connector/types.go b/eai_agentplatform/backend-go/internal/connectors/core/contracts/contracts.go similarity index 69% rename from eai_agentplatform/backend-go/internal/connector/types.go rename to eai_agentplatform/backend-go/internal/connectors/core/contracts/contracts.go index 4bd7063..b9774ff 100644 --- a/eai_agentplatform/backend-go/internal/connector/types.go +++ b/eai_agentplatform/backend-go/internal/connectors/core/contracts/contracts.go @@ -1,12 +1,14 @@ -package connector +package contracts -import ( - "context" - "strings" +import "errors" - "eai_agentplatform/backend/internal/config" -) +// ErrConnectorNotFound 表示连接器键不存在。 +var ErrConnectorNotFound = errors.New("connector not found") +// ErrConnectorQueryUnsupported 表示连接器不支持查询。 +var ErrConnectorQueryUnsupported = errors.New("connector query unsupported") + +// Definition 描述一个连接器的面向用户定义。 type Definition struct { Key string `json:"key"` Label string `json:"label"` @@ -22,6 +24,7 @@ type Definition struct { Objects []ObjectDefinition `json:"objects"` } +// ActionDefinition 描述一个连接器动作。 type ActionDefinition struct { Key string `json:"key"` Label string `json:"label"` @@ -29,6 +32,7 @@ type ActionDefinition struct { Description string `json:"description"` } +// ObjectDefinition 描述一个连接器可操作的对象。 type ObjectDefinition struct { Key string `json:"key"` Label string `json:"label"` @@ -39,6 +43,7 @@ type ObjectDefinition struct { FilterHint string `json:"filter_hint"` } +// QueryRequest 描述一次连接器查询请求。 type QueryRequest struct { ObjectKey string `json:"object_key"` FormID string `json:"form_id"` @@ -51,6 +56,7 @@ type QueryRequest struct { Filters map[string]any `json:"filters"` } +// QueryResult 描述一次连接器查询结果。 type QueryResult struct { ConnectorKey string `json:"connector_key"` ConnectorLabel string `json:"connector_label"` @@ -63,32 +69,3 @@ type QueryResult struct { Citations []map[string]any `json:"citations"` Risks []map[string]any `json:"risks"` } - -func ListDefinitions(cfg *config.Config) []Definition { - definitions := registry(cfg) - items := make([]Definition, 0, len(connectorOrder)) - for _, key := range connectorOrder { - if item, ok := definitions[key]; ok { - items = append(items, item) - } - } - return items -} - -func GetDefinition(cfg *config.Config, key string) (Definition, bool) { - key = strings.TrimSpace(strings.ToLower(key)) - item, ok := registry(cfg)[key] - return item, ok -} - -func Query(ctx context.Context, cfg *config.Config, key string, req QueryRequest) (QueryResult, error) { - key = strings.TrimSpace(strings.ToLower(key)) - switch key { - case "kingdee": - return NewKingdee(cfg).Query(ctx, req) - case "wms_input", "mes_input", "mail163_input", "dingtalk_table_input", "feishu_bitable_input", "wecom_sheet_input": - return queryStaticConnector(cfg, key, req) - default: - return QueryResult{}, ErrConnectorNotFound - } -} diff --git a/eai_agentplatform/backend-go/internal/connectors/core/manifest.go b/eai_agentplatform/backend-go/internal/connectors/core/manifest.go new file mode 100644 index 0000000..8c20926 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/core/manifest.go @@ -0,0 +1,17 @@ +package connectorcore + +type Manifest struct { + Key string + Label string + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} + +func (m Manifest) UninstallMeta() UninstallMeta { + return UninstallMeta{ + OwnedDefinitionKeys: append([]string(nil), m.OwnedDefinitionKeys...), + OwnedCatalogEntries: append([]string(nil), m.OwnedCatalogEntries...), + ReferencePolicy: m.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/connectors/core/registry.go b/eai_agentplatform/backend-go/internal/connectors/core/registry.go new file mode 100644 index 0000000..f212545 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/core/registry.go @@ -0,0 +1,36 @@ +package connectorcore + +type Registry struct { + manifests []Manifest + byKey map[string]Manifest +} + +func NewRegistry(manifests ...Manifest) *Registry { + items := make([]Manifest, 0, len(manifests)) + byKey := make(map[string]Manifest, len(manifests)) + for _, manifest := range manifests { + if manifest.Key == "" { + continue + } + items = append(items, manifest) + byKey[manifest.Key] = manifest + } + return &Registry{manifests: items, byKey: byKey} +} + +func (r *Registry) Manifests() []Manifest { + if r == nil { + return nil + } + items := make([]Manifest, len(r.manifests)) + copy(items, r.manifests) + return items +} + +func (r *Registry) ByKey(key string) (Manifest, bool) { + if r == nil { + return Manifest{}, false + } + item, ok := r.byKey[key] + return item, ok +} diff --git a/eai_agentplatform/backend-go/internal/connectors/core/uninstall.go b/eai_agentplatform/backend-go/internal/connectors/core/uninstall.go new file mode 100644 index 0000000..e92cb29 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/core/uninstall.go @@ -0,0 +1,16 @@ +package connectorcore + +type UninstallPreview struct { + DefinitionKeys []string + CatalogEntries []string + Policy SharedReferencePolicy +} + +func BuildUninstallPreview(manifest Manifest) UninstallPreview { + meta := manifest.UninstallMeta() + return UninstallPreview{ + DefinitionKeys: meta.OwnedDefinitionKeys, + CatalogEntries: meta.OwnedCatalogEntries, + Policy: meta.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/connector/kingdee.go b/eai_agentplatform/backend-go/internal/connectors/packages/kingdee/kingdee.go similarity index 81% rename from eai_agentplatform/backend-go/internal/connector/kingdee.go rename to eai_agentplatform/backend-go/internal/connectors/packages/kingdee/kingdee.go index e2f621a..fa344b7 100644 --- a/eai_agentplatform/backend-go/internal/connector/kingdee.go +++ b/eai_agentplatform/backend-go/internal/connectors/packages/kingdee/kingdee.go @@ -1,4 +1,4 @@ -package connector +package kingdee import ( "bytes" @@ -13,17 +13,19 @@ import ( "time" "eai_agentplatform/backend/internal/config" + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" + connectorquery "eai_agentplatform/backend/internal/connectors/query" ) -type KingdeeConnector struct { +type Connector struct { cfg *config.Config } -func NewKingdee(cfg *config.Config) *KingdeeConnector { - return &KingdeeConnector{cfg: cfg} +func New(cfg *config.Config) *Connector { + return &Connector{cfg: cfg} } -func (k *KingdeeConnector) Definition() Definition { +func (k *Connector) Definition() connectorcontracts.Definition { mode := "demo" status := "not_configured" if k.isConfigured() { @@ -31,13 +33,13 @@ func (k *KingdeeConnector) Definition() Definition { status = "ready" } - objects := make([]ObjectDefinition, 0, len(kingdeeObjectCatalog)) + objects := make([]connectorcontracts.ObjectDefinition, 0, len(kingdeeObjectCatalog)) for _, item := range kingdeeObjectOrder { obj := kingdeeObjectCatalog[item] objects = append(objects, obj) } - return Definition{ + return connectorcontracts.Definition{ Key: "kingdee", Label: "金蝶云星空读取连接器", Vendor: "Kingdee", @@ -48,7 +50,7 @@ func (k *KingdeeConnector) Definition() Definition { Status: status, AuthConfigured: k.isConfigured(), Capabilities: []string{"read", "query", "schema_hint"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "fetch_customer", Label: "读取客户", ActionType: "FetchCustomer", Description: "从金蝶读取客户主数据"}, {Key: "fetch_material", Label: "读取物料", ActionType: "FetchMaterial", Description: "从金蝶读取物料主数据"}, {Key: "fetch_sales_order", Label: "读取销售订单", ActionType: "FetchSalesOrder", Description: "从金蝶读取销售订单"}, @@ -59,16 +61,16 @@ func (k *KingdeeConnector) Definition() Definition { } } -func (k *KingdeeConnector) Query(ctx context.Context, req QueryRequest) (QueryResult, error) { +func (k *Connector) Query(ctx context.Context, req connectorcontracts.QueryRequest) (connectorcontracts.QueryResult, error) { objectDef, err := k.resolveObject(req.ObjectKey, req.FormID) if err != nil { - return QueryResult{}, err + return connectorcontracts.QueryResult{}, err } req.ObjectKey = objectDef.Key - req.FormID = firstNonEmpty(req.FormID, objectDef.RecommendedFormID) - req.FieldKeys = normalizeFieldKeys(req.FieldKeys, objectDef.DefaultFields) - req.Limit = normalizeLimit(req.Limit) + req.FormID = connectorquery.FirstNonEmpty(req.FormID, objectDef.RecommendedFormID) + req.FieldKeys = connectorquery.NormalizeFieldKeys(req.FieldKeys, objectDef.DefaultFields) + req.Limit = connectorquery.NormalizeLimit(req.Limit) if req.StartRow < 0 { req.StartRow = 0 } @@ -79,11 +81,11 @@ func (k *KingdeeConnector) Query(ctx context.Context, req QueryRequest) (QueryRe return k.queryLive(ctx, objectDef, req) } -func (k *KingdeeConnector) queryDemo(objectDef ObjectDefinition, req QueryRequest) QueryResult { - sourceRows := cloneRows(kingdeeDemoData[objectDef.Key]) - records := sliceRows(sourceRows, req.StartRow, req.Limit) +func (k *Connector) queryDemo(objectDef connectorcontracts.ObjectDefinition, req connectorcontracts.QueryRequest) connectorcontracts.QueryResult { + sourceRows := connectorquery.CloneRows(kingdeeDemoData[objectDef.Key]) + records := connectorquery.SliceRows(sourceRows, req.StartRow, req.Limit) if len(req.FieldKeys) > 0 { - records = selectFields(records, req.FieldKeys) + records = connectorquery.SelectFields(records, req.FieldKeys) } risks := []map[string]any{ @@ -101,13 +103,13 @@ func (k *KingdeeConnector) queryDemo(objectDef ObjectDefinition, req QueryReques }) } - return QueryResult{ + return connectorcontracts.QueryResult{ ConnectorKey: "kingdee", ConnectorLabel: "金蝶云星空读取连接器", ObjectKey: objectDef.Key, ObjectLabel: objectDef.Label, Mode: "demo", - Request: buildRequestEcho(req), + Request: connectorquery.BuildRequestEcho(req), Outputs: map[string]any{ "form_id": req.FormID, "field_keys": req.FieldKeys, @@ -128,27 +130,27 @@ func (k *KingdeeConnector) queryDemo(objectDef ObjectDefinition, req QueryReques } } -func (k *KingdeeConnector) queryLive(ctx context.Context, objectDef ObjectDefinition, req QueryRequest) (QueryResult, error) { +func (k *Connector) queryLive(ctx context.Context, objectDef connectorcontracts.ObjectDefinition, req connectorcontracts.QueryRequest) (connectorcontracts.QueryResult, error) { client, err := k.newHTTPClient() if err != nil { - return QueryResult{}, err + return connectorcontracts.QueryResult{}, err } if err := k.login(ctx, client); err != nil { - return QueryResult{}, err + return connectorcontracts.QueryResult{}, err } records, err := k.executeBillQuery(ctx, client, req) if err != nil { - return QueryResult{}, err + return connectorcontracts.QueryResult{}, err } - return QueryResult{ + return connectorcontracts.QueryResult{ ConnectorKey: "kingdee", ConnectorLabel: "金蝶云星空读取连接器", ObjectKey: objectDef.Key, ObjectLabel: objectDef.Label, Mode: "live", - Request: buildRequestEcho(req), + Request: connectorquery.BuildRequestEcho(req), Outputs: map[string]any{ "form_id": req.FormID, "field_keys": req.FieldKeys, @@ -168,7 +170,7 @@ func (k *KingdeeConnector) queryLive(ctx context.Context, objectDef ObjectDefini }, nil } -func (k *KingdeeConnector) newHTTPClient() (*http.Client, error) { +func (k *Connector) newHTTPClient() (*http.Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err @@ -183,7 +185,7 @@ func (k *KingdeeConnector) newHTTPClient() (*http.Client, error) { }, nil } -func (k *KingdeeConnector) login(ctx context.Context, client *http.Client) error { +func (k *Connector) login(ctx context.Context, client *http.Client) error { loginURL := sanitizeKingdeeBaseURL(k.cfg.KingdeeBaseURL) + "Kingdee.BOS.WebApi.ServicesStub.AuthService.ValidateUser.common.kdsvc" payloads := []any{ map[string]any{ @@ -219,7 +221,7 @@ func (k *KingdeeConnector) login(ctx context.Context, client *http.Client) error return lastErr } -func (k *KingdeeConnector) executeBillQuery(ctx context.Context, client *http.Client, req QueryRequest) ([]map[string]any, error) { +func (k *Connector) executeBillQuery(ctx context.Context, client *http.Client, req connectorcontracts.QueryRequest) ([]map[string]any, error) { queryURL := sanitizeKingdeeBaseURL(k.cfg.KingdeeBaseURL) + "Kingdee.BOS.WebApi.ServicesStub.DynamicFormService.ExecuteBillQuery.common.kdsvc" payload := map[string]any{ "FormId": req.FormID, @@ -249,18 +251,18 @@ func (k *KingdeeConnector) executeBillQuery(ctx context.Context, client *http.Cl return rows, nil } -func (k *KingdeeConnector) resolveObject(objectKey string, formID string) (ObjectDefinition, error) { +func (k *Connector) resolveObject(objectKey string, formID string) (connectorcontracts.ObjectDefinition, error) { objectKey = strings.TrimSpace(objectKey) if objectKey != "" { if objectDef, ok := kingdeeObjectCatalog[objectKey]; ok { return objectDef, nil } - return ObjectDefinition{}, fmt.Errorf("unsupported kingdee object: %s", objectKey) + return connectorcontracts.ObjectDefinition{}, fmt.Errorf("unsupported kingdee object: %s", objectKey) } if strings.TrimSpace(formID) == "" { - return ObjectDefinition{}, errors.New("object_key 或 form_id 至少填写一个") + return connectorcontracts.ObjectDefinition{}, errors.New("object_key 或 form_id 至少填写一个") } - return ObjectDefinition{ + return connectorcontracts.ObjectDefinition{ Key: "custom_query", Label: "自定义表单查询", Description: "通过自定义 FormId 执行 ExecuteBillQuery", @@ -271,13 +273,25 @@ func (k *KingdeeConnector) resolveObject(objectKey string, formID string) (Objec }, nil } -func (k *KingdeeConnector) isConfigured() bool { +func (k *Connector) isConfigured() bool { return strings.TrimSpace(k.cfg.KingdeeBaseURL) != "" && strings.TrimSpace(k.cfg.KingdeeAccountID) != "" && strings.TrimSpace(k.cfg.KingdeeUsername) != "" && strings.TrimSpace(k.cfg.KingdeePassword) != "" } +func sanitizeKingdeeBaseURL(base string) string { + base = strings.TrimSpace(base) + if base == "" { + return "" + } + base = strings.TrimRight(base, "/") + if !strings.Contains(strings.ToLower(base), "/k3cloud") { + base += "/K3Cloud" + } + return strings.TrimRight(base, "/") + "/" +} + func postJSON(ctx context.Context, client *http.Client, url string, payload any) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { @@ -434,101 +448,6 @@ func zipRow(fieldKeys []string, values []any) map[string]any { return row } -func normalizeFieldKeys(fieldKeys []string, defaults []string) []string { - normalized := make([]string, 0, len(fieldKeys)) - for _, item := range fieldKeys { - item = strings.TrimSpace(item) - if item != "" { - normalized = append(normalized, item) - } - } - if len(normalized) == 0 { - return append([]string{}, defaults...) - } - return normalized -} - -func normalizeLimit(limit int) int { - switch { - case limit <= 0: - return 20 - case limit > 2000: - return 2000 - default: - return limit - } -} - -func buildRequestEcho(req QueryRequest) map[string]any { - return map[string]any{ - "object_key": req.ObjectKey, - "form_id": req.FormID, - "field_keys": req.FieldKeys, - "filter_string": req.FilterString, - "order_string": req.OrderString, - "start_row": req.StartRow, - "limit": req.Limit, - "use_demo": req.UseDemo, - } -} - -func sanitizeKingdeeBaseURL(base string) string { - base = strings.TrimSpace(base) - if base == "" { - return "" - } - base = strings.TrimRight(base, "/") - if !strings.Contains(strings.ToLower(base), "/k3cloud") { - base += "/K3Cloud" - } - return strings.TrimRight(base, "/") + "/" -} - -func cloneRows(rows []map[string]any) []map[string]any { - cloned := make([]map[string]any, 0, len(rows)) - for _, row := range rows { - copyRow := make(map[string]any, len(row)) - for key, value := range row { - copyRow[key] = value - } - cloned = append(cloned, copyRow) - } - return cloned -} - -func sliceRows(rows []map[string]any, startRow int, limit int) []map[string]any { - if startRow >= len(rows) { - return []map[string]any{} - } - end := startRow + limit - if end > len(rows) { - end = len(rows) - } - return rows[startRow:end] -} - -func selectFields(rows []map[string]any, fieldKeys []string) []map[string]any { - selected := make([]map[string]any, 0, len(rows)) - for _, row := range rows { - item := make(map[string]any, len(fieldKeys)) - for _, fieldKey := range fieldKeys { - item[fieldKey] = row[fieldKey] - } - selected = append(selected, item) - } - return selected -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - value = strings.TrimSpace(value) - if value != "" { - return value - } - } - return "" -} - func stringMustJSON(v any) string { data, err := json.Marshal(v) if err != nil { @@ -547,7 +466,7 @@ var kingdeeObjectOrder = []string{ "receivable", } -var kingdeeObjectCatalog = map[string]ObjectDefinition{ +var kingdeeObjectCatalog = map[string]connectorcontracts.ObjectDefinition{ "customer": { Key: "customer", Label: "客户主数据", diff --git a/eai_agentplatform/backend-go/internal/connectors/packages/kingdee/kingdee_test.go b/eai_agentplatform/backend-go/internal/connectors/packages/kingdee/kingdee_test.go new file mode 100644 index 0000000..c756113 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/packages/kingdee/kingdee_test.go @@ -0,0 +1,56 @@ +package kingdee + +import ( + "context" + "testing" + + "eai_agentplatform/backend/internal/config" + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" +) + +func TestDefinitionDefaultsToDemo(t *testing.T) { + definition := New(&config.Config{}).Definition() + if definition.Key != "kingdee" { + t.Fatalf("unexpected key: %s", definition.Key) + } + if definition.Direction != "input" { + t.Fatalf("expected input direction, got %s", definition.Direction) + } + if definition.Mode != "demo" { + t.Fatalf("expected demo mode, got %s", definition.Mode) + } + if len(definition.Objects) == 0 { + t.Fatal("expected predefined objects") + } +} + +func TestQueryDemoSelectsFields(t *testing.T) { + result, err := New(&config.Config{}).Query(context.Background(), connectorcontracts.QueryRequest{ + ObjectKey: "sales_order", + FieldKeys: []string{"FBillNo", "FCustId.FNumber"}, + Limit: 1, + }) + if err != nil { + t.Fatalf("query failed: %v", err) + } + if result.Mode != "demo" { + t.Fatalf("expected demo mode, got %s", result.Mode) + } + + records, ok := result.Outputs["records"].([]map[string]any) + if !ok { + t.Fatalf("records type mismatch: %T", result.Outputs["records"]) + } + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + if _, exists := records[0]["FBillNo"]; !exists { + t.Fatal("expected FBillNo field") + } + if _, exists := records[0]["FCustId.FNumber"]; !exists { + t.Fatal("expected FCustId.FNumber field") + } + if _, exists := records[0]["FDate"]; exists { + t.Fatal("did not expect FDate field in selected output") + } +} diff --git a/eai_agentplatform/backend-go/internal/connectors/query/query.go b/eai_agentplatform/backend-go/internal/connectors/query/query.go new file mode 100644 index 0000000..fa445c1 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/query/query.go @@ -0,0 +1,90 @@ +package connectorquery + +import ( + "strings" + + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" +) + +func FirstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +func NormalizeFieldKeys(fieldKeys []string, defaults []string) []string { + normalized := make([]string, 0, len(fieldKeys)) + for _, item := range fieldKeys { + item = strings.TrimSpace(item) + if item != "" { + normalized = append(normalized, item) + } + } + if len(normalized) == 0 { + return append([]string{}, defaults...) + } + return normalized +} + +func NormalizeLimit(limit int) int { + switch { + case limit <= 0: + return 20 + case limit > 2000: + return 2000 + default: + return limit + } +} + +func BuildRequestEcho(req connectorcontracts.QueryRequest) map[string]any { + return map[string]any{ + "object_key": req.ObjectKey, + "form_id": req.FormID, + "field_keys": req.FieldKeys, + "filter_string": req.FilterString, + "order_string": req.OrderString, + "start_row": req.StartRow, + "limit": req.Limit, + "use_demo": req.UseDemo, + } +} + +func CloneRows(rows []map[string]any) []map[string]any { + cloned := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + copyRow := make(map[string]any, len(row)) + for key, value := range row { + copyRow[key] = value + } + cloned = append(cloned, copyRow) + } + return cloned +} + +func SliceRows(rows []map[string]any, startRow int, limit int) []map[string]any { + if startRow >= len(rows) { + return []map[string]any{} + } + end := startRow + limit + if end > len(rows) { + end = len(rows) + } + return rows[startRow:end] +} + +func SelectFields(rows []map[string]any, fieldKeys []string) []map[string]any { + selected := make([]map[string]any, 0, len(rows)) + for _, row := range rows { + item := make(map[string]any, len(fieldKeys)) + for _, fieldKey := range fieldKeys { + item[fieldKey] = row[fieldKey] + } + selected = append(selected, item) + } + return selected +} diff --git a/eai_agentplatform/backend-go/internal/connector/static_catalog.go b/eai_agentplatform/backend-go/internal/connectors/registry/registry.go similarity index 83% rename from eai_agentplatform/backend-go/internal/connector/static_catalog.go rename to eai_agentplatform/backend-go/internal/connectors/registry/registry.go index 2348b06..1f8bf67 100644 --- a/eai_agentplatform/backend-go/internal/connector/static_catalog.go +++ b/eai_agentplatform/backend-go/internal/connectors/registry/registry.go @@ -1,17 +1,18 @@ -package connector +package connectorregistry import ( + "context" "errors" "fmt" "strings" "time" "eai_agentplatform/backend/internal/config" + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" + "eai_agentplatform/backend/internal/connectors/packages/kingdee" + "eai_agentplatform/backend/internal/connectors/query" ) -var ErrConnectorNotFound = errors.New("connector not found") -var ErrConnectorQueryUnsupported = errors.New("connector query unsupported") - var connectorOrder = []string{ "kingdee", "wms_input", @@ -25,9 +26,41 @@ var connectorOrder = []string{ "wecom_sheet_output", } -func registry(cfg *config.Config) map[string]Definition { - items := map[string]Definition{ - "kingdee": NewKingdee(cfg).Definition(), +// ListDefinitions 返回按稳定顺序排序的全部连接器定义。 +func ListDefinitions(cfg *config.Config) []connectorcontracts.Definition { + definitions := registry(cfg) + items := make([]connectorcontracts.Definition, 0, len(connectorOrder)) + for _, key := range connectorOrder { + if item, ok := definitions[key]; ok { + items = append(items, item) + } + } + return items +} + +// GetDefinition 按键返回单个连接器定义。 +func GetDefinition(cfg *config.Config, key string) (connectorcontracts.Definition, bool) { + key = strings.TrimSpace(strings.ToLower(key)) + item, ok := registry(cfg)[key] + return item, ok +} + +// Query 按连接器键派发查询。 +func Query(ctx context.Context, cfg *config.Config, key string, req connectorcontracts.QueryRequest) (connectorcontracts.QueryResult, error) { + key = strings.TrimSpace(strings.ToLower(key)) + switch key { + case "kingdee": + return kingdee.New(cfg).Query(ctx, req) + case "wms_input", "mes_input", "mail163_input", "dingtalk_table_input", "feishu_bitable_input", "wecom_sheet_input": + return queryStaticConnector(key, req) + default: + return connectorcontracts.QueryResult{}, connectorcontracts.ErrConnectorNotFound + } +} + +func registry(cfg *config.Config) map[string]connectorcontracts.Definition { + items := map[string]connectorcontracts.Definition{ + "kingdee": kingdee.New(cfg).Definition(), "wms_input": staticDefinitions["wms_input"], "mes_input": staticDefinitions["mes_input"], "mail163_input": staticDefinitions["mail163_input"], @@ -41,39 +74,39 @@ func registry(cfg *config.Config) map[string]Definition { return items } -func queryStaticConnector(cfg *config.Config, key string, req QueryRequest) (QueryResult, error) { - definition, ok := registry(cfg)[key] +func queryStaticConnector(key string, req connectorcontracts.QueryRequest) (connectorcontracts.QueryResult, error) { + definition, ok := staticDefinitions[key] if !ok { - return QueryResult{}, ErrConnectorNotFound + return connectorcontracts.QueryResult{}, connectorcontracts.ErrConnectorNotFound } if definition.Direction != "input" { - return QueryResult{}, ErrConnectorQueryUnsupported + return connectorcontracts.QueryResult{}, connectorcontracts.ErrConnectorQueryUnsupported } objectDef, err := resolveStaticObject(definition, req.ObjectKey) if err != nil { - return QueryResult{}, err + return connectorcontracts.QueryResult{}, err } req.ObjectKey = objectDef.Key - req.FieldKeys = normalizeFieldKeys(req.FieldKeys, objectDef.DefaultFields) - req.Limit = normalizeLimit(req.Limit) + req.FieldKeys = connectorquery.NormalizeFieldKeys(req.FieldKeys, objectDef.DefaultFields) + req.Limit = connectorquery.NormalizeLimit(req.Limit) if req.StartRow < 0 { req.StartRow = 0 } - sourceRows := cloneRows(staticDemoRows[key][objectDef.Key]) - records := sliceRows(sourceRows, req.StartRow, req.Limit) + sourceRows := connectorquery.CloneRows(staticDemoRows[key][objectDef.Key]) + records := connectorquery.SliceRows(sourceRows, req.StartRow, req.Limit) if len(req.FieldKeys) > 0 { - records = selectFields(records, req.FieldKeys) + records = connectorquery.SelectFields(records, req.FieldKeys) } - return QueryResult{ + return connectorcontracts.QueryResult{ ConnectorKey: definition.Key, ConnectorLabel: definition.Label, ObjectKey: objectDef.Key, ObjectLabel: objectDef.Label, Mode: definition.Mode, - Request: buildRequestEcho(req), + Request: connectorquery.BuildRequestEcho(req), Outputs: map[string]any{ "count": len(records), "total": len(sourceRows), @@ -98,11 +131,11 @@ func queryStaticConnector(cfg *config.Config, key string, req QueryRequest) (Que }, nil } -func resolveStaticObject(definition Definition, objectKey string) (ObjectDefinition, error) { +func resolveStaticObject(definition connectorcontracts.Definition, objectKey string) (connectorcontracts.ObjectDefinition, error) { objectKey = strings.TrimSpace(objectKey) if objectKey == "" { if len(definition.Objects) == 0 { - return ObjectDefinition{}, errors.New("当前连接器未定义对象") + return connectorcontracts.ObjectDefinition{}, errors.New("当前连接器未定义对象") } return definition.Objects[0], nil } @@ -111,10 +144,10 @@ func resolveStaticObject(definition Definition, objectKey string) (ObjectDefinit return item, nil } } - return ObjectDefinition{}, fmt.Errorf("unsupported object for connector %s: %s", definition.Key, objectKey) + return connectorcontracts.ObjectDefinition{}, fmt.Errorf("unsupported object for connector %s: %s", definition.Key, objectKey) } -var staticDefinitions = map[string]Definition{ +var staticDefinitions = map[string]connectorcontracts.Definition{ "wms_input": { Key: "wms_input", Label: "WMS 输入连接器", @@ -126,12 +159,12 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"read", "query", "inventory"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "fetch_inventory_snapshot", Label: "读取库存快照", ActionType: "FetchInventorySnapshot", Description: "读取仓位库存与可用库存"}, {Key: "fetch_inbound_notice", Label: "读取入库通知", ActionType: "FetchInboundNotice", Description: "读取待入库单据"}, {Key: "fetch_outbound_task", Label: "读取出库任务", ActionType: "FetchOutboundTask", Description: "读取待执行出库任务"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "inventory_snapshot", Label: "库存快照", Description: "按仓位读取库存记录", Mode: "read", RecommendedFormID: "inventory_snapshot", DefaultFields: []string{"warehouse_code", "location_code", "sku", "on_hand_qty", "available_qty"}, FilterHint: "例如:available_qty > 0"}, {Key: "inbound_notice", Label: "入库通知", Description: "读取待收货单据", Mode: "read", RecommendedFormID: "inbound_notice", DefaultFields: []string{"notice_no", "supplier_name", "warehouse_code", "eta_date", "status"}, FilterHint: "例如:status = 'pending'"}, {Key: "outbound_task", Label: "出库任务", Description: "读取拣货与发运任务", Mode: "read", RecommendedFormID: "outbound_task", DefaultFields: []string{"task_no", "order_no", "warehouse_code", "pick_status", "carrier"}, FilterHint: "例如:pick_status != 'done'"}, @@ -148,12 +181,12 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"read", "query", "production"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "fetch_work_order", Label: "读取工单", ActionType: "FetchWorkOrder", Description: "读取生产工单及执行状态"}, {Key: "fetch_equipment_alarm", Label: "读取设备告警", ActionType: "FetchEquipmentAlarm", Description: "读取产线设备异常告警"}, {Key: "fetch_production_report", Label: "读取生产报工", ActionType: "FetchProductionReport", Description: "读取报工和产量数据"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "work_order", Label: "生产工单", Description: "读取工单状态和排产信息", Mode: "read", RecommendedFormID: "work_order", DefaultFields: []string{"work_order_no", "product_code", "line_code", "planned_qty", "status"}, FilterHint: "例如:status in ('released','running')"}, {Key: "equipment_alarm", Label: "设备告警", Description: "读取产线设备告警信息", Mode: "read", RecommendedFormID: "equipment_alarm", DefaultFields: []string{"alarm_no", "equipment_code", "alarm_level", "alarm_time", "status"}, FilterHint: "例如:status = 'open'"}, {Key: "production_report", Label: "生产报工", Description: "读取班次报工与产量统计", Mode: "read", RecommendedFormID: "production_report", DefaultFields: []string{"report_no", "work_order_no", "reported_qty", "qualified_qty", "shift_name"}, FilterHint: "例如:reported_qty > 0"}, @@ -170,12 +203,12 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"read", "imap", "attachment"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "fetch_inbox_mail", Label: "读取收件箱", ActionType: "FetchInboxMail", Description: "拉取收件箱邮件列表"}, {Key: "fetch_unread_mail", Label: "读取未读邮件", ActionType: "FetchUnreadMail", Description: "读取未读邮件与摘要"}, {Key: "fetch_mail_attachment", Label: "读取邮件附件", ActionType: "FetchMailAttachment", Description: "拉取指定邮件的附件元数据"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "inbox_message", Label: "收件箱邮件", Description: "读取收件箱邮件列表", Mode: "read", RecommendedFormID: "INBOX", DefaultFields: []string{"message_id", "from", "subject", "received_at", "has_attachment"}, FilterHint: "例如:UNSEEN"}, {Key: "unread_message", Label: "未读邮件", Description: "读取未读邮件和优先级", Mode: "read", RecommendedFormID: "UNSEEN", DefaultFields: []string{"message_id", "from", "subject", "priority", "received_at"}, FilterHint: "例如:SINCE 16-Aug-2026"}, {Key: "mail_attachment", Label: "邮件附件", Description: "读取邮件附件元数据", Mode: "read", RecommendedFormID: "ATTACHMENT", DefaultFields: []string{"message_id", "attachment_name", "attachment_size", "content_type"}, FilterHint: "例如:HASATTACHMENT"}, @@ -192,11 +225,11 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"read", "record", "sheet"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "list_table_records", Label: "读取表格记录", ActionType: "ListTableRecords", Description: "读取指定表中的多行记录"}, {Key: "get_table_schema", Label: "读取表结构", ActionType: "GetTableSchema", Description: "读取字段定义与主字段"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "sheet_record", Label: "表格记录", Description: "读取 AI 表格记录", Mode: "read", RecommendedFormID: "sheet_record", DefaultFields: []string{"record_id", "sheet_name", "title", "owner", "status"}, FilterHint: "例如:status = '进行中'"}, {Key: "field_schema", Label: "字段结构", Description: "读取 AI 表格字段结构", Mode: "read", RecommendedFormID: "field_schema", DefaultFields: []string{"field_id", "field_name", "field_type", "required"}, FilterHint: "例如:field_type = 'singleSelect'"}, }, @@ -212,11 +245,11 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"read", "record", "bitable"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "list_bitable_records", Label: "读取多维表记录", ActionType: "ListBitableRecords", Description: "读取飞书多维表格记录"}, {Key: "get_bitable_fields", Label: "读取字段定义", ActionType: "GetBitableFields", Description: "读取字段配置和类型"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "bitable_record", Label: "多维表记录", Description: "读取业务记录与状态字段", Mode: "read", RecommendedFormID: "bitable_record", DefaultFields: []string{"record_id", "table_name", "title", "owner", "status"}, FilterHint: "例如:status = '待跟进'"}, {Key: "field_schema", Label: "字段结构", Description: "读取字段与权限结构", Mode: "read", RecommendedFormID: "field_schema", DefaultFields: []string{"field_id", "field_name", "field_type", "is_primary"}, FilterHint: "例如:field_type = 'Text'"}, }, @@ -232,11 +265,11 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"read", "record", "smart_sheet"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "list_sheet_records", Label: "读取智能表格记录", ActionType: "ListSheetRecords", Description: "读取企微智能表格记录"}, {Key: "get_sheet_views", Label: "读取视图定义", ActionType: "GetSheetViews", Description: "读取表格视图和筛选规则"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "sheet_record", Label: "智能表格记录", Description: "读取行级业务记录", Mode: "read", RecommendedFormID: "sheet_record", DefaultFields: []string{"record_id", "sheet_name", "title", "owner", "progress"}, FilterHint: "例如:progress != '已完成'"}, {Key: "view_schema", Label: "视图定义", Description: "读取视图与权限信息", Mode: "read", RecommendedFormID: "view_schema", DefaultFields: []string{"view_id", "view_name", "view_type", "permission_scope"}, FilterHint: "例如:view_type = 'kanban'"}, }, @@ -252,12 +285,12 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"write", "upsert", "record"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "insert_table_record", Label: "新增记录", ActionType: "InsertTableRecord", Description: "向钉钉 AI 表格插入记录"}, {Key: "update_table_record", Label: "更新记录", ActionType: "UpdateTableRecord", Description: "更新现有记录"}, {Key: "append_result_report", Label: "追加结果报表", ActionType: "AppendResultReport", Description: "写入报表汇总行"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "sheet_record", Label: "表格记录", Description: "写入业务结果记录", Mode: "write", RecommendedFormID: "sheet_record", DefaultFields: []string{"title", "owner", "status", "updated_at"}, FilterHint: "按主键 upsert"}, }, }, @@ -272,12 +305,12 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"write", "upsert", "record"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "insert_bitable_record", Label: "新增记录", ActionType: "InsertBitableRecord", Description: "向飞书多维表格新增记录"}, {Key: "update_bitable_record", Label: "更新记录", ActionType: "UpdateBitableRecord", Description: "更新多维表格记录"}, {Key: "sync_result_view", Label: "同步结果视图", ActionType: "SyncResultView", Description: "将结果同步到指定视图"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "bitable_record", Label: "多维表记录", Description: "写入业务结果记录", Mode: "write", RecommendedFormID: "bitable_record", DefaultFields: []string{"title", "owner", "status", "updated_at"}, FilterHint: "按 record_id 或业务主键 upsert"}, }, }, @@ -292,12 +325,12 @@ var staticDefinitions = map[string]Definition{ Status: "blueprint", AuthConfigured: false, Capabilities: []string{"write", "upsert", "record"}, - Actions: []ActionDefinition{ + Actions: []connectorcontracts.ActionDefinition{ {Key: "insert_sheet_record", Label: "新增记录", ActionType: "InsertSheetRecord", Description: "向企微智能表格新增记录"}, {Key: "update_sheet_record", Label: "更新记录", ActionType: "UpdateSheetRecord", Description: "更新已有行"}, {Key: "notify_sheet_owner", Label: "通知表格负责人", ActionType: "NotifySheetOwner", Description: "结果写入后通知负责人"}, }, - Objects: []ObjectDefinition{ + Objects: []connectorcontracts.ObjectDefinition{ {Key: "sheet_record", Label: "智能表格记录", Description: "写入业务结果记录", Mode: "write", RecommendedFormID: "sheet_record", DefaultFields: []string{"title", "owner", "progress", "updated_at"}, FilterHint: "按业务键 upsert"}, }, }, diff --git a/eai_agentplatform/backend-go/internal/connectors/registry/registry_test.go b/eai_agentplatform/backend-go/internal/connectors/registry/registry_test.go new file mode 100644 index 0000000..1fefd7e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/connectors/registry/registry_test.go @@ -0,0 +1,47 @@ +package connectorregistry + +import ( + "context" + "testing" + + "eai_agentplatform/backend/internal/config" + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" +) + +func TestListDefinitionsContainsInputAndOutputConnectors(t *testing.T) { + definitions := ListDefinitions(&config.Config{}) + if len(definitions) < 8 { + t.Fatalf("expected multiple connectors, got %d", len(definitions)) + } + + foundInput := false + foundOutput := false + for _, item := range definitions { + if item.Direction == "input" { + foundInput = true + } + if item.Direction == "output" { + foundOutput = true + } + } + if !foundInput || !foundOutput { + t.Fatalf("expected both input and output connectors, input=%v output=%v", foundInput, foundOutput) + } +} + +func TestStaticInputConnectorQueryDemo(t *testing.T) { + result, err := Query(context.Background(), &config.Config{}, "wecom_sheet_input", connectorcontracts.QueryRequest{ + ObjectKey: "sheet_record", + FieldKeys: []string{"record_id", "title"}, + Limit: 1, + }) + if err != nil { + t.Fatalf("query failed: %v", err) + } + if result.ConnectorKey != "wecom_sheet_input" { + t.Fatalf("unexpected connector key: %s", result.ConnectorKey) + } + if result.ObjectKey != "sheet_record" { + t.Fatalf("unexpected object key: %s", result.ObjectKey) + } +} diff --git a/eai_agentplatform/backend-go/internal/jsonutil/jsonutil.go b/eai_agentplatform/backend-go/internal/jsonutil/jsonutil.go new file mode 100644 index 0000000..bcc08fb --- /dev/null +++ b/eai_agentplatform/backend-go/internal/jsonutil/jsonutil.go @@ -0,0 +1,96 @@ +// Package jsonutil 提供各对象目录(store、skills、specialists、xapps、api)共用的 +// JSON 序列化与合法性校验辅助函数。 +// +// 该包是最底层的中立工具包:不引用任何业务/域包,只依赖标准库,因此能被 +// store(最底层数据层)与各领域、api 层同时导入而不会产生 import 循环。 +// 各领域原来的 mustJSON / ValidateJSONObjectJSON / ValidateJSONStringArray / +// ValidateStructuredRecordsJSON 实现统一收敛到这里,避免重复。 +package jsonutil + +import ( + "encoding/json" + "strings" +) + +// MustJSON 序列化任意值;序列化失败时返回 "[]"(与历史行为一致)。 +func MustJSON(v any) string { + data, err := json.Marshal(v) + if err != nil { + return "[]" + } + return string(data) +} + +// ValidateObjectJSON 校验 value 是否为合法的「对象」JSON(空串视为合法)。 +func ValidateObjectJSON(value string) bool { + if trimJSON(value) == "" { + return true + } + var obj map[string]any + return json.Unmarshal([]byte(trimJSON(value)), &obj) == nil +} + +// ValidateStringArrayJSON 校验 value 是否为合法的「字符串数组」JSON(空串视为合法)。 +func ValidateStringArrayJSON(value string) bool { + if trimJSON(value) == "" { + return true + } + var arr []string + return json.Unmarshal([]byte(trimJSON(value)), &arr) == nil +} + +// ValidateStructuredRecordsJSON 校验 value 是否为合法的「结构化记录数组」JSON(空串视为合法)。 +func ValidateStructuredRecordsJSON(value string) bool { + if trimJSON(value) == "" { + return true + } + var arr []map[string]any + return json.Unmarshal([]byte(trimJSON(value)), &arr) == nil +} + +func trimJSON(value string) string { + return strings.TrimSpace(value) +} + +// ParseJSON 安全反序列化 JSON 字符串;空串返回 nil(不报错)。 +func ParseJSON(data string, v any) error { + if trimJSON(data) == "" { + return nil + } + return json.Unmarshal([]byte(data), v) +} + +// ParseStringArray 解析 JSON 字符串数组;空串返回 nil(不报错)。 +func ParseStringArray(data string) ([]string, error) { + if trimJSON(data) == "" { + return nil, nil + } + var arr []string + if err := json.Unmarshal([]byte(data), &arr); err != nil { + return nil, err + } + if arr == nil { + return []string{}, nil + } + return arr, nil +} + +// ParseObject 解析 JSON 对象;空串返回 nil map(不报错)。 +func ParseObject(data string) (map[string]any, error) { + if trimJSON(data) == "" { + return nil, nil + } + var obj map[string]any + if err := json.Unmarshal([]byte(data), &obj); err != nil { + return nil, err + } + return obj, nil +} + +// ParseArray 解析任意 JSON 数组;空串返回 nil(不报错)。 +func ParseArray(data string, v any) error { + if trimJSON(data) == "" { + return nil + } + return json.Unmarshal([]byte(data), v) +} diff --git a/eai_agentplatform/backend-go/internal/model/action_definition.go b/eai_agentplatform/backend-go/internal/model/action_definition.go index eae475e..b231e97 100644 --- a/eai_agentplatform/backend-go/internal/model/action_definition.go +++ b/eai_agentplatform/backend-go/internal/model/action_definition.go @@ -4,23 +4,23 @@ 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"` + 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:"column:input_schema_json;type:text" json:"input_schema_json"` // JSON → map[string]any + OutputSchemaJSON string `gorm:"column:output_schema_json;type:text" json:"output_schema_json"` // JSON → map[string]any + 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/ai_call_log.go b/eai_agentplatform/backend-go/internal/model/ai_call_log.go index d62937a..da39829 100644 --- a/eai_agentplatform/backend-go/internal/model/ai_call_log.go +++ b/eai_agentplatform/backend-go/internal/model/ai_call_log.go @@ -4,9 +4,9 @@ import "time" // AiCallLog AI 调用日志(审计 + 按用户算力点计费) type AiCallLog struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - Capability string `gorm:"size:32;not null;index" json:"capability"` // ai_chat / text_gen / embed + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + UsageKind string `gorm:"column:usage_kind;size:32;not null;index" json:"usage_kind"` // ai_chat / text_gen / embed // SpecialistKey 本次调用以哪个专员的身份进行;空 = 通用助手。 // 用来回答「这条回答是谁说的」,也是排查「专员没生效」的第一手线索。 SpecialistKey string `gorm:"size:64;index" json:"specialist_key"` diff --git a/eai_agentplatform/backend-go/internal/model/app_definition.go b/eai_agentplatform/backend-go/internal/model/app_definition.go deleted file mode 100644 index eec6b11..0000000 --- a/eai_agentplatform/backend-go/internal/model/app_definition.go +++ /dev/null @@ -1,38 +0,0 @@ -package model - -import "time" - -// AppDefinition 面向用户的一键应用目录定义。 -// 它是“成品入口”这一层:决定应用怎么展示、默认挂哪个专员/技能、打开后走哪条路。 -type AppDefinition 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"` - Badge string `gorm:"size:32;default:''" json:"badge"` - Kind string `gorm:"size:64;default:''" json:"kind"` - MarketTag string `gorm:"column:market_tag;size:32;default:'';index" json:"market_tag"` - WorkerType string `gorm:"size:16;default:'';index" json:"worker_type"` - Tier string `gorm:"size:16;default:'';index" json:"tier"` - Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom / imported - Color string `gorm:"size:32;default:''" json:"color"` - IconText string `gorm:"column:icon_text;size:16;default:''" json:"icon_text"` - CoverTone string `gorm:"column:cover_tone;type:text" json:"cover_tone"` - Summary string `gorm:"type:text" json:"summary"` - Description string `gorm:"type:text" json:"description"` - OpenRoute string `gorm:"column:open_route;size:256;default:''" json:"open_route"` - SpecialistKey string `gorm:"column:specialist_key;size:64;default:'';index" json:"specialist_key"` - SkillKey string `gorm:"column:skill_key;size:64;default:'';index" json:"skill_key"` - DefaultPrompt string `gorm:"column:default_prompt;type:text" json:"default_prompt"` - PromptsJSON string `gorm:"column:prompts_json;type:text" json:"prompts_json"` - TagsJSON string `gorm:"column:tags_json;type:text" json:"tags_json"` - InstallState string `gorm:"column:install_state;size:16;not null;default:installed;index" json:"install_state"` - ExposedToUser bool `gorm:"column:exposed_to_user;not null;default:true;index" json:"exposed_to_user"` - State string `gorm:"size:16;not null;default:active;index" json:"state"` // active / inactive - SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (AppDefinition) TableName() string { return "app_definition" } diff --git a/eai_agentplatform/backend-go/internal/model/exam_paper.go b/eai_agentplatform/backend-go/internal/model/exam_paper.go index f669411..7c33823 100644 --- a/eai_agentplatform/backend-go/internal/model/exam_paper.go +++ b/eai_agentplatform/backend-go/internal/model/exam_paper.go @@ -13,7 +13,7 @@ type ExamPaper struct { PassScore int `gorm:"not null" json:"pass_score"` DurationMinutes int `gorm:"not null" json:"duration_minutes"` Randomize bool `gorm:"not null" json:"randomize"` - PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题 + PositionID *uint `gorm:"index" json:"position_id"` // 关联岗位考试,可空;设置后按岗位知识映射抽题 Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/eai_agentplatform/backend-go/internal/model/exam_record.go b/eai_agentplatform/backend-go/internal/model/exam_record.go index 4ab2e12..b114cf9 100644 --- a/eai_agentplatform/backend-go/internal/model/exam_record.go +++ b/eai_agentplatform/backend-go/internal/model/exam_record.go @@ -14,7 +14,7 @@ type ExamRecord struct { Passed bool `gorm:"not null;index" json:"passed"` CorrectCount int `gorm:"not null" json:"correct_count"` WrongCount int `gorm:"not null" json:"wrong_count"` - DetailJSON string `gorm:"type:text;not null" json:"detail_json"` // 答题明细 JSON + DetailJSON string `gorm:"column:detail_json;type:text;not null" json:"detail_json"` // 答题明细 JSON → map[string]any SubmittedAt time.Time `gorm:"not null;index" json:"submitted_at"` } diff --git a/eai_agentplatform/backend-go/internal/model/knowledge_faq.go b/eai_agentplatform/backend-go/internal/model/knowledge_faq.go index bc81e2d..d6ae7f4 100644 --- a/eai_agentplatform/backend-go/internal/model/knowledge_faq.go +++ b/eai_agentplatform/backend-go/internal/model/knowledge_faq.go @@ -7,8 +7,8 @@ type KnowledgeFAQ struct { ID uint `gorm:"primaryKey" json:"id"` KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"` Question string `gorm:"size:512;not null" json:"question"` - Answer string `gorm:"type:text;not null" json:"answer"` - SimilarQuestions string `gorm:"type:text" json:"similar_questions"` + Answer string `gorm:"column:answer;type:text;not null" json:"answer"` + SimilarQuestions string `gorm:"column:similar_questions;type:text" json:"similar_questions"` // JSON → []string Keywords string `gorm:"size:512" json:"keywords"` Status string `gorm:"size:16;not null;default:active;index" json:"status"` SortOrder int `gorm:"not null;default:0" json:"sort_order"` diff --git a/eai_agentplatform/backend-go/internal/model/mistake_record.go b/eai_agentplatform/backend-go/internal/model/mistake_record.go index 2234ebb..b36c9f4 100644 --- a/eai_agentplatform/backend-go/internal/model/mistake_record.go +++ b/eai_agentplatform/backend-go/internal/model/mistake_record.go @@ -6,18 +6,18 @@ import "time" // 按 (user_id, question_id, source) 去重:同一学员同一题同一种考试只保留一条, // 再次答错时更新作答与解析并将 resolved 重置为 false。 type MistakeRecord struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - QuestionID uint `gorm:"not null;index" json:"question_id"` - Source string `gorm:"size:16;not null;index" json:"source"` // self_test / formal - QuestionType string `gorm:"size:16;not null" json:"question_type"` // single/multiple/judge/essay(冗余,供错题本独立展示) - QuestionStem string `gorm:"type:text;not null" json:"question_stem"` - UserAnswer string `gorm:"type:text" json:"user_answer"` // JSON 序列化的作答 - CorrectAnswer string `gorm:"type:text" json:"correct_answer"` // JSON 序列化的正确答案 / 评分标准 - Explanation string `gorm:"type:text;default:''" json:"explanation"` - Resolved bool `gorm:"not null;default:false;index" json:"resolved"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + QuestionID uint `gorm:"not null;index" json:"question_id"` + Source string `gorm:"size:16;not null;index" json:"source"` // self_test / formal + QuestionType string `gorm:"size:16;not null" json:"question_type"` // single/multiple/judge/essay + QuestionStem string `gorm:"type:text;not null" json:"question_stem"` + UserAnswer string `gorm:"column:user_answer;type:text" json:"user_answer"` // JSON 序列化 → 作答 (string, []string, bool) + CorrectAnswer string `gorm:"column:correct_answer;type:text" json:"correct_answer"` // JSON 序列化 → 正确答案 / 评分标准 []string + Explanation string `gorm:"type:text;default:''" json:"explanation"` + Resolved bool `gorm:"not null;default:false;index" json:"resolved"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (MistakeRecord) TableName() string { return "mistake_record" } diff --git a/eai_agentplatform/backend-go/internal/model/notification.go b/eai_agentplatform/backend-go/internal/model/notification.go deleted file mode 100644 index c2559c9..0000000 --- a/eai_agentplatform/backend-go/internal/model/notification.go +++ /dev/null @@ -1,17 +0,0 @@ -package model - -import "time" - -// Notification 站内消息通知:考试发布 / 考试通过发证 / 岗位设置等事件推送给学员。 -type Notification struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"not null;index" json:"user_id"` - Type string `gorm:"size:32;not null;index" json:"type"` // exam_publish / exam_pass / position_set - Title string `gorm:"size:128;not null" json:"title"` - Content string `gorm:"type:text;not null" json:"content"` - Link string `gorm:"size:128;default:''" json:"link"` // 前端跳转路由 - Read bool `gorm:"not null;default:false;index" json:"read"` - CreatedAt time.Time `json:"created_at"` -} - -func (Notification) TableName() string { return "notification" } diff --git a/eai_agentplatform/backend-go/internal/model/object_aliases.go b/eai_agentplatform/backend-go/internal/model/object_aliases.go new file mode 100644 index 0000000..7897fdb --- /dev/null +++ b/eai_agentplatform/backend-go/internal/model/object_aliases.go @@ -0,0 +1,13 @@ +package model + +import ( + xappmodel "eai_agentplatform/backend/internal/xapps/model" +) + +// XAppDefinition / UserXAppCenter 是 Batch D 已合并进 xapps 域模型的类型, +// store / seed 与迁移测试仍通过 model.XAppDefinition / model.UserXAppCenter 引用, +// 因此这里保留这两个类型别名以兼容既有调用点。 +// 其余对象(专员、技能等)已按「一对象一目录」各自落位,不再经 model 层转出, +// 调用方应直接 import 各对象域的 model 包。 +type XAppDefinition = xappmodel.XAppDefinition +type UserXAppCenter = xappmodel.UserXAppCenter diff --git a/eai_agentplatform/backend-go/internal/model/official_account_hotspot.go b/eai_agentplatform/backend-go/internal/model/official_account_hotspot.go deleted file mode 100644 index 16a8c8b..0000000 --- a/eai_agentplatform/backend-go/internal/model/official_account_hotspot.go +++ /dev/null @@ -1,25 +0,0 @@ -package model - -import "time" - -// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。 -type OfficialAccountHotspot struct { - ID uint `gorm:"primaryKey" json:"id"` - BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"` - SourceKey string `gorm:"size:64;not null;index;uniqueIndex:idx_hotspot_source_url" json:"source_key"` - SourceLabel string `gorm:"size:128;not null" json:"source_label"` - SourceType string `gorm:"size:16;not null" json:"source_type"` - Title string `gorm:"size:512;not null" json:"title"` - URL string `gorm:"size:1024;not null;uniqueIndex:idx_hotspot_source_url" json:"url"` - Summary string `gorm:"type:text" json:"summary"` - PublishedAt *time.Time `json:"published_at"` - RawScore float64 `gorm:"not null;default:0" json:"raw_score"` - DomainScore float64 `gorm:"not null;default:0" json:"domain_score"` - HeatLabel string `gorm:"size:32;default:''" json:"heat_label"` - TagsJSON string `gorm:"type:text" json:"tags_json"` - FetchedAt time.Time `gorm:"index" json:"fetched_at"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" } diff --git a/eai_agentplatform/backend-go/internal/model/position_knowledge.go b/eai_agentplatform/backend-go/internal/model/position_knowledge.go index d827b76..b78d7b7 100644 --- a/eai_agentplatform/backend-go/internal/model/position_knowledge.go +++ b/eai_agentplatform/backend-go/internal/model/position_knowledge.go @@ -7,9 +7,9 @@ import "time" type PositionKnowledge struct { ID uint `gorm:"primaryKey" json:"id"` PositionID uint `gorm:"not null;index" json:"position_id"` - Domain string `gorm:"size:16;not null;index" json:"domain"` // company / product / sales - CourseID *uint `gorm:"index" json:"course_id"` // 绑定具体课程,可空 - ProductID *uint `gorm:"index" json:"product_id"` // 绑定具体产品,可空 + Domain string `gorm:"size:16;not null;index" json:"domain"` // company / product / sales + CourseID *uint `gorm:"index" json:"course_id"` // 绑定具体课程,可空 + ProductID *uint `gorm:"index" json:"product_id"` // 绑定具体产品,可空 RequiredLevel string `gorm:"size:16;not null;default:L1" json:"required_level"` // L1/L2/L3/L4 Weight float64 `gorm:"not null;default:1" json:"weight"` IsMandatory bool `gorm:"not null;default:true" json:"is_mandatory"` diff --git a/eai_agentplatform/backend-go/internal/model/project.go b/eai_agentplatform/backend-go/internal/model/project.go index 00f5230..db934ef 100644 --- a/eai_agentplatform/backend-go/internal/model/project.go +++ b/eai_agentplatform/backend-go/internal/model/project.go @@ -6,7 +6,7 @@ import "time" // // 任务记「做的是哪件事」,项目记「这些事是为谁、按什么规矩做的」—— // 指令是给 AI 的长期上下文,预置的专员/技能/连接器是这个项目默认带上的能力。 -// 归属跟 WorkerTask.Owner 同构(全名优先,回退登录名),列表只列自己的。 +// 归属跟 TaskRecord.Owner 同构(全名优先,回退登录名),列表只列自己的。 type Project struct { ID uint `gorm:"primaryKey" json:"id"` Name string `gorm:"size:40;not null" json:"name"` @@ -14,11 +14,11 @@ type Project struct { 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"` + // 建项目时选中的能力,存 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"` } diff --git a/eai_agentplatform/backend-go/internal/model/question.go b/eai_agentplatform/backend-go/internal/model/question.go index 2f281ca..f9088a3 100644 --- a/eai_agentplatform/backend-go/internal/model/question.go +++ b/eai_agentplatform/backend-go/internal/model/question.go @@ -2,6 +2,12 @@ package model import "time" +// QuestionOption 题目选项 +type QuestionOption struct { + Key string `json:"key"` + Text string `json:"text"` +} + // Question 题目表 type Question struct { ID uint `gorm:"primaryKey" json:"id"` @@ -9,8 +15,8 @@ type Question struct { CourseID *uint `gorm:"index" json:"course_id"` Type string `gorm:"size:16;not null" json:"type"` // single / multiple / judge Stem string `gorm:"type:text;not null" json:"stem"` - Options string `gorm:"type:text;not null" json:"options"` // JSON 数组:["A","B","C"] - Answer string `gorm:"type:text;not null" json:"answer"` // JSON 数组:single=[索引] multiple=[索引...] judge=[bool] + Options string `gorm:"column:options;type:text;not null" json:"options"` + Answer string `gorm:"column:answer;type:text;not null" json:"answer"` Explanation string `gorm:"type:text;default:''" json:"explanation"` Status string `gorm:"size:16;not null;default:active;index" json:"status"` // active / inactive CreatedAt time.Time `json:"created_at"` diff --git a/eai_agentplatform/backend-go/internal/model/skill_keys.go b/eai_agentplatform/backend-go/internal/model/skill_keys.go deleted file mode 100644 index 7f75c59..0000000 --- a/eai_agentplatform/backend-go/internal/model/skill_keys.go +++ /dev/null @@ -1,104 +0,0 @@ -package model - -import ( - "encoding/json" - "fmt" - "sort" - "strings" -) - -// ValidSkillKeys 技能 key 全集,用于校验 Specialist.AllowedSkills。 -// -// 权威来源是前端 frontend/src/config/workbench.js 的 staticSkillCatalog —— -// 技能实现仍归前端,后端只持有 key 清单,用于录入时挡住拼写错误。 -// 两个清单的一致性由 skill_keys_test.go 守着,改动任一侧都会让测试失败。 -// -// 背景:前端 22 个技能 vs 后端 skill_definition 7 行,长期无同步机制; -// 本清单是 SY23 §2.4 的「单向校验」兜底,不解决同步问题本身。 -var ValidSkillKeys = map[string]bool{ - "smart-assistant": true, // 通用助手(默认挂载) - "document-translate": true, - "copy-proofreading": true, - "audio-transcribe": true, - "batch-extract": true, - "contract-review": true, // 注意:与专员 key contract-review 同名,日志里请带类型前缀 - "report-generation": true, // 同上,与专员 key report-generation 同名 - "ppt-generation": true, - "mind-map": true, - "longform-writing": true, - "text-toolkit": true, - "ocr-understanding": true, - "meeting-minutes": true, - "project-planning": true, - "email-drafting": true, - "table-cleanup": true, - "proposal-summary": true, - "progress-report": true, - "contract-brief": true, - "interview-summary": true, - "policy-rewrite": true, - "survey-summary": true, -} - -// SortedValidSkillKeys 返回排序后的 key 清单,用于报错文案与文档生成 -func SortedValidSkillKeys() []string { - keys := make([]string, 0, len(ValidSkillKeys)) - for k := range ValidSkillKeys { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// ParseAllowedSkills 解析 AllowedSkills JSON 数组。 -// 空串、非法 JSON 一律返回 nil —— 读路径不报错,拿不到绑定就退回通用助手行为。 -func ParseAllowedSkills(raw string) []string { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return nil - } - var keys []string - if err := json.Unmarshal([]byte(trimmed), &keys); err != nil { - return nil - } - out := make([]string, 0, len(keys)) - for _, k := range keys { - if k = strings.TrimSpace(k); k != "" { - out = append(out, k) - } - } - return out -} - -// MarshalAllowedSkills 把 key 列表序列化为 AllowedSkills 的存储形式。 -// 空列表存空串(而不是 "[]"),与「未绑定」保持同一种表示。 -func MarshalAllowedSkills(keys []string) (string, error) { - if len(keys) == 0 { - return "", nil - } - raw, err := json.Marshal(keys) - if err != nil { - return "", err - } - return string(raw), nil -} - -// ValidateAllowedSkills 校验 key 列表,返回第一个非法 key 的错误。 -// 同时挡掉重复项——重复会让「首个为主技能」的语义变得不确定。 -func ValidateAllowedSkills(keys []string) error { - seen := make(map[string]bool, len(keys)) - for _, k := range keys { - k = strings.TrimSpace(k) - if k == "" { - continue - } - if !ValidSkillKeys[k] { - return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(SortedValidSkillKeys(), " / ")) - } - if seen[k] { - return fmt.Errorf("技能 key %q 重复绑定", k) - } - seen[k] = true - } - return nil -} diff --git a/eai_agentplatform/backend-go/internal/model/specialist.go b/eai_agentplatform/backend-go/internal/model/specialist.go deleted file mode 100644 index bf5c0e8..0000000 --- a/eai_agentplatform/backend-go/internal/model/specialist.go +++ /dev/null @@ -1,49 +0,0 @@ -package model - -import "time" - -// Specialist 数字员工专员目录 -type Specialist 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"` - Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry - WorkerType string `gorm:"size:16;not null;default:dw;index" json:"worker_type"` - ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;not null;default:''" json:"object_entry_route"` - Summary string `gorm:"type:text" json:"summary"` - InteractionCardJSON string `gorm:"column:interaction_card_json;type:text" json:"interaction_card_json"` - WorkStatus string `gorm:"size:64;default:''" json:"work_status"` - RiskLabel string `gorm:"size:64;default:''" json:"risk_label"` - Color string `gorm:"size:16;default:''" json:"color"` - Stage string `gorm:"size:64;default:''" json:"stage"` - Progress int `gorm:"not null;default:0" json:"progress"` - MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用 - Version string `gorm:"size:32;default:''" json:"version"` - ConnectorScope string `gorm:"type:text" json:"connector_scope"` - PermissionScope string `gorm:"type:text" json:"permission_scope"` - ResourceBindings string `gorm:"type:text" json:"resource_bindings"` - InfoSources string `gorm:"type:text" json:"info_sources"` - BaseSkills string `gorm:"type:text" json:"base_skills"` - AIAssistance string `gorm:"type:text" json:"ai_assistance"` - GeneratedSkills string `gorm:"type:text" json:"generated_skills"` - // RuleFileMarkdown 岗位说明书正文(Markdown 全文)。 - // 会话创建时注入 System Prompt,决定这个专员怎么说话、怎么推进、什么时候调用哪个技能。 - // 参考 AionCore builtin-assistants 的 rule_file;命名对齐 SY23。 - RuleFileMarkdown string `gorm:"type:text" json:"rule_file_markdown"` - // AllowedSkills 绑定技能 key 列表,JSON 字符串数组,顺序即优先级(首个为主技能)。 - // 元素取值须通过 model.ValidSkillKeys 校验,对齐前端 staticSkillCatalog[].key。 - // 字段名对齐 SY22 §6.1 对象 manifest 的 allowed_skills。 - AllowedSkills string `gorm:"type:text" json:"allowed_skills"` - InputsRecordsJSON string `gorm:"column:inputs_records_json;type:text" json:"inputs_records_json"` - PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"` - ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"` - ResultRecordsJSON string `gorm:"type:text" json:"result_records_json"` - State string `gorm:"size:16;not null;default:active;index" json:"state"` // active / inactive - SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -func (Specialist) TableName() string { return "specialist" } diff --git a/eai_agentplatform/backend-go/internal/model/worker_artifact.go b/eai_agentplatform/backend-go/internal/model/task_artifact.go similarity index 58% rename from eai_agentplatform/backend-go/internal/model/worker_artifact.go rename to eai_agentplatform/backend-go/internal/model/task_artifact.go index 7b30c0f..01afe88 100644 --- a/eai_agentplatform/backend-go/internal/model/worker_artifact.go +++ b/eai_agentplatform/backend-go/internal/model/task_artifact.go @@ -2,20 +2,20 @@ package model import "time" -// WorkerArtifact 专员交付物 -type WorkerArtifact struct { +// TaskArtifact 专员交付物 +type TaskArtifact struct { ID uint `gorm:"primaryKey" json:"id"` TaskID uint `gorm:"not null;index" json:"task_id"` SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` Title string `gorm:"size:128;not null" json:"title"` ArtifactType string `gorm:"size:32;not null;default:text" json:"artifact_type"` Status string `gorm:"size:32;not null;default:draft;index" json:"status"` - ContentText string `gorm:"type:text" json:"content_text"` - ContentJSON string `gorm:"type:text" json:"content_json"` - SourceRefsJSON string `gorm:"type:text" json:"source_refs_json"` + ContentText string `gorm:"column:content_text;type:text" json:"content_text"` + ContentJSON string `gorm:"column:content_json;type:text" json:"content_json"` // JSON → map[string]any + SourceRefsJSON string `gorm:"column:source_refs_json;type:text" json:"source_refs_json"` // JSON → []SourceRef CreatedByRunID *uint `gorm:"index" json:"created_by_run_id"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -func (WorkerArtifact) TableName() string { return "worker_artifact" } +func (TaskArtifact) TableName() string { return "task_artifact" } diff --git a/eai_agentplatform/backend-go/internal/model/worker_task.go b/eai_agentplatform/backend-go/internal/model/task_record.go similarity index 73% rename from eai_agentplatform/backend-go/internal/model/worker_task.go rename to eai_agentplatform/backend-go/internal/model/task_record.go index 4d7336c..aad75a8 100644 --- a/eai_agentplatform/backend-go/internal/model/worker_task.go +++ b/eai_agentplatform/backend-go/internal/model/task_record.go @@ -2,18 +2,18 @@ package model import "time" -// WorkerTask 专员事项/任务 -type WorkerTask struct { +// TaskRecord 专员事项/任务 +type TaskRecord struct { ID uint `gorm:"primaryKey" json:"id"` SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` Title string `gorm:"size:128;not null" json:"title"` - Summary string `gorm:"type:text" json:"summary"` + Summary string `gorm:"column:summary;type:text" json:"summary"` Owner string `gorm:"size:64;default:''" json:"owner"` Priority string `gorm:"size:16;not null;default:P2" json:"priority"` Status string `gorm:"size:32;not null;default:待处理;index" json:"status"` - ContextJSON string `gorm:"type:text" json:"context_json"` + ContextJSON string `gorm:"column:context_json;type:text" json:"context_json"` // JSON → map[string]any CurrentRunID *uint `gorm:"index" json:"current_run_id"` - CurrentResult string `gorm:"type:text" json:"current_result"` + CurrentResult string `gorm:"column:current_result;type:text" json:"current_result"` // JSON → map[string]any DueAt *time.Time `json:"due_at"` CreatedBy *uint `gorm:"index" json:"created_by"` LastTriggeredAt *time.Time `json:"last_triggered_at"` @@ -26,4 +26,4 @@ type WorkerTask struct { UpdatedAt time.Time `json:"updated_at"` } -func (WorkerTask) TableName() string { return "worker_task" } +func (TaskRecord) TableName() string { return "task_record" } diff --git a/eai_agentplatform/backend-go/internal/model/worker_run.go b/eai_agentplatform/backend-go/internal/model/task_run.go similarity index 61% rename from eai_agentplatform/backend-go/internal/model/worker_run.go rename to eai_agentplatform/backend-go/internal/model/task_run.go index 8b4f3e0..a5a9413 100644 --- a/eai_agentplatform/backend-go/internal/model/worker_run.go +++ b/eai_agentplatform/backend-go/internal/model/task_run.go @@ -2,8 +2,8 @@ package model import "time" -// WorkerRun 专员动作执行记录 -type WorkerRun struct { +// TaskRun 专员动作执行记录 +type TaskRun struct { ID uint `gorm:"primaryKey" json:"id"` TaskID uint `gorm:"not null;index" json:"task_id"` SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` @@ -11,13 +11,13 @@ type WorkerRun struct { ActionTitle string `gorm:"size:128;not null" json:"action_title"` ActionType string `gorm:"size:32;default:''" json:"action_type"` Status string `gorm:"size:16;not null;default:done;index" json:"status"` - InputJSON string `gorm:"type:text" json:"input_json"` - OutputJSON string `gorm:"type:text" json:"output_json"` - LogsJSON string `gorm:"type:text" json:"logs_json"` + InputJSON string `gorm:"column:input_json;type:text" json:"input_json"` // JSON → map[string]any + OutputJSON string `gorm:"column:output_json;type:text" json:"output_json"` // JSON → map[string]any + LogsJSON string `gorm:"column:logs_json;type:text" json:"logs_json"` // JSON → []map[string]any StartedAt time.Time `json:"started_at"` FinishedAt *time.Time `json:"finished_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -func (WorkerRun) TableName() string { return "worker_run" } +func (TaskRun) TableName() string { return "task_run" } diff --git a/eai_agentplatform/backend-go/internal/repository/certificate.go b/eai_agentplatform/backend-go/internal/repository/certificate.go new file mode 100644 index 0000000..04d000d --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/certificate.go @@ -0,0 +1,47 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// Certificate 证书仓库。 +type CertificateRepo struct{ *QueryBuilder } + +// GetByUserAndExam 获取某用户在某考试中的证书。 +func (r CertificateRepo) GetByUserAndExam(userID uint, examID uint) (model.Certificate, bool) { + var c model.Certificate + if r.Type(&c).Where("user_id = ? AND exam_id = ?", userID, examID).First(&c) { + return c, true + } + return model.Certificate{}, false +} + +// ListByUser 获取某用户的证书列表。 +func (r CertificateRepo) ListByUser(userID uint) []model.Certificate { + var items []model.Certificate + if r.Type(&items).Where("user_id = ?", userID).Order("issued_at DESC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r CertificateRepo) GetByID(id uint) (model.Certificate, bool) { + var c model.Certificate + if r.Type(&c).Where("id = ?", id).First(&c) { + return c, true + } + return model.Certificate{}, false +} + +// Insert 创建证书。 +func (r CertificateRepo) Insert(c *model.Certificate) bool { + return r.QueryBuilder.Insert(c) +} + +// CountByUser 统计某用户的证书数。 +func (r CertificateRepo) CountByUser(userID uint) int64 { + var c int64 + r.Inner().Model(&model.Certificate{}).Where("user_id = ?", userID).Count(&c) + return c +} diff --git a/eai_agentplatform/backend-go/internal/repository/core.go b/eai_agentplatform/backend-go/internal/repository/core.go new file mode 100644 index 0000000..2ad8f0d --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/core.go @@ -0,0 +1,198 @@ +// Package repository 提供统一的数据访问层。 +// +// 所有 handler 必须通过 Repository 访问数据,禁止直接调用 store.DB。 +// 核心 QueryBuilder 封装常用查询操作(FindAll / FindBy / FindByID / Create / Update / Delete), +// 各实体仓库基于 QueryBuilder 构建领域方法。 +// +// 设计原则: +// - 只封装数据访问,不封装业务逻辑(业务逻辑留在 handler/service) +// - 返回 *gorm.DB 的方法允许链式调用(Where / Order / Limit 等) +// - 所有方法统一错误处理(错误已记录日志,调用方通过 bool 判断) +package repository + +import ( + "log" + + "gorm.io/gorm" + + "eai_agentplatform/backend/internal/store" +) + +// DB 全局数据库句柄(供仓库方法使用)。 +// 实际使用时指向 store.DB。 +var DB *gorm.DB + +func init() { + DB = store.DB +} + +// SetDB 设置仓库使用的数据库句柄(测试时覆写)。 +func SetDB(db *gorm.DB) { + DB = db +} + +// QueryBuilder 通用查询构建器。 +// 内部包装 *gorm.DB,提供安全的数据访问方法。 +type QueryBuilder struct { + query *gorm.DB +} + +// New 创建新的查询构建器(用于新建记录)。 +func New(model any) *QueryBuilder { + return &QueryBuilder{query: DB.Model(model)} +} + +// Table 创建基于已有表名的查询构建器(用于批量操作)。 +func Table(tableName string) *QueryBuilder { + return &QueryBuilder{query: DB.Table(tableName)} +} + +// base 返回非 nil 的底层查询链。若调用方是零值仓库(内嵌 *QueryBuilder 为 nil), +// 则从包级 DB 重新起步,避免 nil 指针解引用。 +func (q *QueryBuilder) base() *gorm.DB { + if q == nil || q.query == nil { + return DB + } + return q.query +} + +// Type 指定查询的模型类型(用于 Find 返回)。 +func (q *QueryBuilder) Type(model any) *QueryBuilder { + return &QueryBuilder{query: q.base().Model(model)} +} + +// Query 返回当前查询构建器(用于链式调用)。 +func (q *QueryBuilder) Query() *QueryBuilder { + return &QueryBuilder{query: q.base()} +} + +// Where 添加 WHERE 条件。 +func (q *QueryBuilder) Where(cond string, args ...any) *QueryBuilder { + return &QueryBuilder{query: q.base().Where(cond, args...)} +} + +// Order 添加排序。 +func (q *QueryBuilder) Order(value string) *QueryBuilder { + return &QueryBuilder{query: q.base().Order(value)} +} + +// Limit 限制返回数量。 +func (q *QueryBuilder) Limit(n int) *QueryBuilder { + return &QueryBuilder{query: q.base().Limit(n)} +} + +// Offset 设置偏移。 +func (q *QueryBuilder) Offset(n int) *QueryBuilder { + return &QueryBuilder{query: q.base().Offset(n)} +} + +// Scopes 应用 GORM scopes。 +func (q *QueryBuilder) Scopes(scopes ...func(*gorm.DB) *gorm.DB) *QueryBuilder { + return &QueryBuilder{query: q.base().Scopes(scopes...)} +} + +// First 查询第一条记录。返回 (model, found)。 +func (q *QueryBuilder) First(model any) (found bool) { + if err := q.base().First(model).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return false + } + log.Printf("[repository] First error: %v", err) + return false + } + return true +} + +// Find 查询所有匹配记录。 +func (q *QueryBuilder) Find(dest any) (found bool) { + if err := q.base().Find(dest).Error; err != nil { + log.Printf("[repository] Find error: %v", err) + return false + } + return true +} + +// Count 返回匹配记录数。 +func (q *QueryBuilder) Count() int64 { + var c int64 + q.base().Count(&c) + return c +} + +// Insert 插入记录。 +func (q *QueryBuilder) Insert(value any) bool { + if err := q.base().Create(value).Error; err != nil { + log.Printf("[repository] Insert error: %v", err) + return false + } + return true +} + +// Create 插入记录(Insert 的别名)。 +func (q *QueryBuilder) Create(value any) bool { + return q.Insert(value) +} + +// Save 保存记录(插入或更新)。 +func (q *QueryBuilder) Save(value any) bool { + if err := q.base().Save(value).Error; err != nil { + log.Printf("[repository] Save error: %v", err) + return false + } + return true +} + +// Updates 按条件更新(只更新提供的非零字段)。 +func (q *QueryBuilder) Updates(value any) bool { + if err := q.base().Updates(value).Error; err != nil { + log.Printf("[repository] Updates error: %v", err) + return false + } + return true +} + +// UpdateColumn 更新单列。 +func (q *QueryBuilder) UpdateColumn(column string, value any) bool { + if err := q.base().Update(column, value).Error; err != nil { + log.Printf("[repository] UpdateColumn error: %v", err) + return false + } + return true +} + +// Delete 删除记录。 +func (q *QueryBuilder) Delete(value any) bool { + if err := q.base().Delete(value).Error; err != nil { + log.Printf("[repository] Delete error: %v", err) + return false + } + return true +} + +// DeleteByID 按主键 ID 删除。 +func (q *QueryBuilder) DeleteByID(model any, id uint) bool { + if err := q.base().Model(model).Where("id = ?", id).Delete(model).Error; err != nil { + log.Printf("[repository] DeleteByID error: %v", err) + return false + } + return true +} + +// Raw 执行原始 SQL。 +func (q *QueryBuilder) Raw(sql string, args ...any) *QueryBuilder { + return &QueryBuilder{query: q.base().Raw(sql, args...)} +} + +// Scan 将结果扫描到目标结构体。 +func (q *QueryBuilder) Scan(dest any) bool { + if err := q.base().Scan(dest).Error; err != nil { + log.Printf("[repository] Scan error: %v", err) + return false + } + return true +} + +// Inner 获取底层 *gorm.DB(高级用法,慎用)。 +func (q *QueryBuilder) Inner() *gorm.DB { + return q.base() +} diff --git a/eai_agentplatform/backend-go/internal/repository/course.go b/eai_agentplatform/backend-go/internal/repository/course.go new file mode 100644 index 0000000..01b6c14 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/course.go @@ -0,0 +1,93 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// Course 课程仓库。 +type CourseRepo struct{ *QueryBuilder } + +// List 获取课程列表(按条件过滤)。 +// +// status 的三档含义与产品列表一致,调用方直接透传 query 参数即可: +// - ""(默认):仅 active —— 员工浏览视角 +// - "all":管理员维护视角,不按状态过滤 +// - 其他:按该状态精确过滤 +func (r CourseRepo) List(category, status string) []model.Course { + q := r.Type(&model.Course{}) + if category != "" { + q = q.Where("category = ?", category) + } + switch status { + case "": + q = q.Where("status = ?", "active") + case "all": + default: + q = q.Where("status = ?", status) + } + var items []model.Course + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r CourseRepo) GetByID(id uint) (model.Course, bool) { + var c model.Course + if r.Type(&c).Where("id = ?", id).First(&c) { + return c, true + } + return model.Course{}, false +} + +// GetByCode 按编号获取。 +func (r CourseRepo) GetByCode(code string) (model.Course, bool) { + var c model.Course + if r.Type(&c).Where("code = ?", code).First(&c) { + return c, true + } + return model.Course{}, false +} + +// Insert 创建课程。 +func (r CourseRepo) Insert(c *model.Course) bool { + return r.QueryBuilder.Insert(c) +} + +// Update 更新课程。 +func (r CourseRepo) Update(c *model.Course) bool { + return r.Save(c) +} + +// Delete 软删除。 +func (r CourseRepo) Delete(id uint) bool { + return r.Type(&model.Course{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// CountByCode 按编号统计(唯一性检查)。 +func (r CourseRepo) CountByCode(code string, excludeID *uint) int64 { + var c int64 + q := r.Inner().Model(&model.Course{}).Where("code = ?", code) + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + q.Count(&c) + return c +} + +// NamesByIDs 批量解析课程名称(id → name),用于列表页回填关联名称。 +// 未命中的 ID 不会出现在返回的 map 中,调用方需自行兜底。 +func (r CourseRepo) NamesByIDs(ids []uint) map[uint]string { + names := map[uint]string{} + if len(ids) == 0 { + return names + } + var items []model.Course + if r.Type(&items).Where("id IN ?", ids).Find(&items) { + for _, c := range items { + names[c.ID] = c.Name + } + } + return names +} diff --git a/eai_agentplatform/backend-go/internal/repository/department.go b/eai_agentplatform/backend-go/internal/repository/department.go new file mode 100644 index 0000000..24863ab --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/department.go @@ -0,0 +1,50 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// Department 部门仓库。 +type DepartmentRepo struct{ *QueryBuilder } + +// List 获取部门列表。 +func (r DepartmentRepo) List() []model.Department { + var items []model.Department + if r.Type(&items).Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r DepartmentRepo) GetByID(id uint) (model.Department, bool) { + var d model.Department + if r.Type(&d).Where("id = ?", id).First(&d) { + return d, true + } + return model.Department{}, false +} + +// GetByName 按名称获取。 +func (r DepartmentRepo) GetByName(name string) (model.Department, bool) { + var d model.Department + if r.Type(&d).Where("name = ?", name).First(&d) { + return d, true + } + return model.Department{}, false +} + +// Insert 创建部门。 +func (r DepartmentRepo) Insert(d *model.Department) bool { + return r.QueryBuilder.Insert(d) +} + +// Update 更新部门。 +func (r DepartmentRepo) Update(d *model.Department) bool { + return r.Save(d) +} + +// Delete 删除部门。 +func (r DepartmentRepo) Delete(id uint) bool { + return r.DeleteByID(&model.Department{}, id) +} diff --git a/eai_agentplatform/backend-go/internal/repository/exam_paper.go b/eai_agentplatform/backend-go/internal/repository/exam_paper.go new file mode 100644 index 0000000..c0942c2 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/exam_paper.go @@ -0,0 +1,48 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// ExamPaper 试卷仓库。 +type ExamPaperRepo struct{ *QueryBuilder } + +// GetByID 按 ID 获取。 +func (r ExamPaperRepo) GetByID(id uint) (model.ExamPaper, bool) { + var p model.ExamPaper + if r.Type(&p).Where("id = ?", id).First(&p) { + return p, true + } + return model.ExamPaper{}, false +} + +// GetActive 获取所有激活的试卷。 +func (r ExamPaperRepo) GetActive() []model.ExamPaper { + var items []model.ExamPaper + if r.Type(&items).Where("status = ?", "active").Find(&items) { + return items + } + return nil +} + +// Insert 创建试卷。 +func (r ExamPaperRepo) Insert(p *model.ExamPaper) bool { + return r.QueryBuilder.Insert(p) +} + +// Update 更新试卷。 +func (r ExamPaperRepo) Update(p *model.ExamPaper) bool { + return r.Save(p) +} + +// Delete 软删除。 +func (r ExamPaperRepo) Delete(id uint) bool { + return r.Type(&model.ExamPaper{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// Count 统计试卷数。 +func (r ExamPaperRepo) Count() int64 { + var c int64 + r.Inner().Model(&model.ExamPaper{}).Count(&c) + return c +} diff --git a/eai_agentplatform/backend-go/internal/repository/exam_record.go b/eai_agentplatform/backend-go/internal/repository/exam_record.go new file mode 100644 index 0000000..e3d7683 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/exam_record.go @@ -0,0 +1,89 @@ +package repository + +import ( + "strconv" + + "eai_agentplatform/backend/internal/model" +) + +// ExamRecord 考试记录仓库。 +type ExamRecordRepo struct{ *QueryBuilder } + +// List 获取考试记录列表。 +func (r ExamRecordRepo) List(userIDStr, paperIDStr string) []model.ExamRecord { + q := r.Type(&model.ExamRecord{}) + if userIDStr != "" { + if uid, err := strconv.ParseUint(userIDStr, 10, 32); err == nil { + q = q.Where("user_id = ?", uint(uid)) + } + } + if paperIDStr != "" { + if pid, err := strconv.ParseUint(paperIDStr, 10, 32); err == nil { + q = q.Where("paper_id = ?", uint(pid)) + } + } + var items []model.ExamRecord + if q.Order("submitted_at DESC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r ExamRecordRepo) GetByID(id uint) (model.ExamRecord, bool) { + var rec model.ExamRecord + if r.Type(&rec).Where("id = ?", id).First(&rec) { + return rec, true + } + return model.ExamRecord{}, false +} + +// Insert 创建记录。 +func (r ExamRecordRepo) Insert(rec *model.ExamRecord) bool { + return r.QueryBuilder.Insert(rec) +} + +// Update 更新记录。 +func (r ExamRecordRepo) Update(rec *model.ExamRecord) bool { + return r.Save(rec) +} + +// Remove 删除记录。 +func (r ExamRecordRepo) Remove(id uint) bool { + return r.DeleteByID(&model.ExamRecord{}, id) +} + +// CountByUser 统计某用户的考试记录数。 +func (r ExamRecordRepo) CountByUser(userID uint) int64 { + var c int64 + r.Inner().Model(&model.ExamRecord{}).Where("user_id = ?", userID).Count(&c) + return c +} + +// HasTaken 判断用户是否已参加过某张试卷(正式考的唯一性依据)。 +// 试卷列表标注「已完成」、开考拦截、交卷拦截三处共用同一语义。 +func (r ExamRecordRepo) HasTaken(userID, paperID uint) bool { + var c int64 + r.Inner().Model(&model.ExamRecord{}). + Where("user_id = ? AND paper_id = ?", userID, paperID). + Count(&c) + return c > 0 +} + +// ListByUser 取某用户的考试记录(按提交时间倒序)。 +func (r ExamRecordRepo) ListByUser(userID uint) []model.ExamRecord { + var items []model.ExamRecord + if r.Type(&items).Where("user_id = ?", userID).Order("submitted_at DESC").Find(&items) { + return items + } + return nil +} + +// ListAll 取全部考试记录(按提交时间倒序,供管理员导出与统计)。 +func (r ExamRecordRepo) ListAll() []model.ExamRecord { + var items []model.ExamRecord + if r.Type(&items).Order("submitted_at DESC").Find(&items) { + return items + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/repository/knowledge_chunk.go b/eai_agentplatform/backend-go/internal/repository/knowledge_chunk.go new file mode 100644 index 0000000..dca3972 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/knowledge_chunk.go @@ -0,0 +1,74 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// KnowledgeChunk 知识分片仓库。 +type KnowledgeChunkRepo struct{ *QueryBuilder } + +// ApprovedMetaMaps 批量加载解析分片元信息所需的「已审批」素材与知识源(id → 记录)。 +// +// resolveChunkMeta 只认已审批的来源(素材 status=approved、知识源 audit_status=approved), +// 所以这里的过滤条件必须与 resolveChunkMeta 内的判断保持一致:**传空 map 会让所有带 +// media_file_id / knowledge_source_id 的分片被整段丢弃**(历史上 loadKnowledgeCandidates +// 就踩过这个坑,AI 检索候选集只剩「无指针」分片)。 +// AI 检索候选与知识索引构建共用此方法,避免两处各自手写过滤条件而漂移。 +func (r KnowledgeChunkRepo) ApprovedMetaMaps() (map[uint]model.MediaFile, map[uint]model.KnowledgeSource) { + var mediaFiles []model.MediaFile + r.Type(&mediaFiles).Where("status = ?", "approved").Find(&mediaFiles) + mediaMap := make(map[uint]model.MediaFile, len(mediaFiles)) + for _, m := range mediaFiles { + mediaMap[m.ID] = m + } + + var sources []model.KnowledgeSource + r.Type(&sources).Where("audit_status = ?", "approved").Find(&sources) + sourceMap := make(map[uint]model.KnowledgeSource, len(sources)) + for _, s := range sources { + sourceMap[s.ID] = s + } + return mediaMap, sourceMap +} + +// CountByMediaFile 统计某素材切出的分片数(素材状态页用于展示提取结果)。 +func (r KnowledgeChunkRepo) CountByMediaFile(mediaID uint) int64 { + var c int64 + r.Inner().Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", mediaID).Count(&c) + return c +} + +// GetByID 按 ID 获取。 +func (r KnowledgeChunkRepo) GetByID(id uint) (model.KnowledgeChunk, bool) { + var c model.KnowledgeChunk + if r.Type(&c).Where("id = ?", id).First(&c) { + return c, true + } + return model.KnowledgeChunk{}, false +} + +// Insert 创建分片。 +func (r KnowledgeChunkRepo) Insert(c *model.KnowledgeChunk) bool { + return r.QueryBuilder.Insert(c) +} + +// BulkInsert 批量创建分片。 +func (r KnowledgeChunkRepo) BulkInsert(items []model.KnowledgeChunk) int { + count := 0 + for i := range items { + if r.QueryBuilder.Insert(&items[i]) { + count++ + } + } + return count +} + +// Update 更新分片。 +func (r KnowledgeChunkRepo) Update(c *model.KnowledgeChunk) bool { + return r.Save(c) +} + +// DeleteByID 按 ID 删除。 +func (r KnowledgeChunkRepo) RemoveByID(id uint) bool { + return r.DeleteByID(&model.KnowledgeChunk{}, id) +} diff --git a/eai_agentplatform/backend-go/internal/repository/knowledge_faq.go b/eai_agentplatform/backend-go/internal/repository/knowledge_faq.go new file mode 100644 index 0000000..ddbbcd5 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/knowledge_faq.go @@ -0,0 +1,52 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// KnowledgeFAQ FAQ 仓库。 +type KnowledgeFAQRepo struct{ *QueryBuilder } + +// List 获取 FAQ 列表(按知识空间过滤)。 +func (r KnowledgeFAQRepo) List(spaceID uint, keyword string) []model.KnowledgeFAQ { + q := r.Type(&model.KnowledgeFAQ{}).Where("space_id = ?", spaceID) + if keyword != "" { + q = q.Where("question LIKE ?", "%"+keyword+"%") + } + var items []model.KnowledgeFAQ + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r KnowledgeFAQRepo) GetByID(id uint) (model.KnowledgeFAQ, bool) { + var f model.KnowledgeFAQ + if r.Type(&f).Where("id = ?", id).First(&f) { + return f, true + } + return model.KnowledgeFAQ{}, false +} + +// CountBySpace 统计某知识空间的 FAQ 数。 +func (r KnowledgeFAQRepo) CountBySpace(spaceID uint) int64 { + var c int64 + r.Inner().Model(&model.KnowledgeFAQ{}).Where("space_id = ?", spaceID).Count(&c) + return c +} + +// Insert 创建 FAQ。 +func (r KnowledgeFAQRepo) Insert(f *model.KnowledgeFAQ) bool { + return r.QueryBuilder.Insert(f) +} + +// Update 更新 FAQ。 +func (r KnowledgeFAQRepo) Update(f *model.KnowledgeFAQ) bool { + return r.Save(f) +} + +// Delete 删除 FAQ。 +func (r KnowledgeFAQRepo) Delete(id uint) bool { + return r.DeleteByID(&model.KnowledgeFAQ{}, id) +} diff --git a/eai_agentplatform/backend-go/internal/repository/knowledge_source.go b/eai_agentplatform/backend-go/internal/repository/knowledge_source.go new file mode 100644 index 0000000..db67dac --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/knowledge_source.go @@ -0,0 +1,62 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// KnowledgeSource 知识源仓库。 +type KnowledgeSourceRepo struct{ *QueryBuilder } + +// List 获取知识源列表。 +func (r KnowledgeSourceRepo) List(spaceID uint, status string) []model.KnowledgeSource { + q := r.Type(&model.KnowledgeSource{}).Where("space_id = ?", spaceID) + if status != "" { + q = q.Where("status = ?", status) + } + var items []model.KnowledgeSource + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r KnowledgeSourceRepo) GetByID(id uint) (model.KnowledgeSource, bool) { + var s model.KnowledgeSource + if r.Type(&s).Where("id = ?", id).First(&s) { + return s, true + } + return model.KnowledgeSource{}, false +} + +// Insert 创建知识源。 +func (r KnowledgeSourceRepo) Insert(s *model.KnowledgeSource) bool { + return r.QueryBuilder.Insert(s) +} + +// Update 更新知识源。 +func (r KnowledgeSourceRepo) Update(s *model.KnowledgeSource) bool { + return r.Save(s) +} + +// Delete 软删除。 +func (r KnowledgeSourceRepo) Delete(id uint) bool { + return r.Type(&model.KnowledgeSource{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// DeleteBySpace 删除某知识空间的所有知识源。 +func (r KnowledgeSourceRepo) DeleteBySpace(spaceID uint) bool { + return r.Type(&model.KnowledgeSource{}).Where("space_id = ?", spaceID).Delete(&model.KnowledgeSource{}) +} + +// CountBySpace 统计某知识空间的知识源数。 +func (r KnowledgeSourceRepo) CountBySpace(spaceID uint) int64 { + var c int64 + r.Inner().Model(&model.KnowledgeSource{}).Where("space_id = ?", spaceID).Count(&c) + return c +} + +// UpdateStatus 更新单个知识源的状态。 +func (r KnowledgeSourceRepo) UpdateStatus(id uint, status string) bool { + return r.Type(&model.KnowledgeSource{}).Where("id = ?", id).UpdateColumn("status", status) +} diff --git a/eai_agentplatform/backend-go/internal/repository/knowledge_space.go b/eai_agentplatform/backend-go/internal/repository/knowledge_space.go new file mode 100644 index 0000000..7cba4ff --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/knowledge_space.go @@ -0,0 +1,72 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// KnowledgeSpace 知识空间仓库。 +type KnowledgeSpaceRepo struct{ *QueryBuilder } + +// List 获取知识空间列表。 +func (r KnowledgeSpaceRepo) List(status string) []model.KnowledgeSpace { + q := r.Type(&model.KnowledgeSpace{}) + if status != "" { + q = q.Where("status = ?", status) + } + var items []model.KnowledgeSpace + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r KnowledgeSpaceRepo) GetByID(id uint) (model.KnowledgeSpace, bool) { + var s model.KnowledgeSpace + if r.Type(&s).Where("id = ?", id).First(&s) { + return s, true + } + return model.KnowledgeSpace{}, false +} + +// GetByName 按名称获取。 +func (r KnowledgeSpaceRepo) GetByName(name string) (model.KnowledgeSpace, bool) { + var s model.KnowledgeSpace + if r.Type(&s).Where("name = ?", name).First(&s) { + return s, true + } + return model.KnowledgeSpace{}, false +} + +// Insert 创建知识空间。 +func (r KnowledgeSpaceRepo) Insert(s *model.KnowledgeSpace) bool { + return r.QueryBuilder.Insert(s) +} + +// Update 更新知识空间。 +func (r KnowledgeSpaceRepo) Update(s *model.KnowledgeSpace) bool { + return r.Save(s) +} + +// Delete 软删除。 +func (r KnowledgeSpaceRepo) Delete(id uint) bool { + return r.Type(&model.KnowledgeSpace{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// CountByName 按名称统计(唯一性检查)。 +func (r KnowledgeSpaceRepo) CountByName(name string, excludeID *uint) int64 { + q := r.Inner().Model(&model.KnowledgeSpace{}).Where("name = ?", name) + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + var c int64 + q.Count(&c) + return c +} + +// CountByID 按 ID 统计。 +func (r KnowledgeSpaceRepo) Count() int64 { + var c int64 + r.Inner().Model(&model.KnowledgeSpace{}).Count(&c) + return c +} diff --git a/eai_agentplatform/backend-go/internal/repository/learning_progress.go b/eai_agentplatform/backend-go/internal/repository/learning_progress.go new file mode 100644 index 0000000..6ee2568 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/learning_progress.go @@ -0,0 +1,47 @@ +package repository + +import ( + "time" + + "eai_agentplatform/backend/internal/model" +) + +// LearningProgress 学习进度仓库。 +type LearningProgressRepo struct{ *QueryBuilder } + +// Get 获取用户某条学习进度。 +func (r LearningProgressRepo) Get(userID uint, itemType string, itemID uint) (model.LearningProgress, bool) { + var p model.LearningProgress + if r.Type(&p).Where("user_id = ? AND item_type = ? AND item_id = ?", userID, itemType, itemID).First(&p) { + return p, true + } + return model.LearningProgress{}, false +} + +// ListByUser 获取某用户的所有学习进度。 +func (r LearningProgressRepo) ListByUser(userID uint) []model.LearningProgress { + var items []model.LearningProgress + if r.Type(&items).Where("user_id = ?", userID).Order("updated_at DESC").Find(&items) { + return items + } + return nil +} + +// Upsert 插入或更新学习进度。 +func (r LearningProgressRepo) Upsert(p *model.LearningProgress) bool { + existing, found := r.Get(p.UserID, p.ItemType, p.ItemID) + if !found { + return r.Insert(p) + } + existing.UpdatedAt = time.Now() + return r.Save(&existing) +} + +// Record 记录一条学习进度。 +func (r LearningProgressRepo) Record(userID uint, itemType string, itemID uint) bool { + _, found := r.Get(userID, itemType, itemID) + if found { + return r.Upsert(&model.LearningProgress{UserID: userID, ItemType: itemType, ItemID: itemID}) + } + return r.Insert(&model.LearningProgress{UserID: userID, ItemType: itemType, ItemID: itemID, CreatedAt: time.Now(), UpdatedAt: time.Now()}) +} diff --git a/eai_agentplatform/backend-go/internal/repository/media_file.go b/eai_agentplatform/backend-go/internal/repository/media_file.go new file mode 100644 index 0000000..3b2340c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/media_file.go @@ -0,0 +1,70 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// MediaFile 媒体文件仓库。 +type MediaFileRepo struct{ *QueryBuilder } + +// GetByID 按 ID 获取。 +func (r MediaFileRepo) GetByID(id uint) (model.MediaFile, bool) { + var m model.MediaFile + if r.Type(&m).Where("id = ?", id).First(&m) { + return m, true + } + return model.MediaFile{}, false +} + +// Insert 创建文件记录。 +func (r MediaFileRepo) Insert(m *model.MediaFile) bool { + return r.QueryBuilder.Insert(m) +} + +// Update 更新文件记录。 +func (r MediaFileRepo) Update(m *model.MediaFile) bool { + return r.Save(m) +} + +// Delete 删除文件记录。 +func (r MediaFileRepo) Delete(id uint) bool { + return r.DeleteByID(&model.MediaFile{}, id) +} + +// ListByBind 获取绑定到某实体、且已审批通过的素材(id 升序)。 +// +// 「审批前置」的落点:课程/产品详情只露出 approved 素材,pending 与 rejected 一律不出现在业务页面。 +// 调用方无需再自己拼 bind_type/bind_id/status 三条件,避免各处漏掉 status 过滤而泄漏未审批素材。 +func (r MediaFileRepo) ListByBind(bindType string, bindID uint) []model.MediaFile { + var items []model.MediaFile + if r.Type(&items). + Where("bind_type = ? AND bind_id = ? AND status = ?", bindType, bindID, "approved"). + Order("id ASC").Find(&items) { + return items + } + return nil +} + +// ListForAudit 审批列表(分页):返回符合条件的总数与本页记录,created_at 倒序。 +// +// status / spaceKey 为空表示不过滤该维度。page 从 1 起,size 由调用方校验后再传入。 +func (r MediaFileRepo) ListForAudit(status, spaceKey string, page, size int) (int64, []model.MediaFile) { + q := r.Inner().Model(&model.MediaFile{}) + if status != "" { + q = q.Where("status = ?", status) + } + if spaceKey != "" { + q = q.Where("knowledge_space_key = ?", spaceKey) + } + var total int64 + q.Count(&total) + + var items []model.MediaFile + q.Order("created_at DESC").Offset((page - 1) * size).Limit(size).Find(&items) + return total, items +} + +// MarkExtracted 标记素材已完成提取(视频/图片仅有此标记,文档由提取管线置位)。 +func (r MediaFileRepo) MarkExtracted(id uint) bool { + return r.Type(&model.MediaFile{}).Where("id = ?", id).UpdateColumn("extracted", true) +} diff --git a/eai_agentplatform/backend-go/internal/repository/mistake_record.go b/eai_agentplatform/backend-go/internal/repository/mistake_record.go new file mode 100644 index 0000000..89ea110 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/mistake_record.go @@ -0,0 +1,127 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// MistakeRecordRepo 错题记录仓库。 +// +// 错题本有两套口径,都收在本仓库里,避免调用方各写各的 Where: +// - 入本/再次答错:唯一键 (user_id, question_id, source),见 RecordWrong +// - 重练判分回写:按 (user_id, question_id) 扇出,把该题在所有来源下的记录一并更新,见 TouchOnPractice +type MistakeRecordRepo struct{ *QueryBuilder } + +// ListByUser 我的错题本(按最近更新倒序)。 +func (r MistakeRecordRepo) ListByUser(userID uint) []model.MistakeRecord { + var items []model.MistakeRecord + if r.Type(&items).Where("user_id = ?", userID).Order("updated_at DESC").Find(&items) { + return items + } + return nil +} + +// ListForPractice 错题重练选题:可按来源过滤、可只看未掌握(按最近更新倒序)。 +func (r MistakeRecordRepo) ListForPractice(userID uint, source string, onlyUnresolved bool) []model.MistakeRecord { + q := r.Type(&model.MistakeRecord{}).Where("user_id = ?", userID) + if source != "" { + q = q.Where("source = ?", source) + } + if onlyUnresolved { + q = q.Where("resolved = ?", false) + } + var items []model.MistakeRecord + if q.Order("updated_at DESC").Find(&items) { + return items + } + return nil +} + +// CountByUser 统计某用户的错题总数。 +func (r MistakeRecordRepo) CountByUser(userID uint) int64 { + var c int64 + r.Inner().Model(&model.MistakeRecord{}).Where("user_id = ?", userID).Count(&c) + return c +} + +// CountResolvedByUser 统计某用户已掌握的错题数。 +func (r MistakeRecordRepo) CountResolvedByUser(userID uint) int64 { + var c int64 + r.Inner().Model(&model.MistakeRecord{}). + Where("user_id = ? AND resolved = ?", userID, true).Count(&c) + return c +} + +// GetByID 按 ID 获取。 +func (r MistakeRecordRepo) GetByID(id uint) (model.MistakeRecord, bool) { + var m model.MistakeRecord + if r.Type(&m).Where("id = ?", id).First(&m) { + return m, true + } + return model.MistakeRecord{}, false +} + +// RecordWrong 答错入本:按唯一键 (user_id, question_id, source) upsert。 +// 已有记录则整字段覆盖,并把 resolved 重置为未掌握——再次答错视为问题重新暴露。 +func (r MistakeRecordRepo) RecordWrong(m model.MistakeRecord) bool { + m.Resolved = false + var old model.MistakeRecord + if !r.Type(&old). + Where("user_id = ? AND question_id = ? AND source = ?", m.UserID, m.QuestionID, m.Source). + First(&old) { + return r.Insert(&m) + } + // Save 是整字段覆盖,必须带上原有主键与创建时间,否则 created_at 会被写成零值。 + m.ID = old.ID + m.CreatedAt = old.CreatedAt + return r.Save(&m) +} + +// TouchOnPractice 错题重练判分后回写:按 (user_id, question_id) 扇出,更新该题在 +// 所有来源下的作答与掌握状态。 +// +// 返回 flipped 为本轮由「未掌握 → 已掌握」翻转的记录 ID,调用方据此加积分; +// 只返回翻转的 ID(而非全部命中的记录)是为了避免反复重练刷分。 +// touched=false 表示该题在错题本里一条都没有,调用方需自行兜底补建记录。 +func (r MistakeRecordRepo) TouchOnPractice(m model.MistakeRecord, resolved bool) (flipped []uint, touched bool) { + var recs []model.MistakeRecord + if !r.Type(&recs).Where("user_id = ? AND question_id = ?", m.UserID, m.QuestionID).Find(&recs) { + return nil, false + } + if len(recs) == 0 { + return nil, false + } + for i := range recs { + was := recs[i].Resolved + recs[i].Resolved = resolved + recs[i].QuestionType = m.QuestionType + recs[i].QuestionStem = m.QuestionStem + recs[i].UserAnswer = m.UserAnswer + recs[i].CorrectAnswer = m.CorrectAnswer + recs[i].Explanation = m.Explanation + r.Save(&recs[i]) + if resolved && !was { + flipped = append(flipped, recs[i].ID) + } + } + return flipped, true +} + +// Insert 创建错题记录。 +func (r MistakeRecordRepo) Insert(m *model.MistakeRecord) bool { + return r.QueryBuilder.Insert(m) +} + +// Update 保存已有记录(改掌握状态等)。 +func (r MistakeRecordRepo) Update(m *model.MistakeRecord) bool { + return r.Save(m) +} + +// Delete 删除错题记录。 +func (r MistakeRecordRepo) Delete(id uint) bool { + return r.DeleteByID(&model.MistakeRecord{}, id) +} + +// DeleteByUser 删除某用户的全部错题。 +func (r MistakeRecordRepo) DeleteByUser(userID uint) bool { + return r.Type(&model.MistakeRecord{}).Where("user_id = ?", userID).Delete(&model.MistakeRecord{}) +} diff --git a/eai_agentplatform/backend-go/internal/repository/position.go b/eai_agentplatform/backend-go/internal/repository/position.go new file mode 100644 index 0000000..12ae55b --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/position.go @@ -0,0 +1,128 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// Position 岗位仓库。 +type PositionRepo struct{ *QueryBuilder } + +// List 获取岗位列表。 +func (r PositionRepo) List(status string) []model.Position { + q := r.Type(&model.Position{}) + if status != "" { + q = q.Where("status = ?", status) + } else { + q = q.Where("status = ?", "active") + } + var items []model.Position + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取。 +func (r PositionRepo) GetByID(id uint) (model.Position, bool) { + var p model.Position + if r.Type(&p).Where("id = ?", id).First(&p) { + return p, true + } + return model.Position{}, false +} + +// GetByName 按名称获取。 +func (r PositionRepo) GetByName(name string) (model.Position, bool) { + var p model.Position + if r.Type(&p).Where("name = ?", name).First(&p) { + return p, true + } + return model.Position{}, false +} + +// Insert 创建岗位。 +func (r PositionRepo) Insert(p *model.Position) bool { + return r.QueryBuilder.Insert(p) +} + +// Update 更新岗位。 +func (r PositionRepo) Update(p *model.Position) bool { + return r.Save(p) +} + +// Delete 软删除。 +func (r PositionRepo) Delete(id uint) bool { + return r.Type(&model.Position{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// CountByDepartment 统计某部门的岗位数。 +func (r PositionRepo) CountByDepartment(departmentID uint) int64 { + var c int64 + r.Inner().Model(&model.Position{}).Where("department_id = ?", departmentID).Count(&c) + return c +} + +// CountByName 按名称统计(唯一性检查)。 +func (r PositionRepo) CountByName(name string, excludeID *uint) int64 { + q := r.Inner().Model(&model.Position{}).Where("name = ?", name) + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + var c int64 + q.Count(&c) + return c +} + +// ============ 岗位知识映射(PositionKnowledge) ============ + +// Knowledge 取岗位知识映射(按 id ASC)。学员应学清单与管理员编辑页共用。 +func (r PositionRepo) Knowledge(positionID uint) []model.PositionKnowledge { + var items []model.PositionKnowledge + if r.Type(&items).Where("position_id = ?", positionID).Order("id ASC").Find(&items) { + return items + } + return nil +} + +// ReplaceKnowledge 整表覆盖岗位知识映射(先删旧、再批量插入)。 +// 两步非原子:调用方若需强一致,应在事务中执行。 +func (r PositionRepo) ReplaceKnowledge(positionID uint, rows []model.PositionKnowledge) bool { + if !r.Type(&model.PositionKnowledge{}).Where("position_id = ?", positionID). + Delete(&model.PositionKnowledge{}) { + return false + } + if len(rows) == 0 { + return true + } + return r.Type(&rows).Create(&rows) +} + +// CountKnowledge 统计岗位知识映射条数。 +func (r PositionRepo) CountKnowledge(positionID uint) int64 { + var c int64 + r.Inner().Model(&model.PositionKnowledge{}).Where("position_id = ?", positionID).Count(&c) + return c +} + +// ============ 岗位考试蓝图(PositionExamBlueprint) ============ + +// Blueprints 取岗位考试蓝图(按 id ASC)。 +func (r PositionRepo) Blueprints(positionID uint) []model.PositionExamBlueprint { + var items []model.PositionExamBlueprint + if r.Type(&items).Where("position_id = ?", positionID).Order("id ASC").Find(&items) { + return items + } + return nil +} + +// ReplaceBlueprints 整表覆盖岗位考试蓝图(先删旧、再批量插入)。 +func (r PositionRepo) ReplaceBlueprints(positionID uint, rows []model.PositionExamBlueprint) bool { + if !r.Type(&model.PositionExamBlueprint{}).Where("position_id = ?", positionID). + Delete(&model.PositionExamBlueprint{}) { + return false + } + if len(rows) == 0 { + return true + } + return r.Type(&rows).Create(&rows) +} diff --git a/eai_agentplatform/backend-go/internal/repository/product.go b/eai_agentplatform/backend-go/internal/repository/product.go new file mode 100644 index 0000000..88029a6 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/product.go @@ -0,0 +1,125 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// Product 产品仓库。 +type ProductRepo struct{ *QueryBuilder } + +// Query 返回基础查询构建器(供复合查询使用)。 +func (r ProductRepo) Query() *QueryBuilder { + return r.Type(&model.Product{}) +} + +// List 获取产品列表(按条件过滤)。 +func (r ProductRepo) List(conds ...map[string]any) []model.Product { + q := r.QueryBuilder.Type(&model.Product{}) + for _, c := range conds { + for k, v := range c { + q = q.Where(k+" = ?", v) + } + } + var items []model.Product + if q.Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取产品。 +func (r ProductRepo) GetByID(id uint) (model.Product, bool) { + var p model.Product + if r.Type(&p).Where("id = ?", id).First(&p) { + return p, true + } + return model.Product{}, false +} + +// GetVisibleByID 按 ID 获取「可对外露出」的产品。 +// +// 与 GetByID 的区别是这里的业务口径:已停用(status=inactive)的产品不再出现在 +// 课程详情这类业务页面上。此前该判断散落在 handler 里手写,容易被漏掉或写歪。 +func (r ProductRepo) GetVisibleByID(id uint) (model.Product, bool) { + var p model.Product + if r.Type(&p).Where("id = ? AND status != ?", id, "inactive").First(&p) { + return p, true + } + return model.Product{}, false +} + +// GetByCode 按编号获取产品。 +func (r ProductRepo) GetByCode(code string) (model.Product, bool) { + var p model.Product + if r.Type(&p).Where("code = ?", code).First(&p) { + return p, true + } + return model.Product{}, false +} + +// Insert 创建产品。 +func (r ProductRepo) Insert(p *model.Product) bool { + return r.QueryBuilder.Insert(p) +} + +// Update 更新产品。 +func (r ProductRepo) Update(p *model.Product) bool { + return r.Save(p) +} + +// Delete 删除产品(软删除,status=inactive)。 +// +// 这里必须写 "inactive" 而不是另造一个 "deleted":产品状态是与课程共用的 +// active/inactive 两档词汇,POST /products/{id} 的 DELETE 处理器也据此向调用方 +// 回包 {"status":"inactive"}。此前写成 "deleted" 会让接口回包与库内实际值不一致, +// 管理员用 status=all 拉列表时会看到一个前端不认识的状态。 +func (r ProductRepo) Delete(id uint) bool { + return r.Type(&model.Product{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// CountByCode 按编号统计(用于唯一性检查)。 +func (r ProductRepo) CountByCode(code string, excludeID *uint) int64 { + q := r.Inner().Model(&model.Product{}).Where("code = ?", code) + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + var c int64 + q.Count(&c) + return c +} + +// NamesByIDs 批量解析产品名称(id → name),用于列表页回填关联名称。 +// 未命中的 ID 不会出现在返回的 map 中,调用方需自行兜底。 +func (r ProductRepo) NamesByIDs(ids []uint) map[uint]string { + names := map[uint]string{} + if len(ids) == 0 { + return names + } + var items []model.Product + if r.Type(&items).Where("id IN ?", ids).Find(&items) { + for _, p := range items { + names[p.ID] = p.Name + } + } + return names +} + +// ProductsByStatus 按状态筛选产品列表。 +func (r ProductRepo) ProductsByStatus(status string, filter map[string]string) []model.Product { + q := r.Type(&model.Product{}) + if cat, ok := filter["category"]; ok { + q = q.Where("category = ?", cat) + } + switch status { + case "": // 默认仅 active(员工浏览) + q = q.Where("status = ?", "active") + case "all": // 管理员维护全量 + default: + q = q.Where("status = ?", status) + } + var items []model.Product + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/repository/question.go b/eai_agentplatform/backend-go/internal/repository/question.go new file mode 100644 index 0000000..a07c124 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/question.go @@ -0,0 +1,94 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// Question 题目仓库。 +type QuestionRepo struct{ *QueryBuilder } + +// List 获取题目列表(按条件过滤)。 +func (r QuestionRepo) List(domain, status string) []model.Question { + q := r.Type(&model.Question{}) + if domain != "" { + q = q.Where("domain = ?", domain) + } + if status != "" { + q = q.Where("status = ?", status) + } + if status == "" { + q = q.Where("status = ?", "active") + } + var items []model.Question + if q.Order("id ASC").Find(&items) { + return items + } + return nil +} + +// GetByID 按 ID 获取题目。 +func (r QuestionRepo) GetByID(id uint) (model.Question, bool) { + var q model.Question + if r.Type(&q).Where("id = ?", id).First(&q) { + return q, true + } + return model.Question{}, false +} + +// GetActiveIDs 获取指定状态的题目 ID 列表。 +func (r QuestionRepo) GetActiveIDs(ids []uint) []uint { + var result []uint + r.Inner().Model(&model.Question{}).Where("id IN ? AND status = ?", ids, "active").Pluck("id", &result) + return result +} + +// GetByIDsWithDomain 获取题目列表(含知识域)。 +func (r QuestionRepo) GetByIDsWithDomain(ids []uint) []model.Question { + var items []model.Question + if r.Type(&items).Where("id IN ? AND status = ?", ids, "active").Find(&items) { + return items + } + return nil +} + +// CountByType 统计某类题目数量。 +func (r QuestionRepo) CountByType(domain, qType string) int64 { + q := r.Inner().Model(&model.Question{}) + if domain != "" { + q = q.Where("domain = ?", domain) + } + var c int64 + q.Where("type = ? AND status = ?", qType, "active").Count(&c) + return c +} + +// Insert 创建题目。 +func (r QuestionRepo) Insert(item *model.Question) bool { + return r.QueryBuilder.Insert(item) +} + +// Update 更新题目。 +func (r QuestionRepo) Update(item *model.Question) bool { + return r.Save(item) +} + +// Delete 软删除。 +func (r QuestionRepo) Delete(id uint) bool { + return r.Type(&model.Question{}).Where("id = ?", id).Updates(map[string]any{"status": "inactive"}) +} + +// PickRandom 随机抽取 N 道题。 +func (r QuestionRepo) PickRandom(domain, qType string, count int, excludeIDs []uint) []model.Question { + q := r.Type(&model.Question{}).Where("status = ? AND type = ?", "active", qType) + if domain != "" { + q = q.Where("domain = ?", domain) + } + if len(excludeIDs) > 0 { + q = q.Where("id NOT IN ?", excludeIDs) + } + var items []model.Question + if q.Order("RANDOM()").Limit(count).Find(&items) { + return items + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/repository/system_config.go b/eai_agentplatform/backend-go/internal/repository/system_config.go new file mode 100644 index 0000000..f75e05c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/system_config.go @@ -0,0 +1,56 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// SystemConfig 系统参数仓库。 +type SystemConfigRepo struct{ *QueryBuilder } + +// GetByKey 按 key 获取参数值。 +func (r SystemConfigRepo) GetByKey(key string) string { + var c model.SystemConfig + if r.Type(&c).Where("config_key = ?", key).First(&c) { + return c.ConfigValue + } + return "" +} + +// Get 按 key 获取完整记录。 +func (r SystemConfigRepo) Get(key string) (model.SystemConfig, bool) { + var c model.SystemConfig + if r.Type(&c).Where("config_key = ?", key).First(&c) { + return c, true + } + return model.SystemConfig{}, false +} + +// SetOrUpdate 设置或更新参数。 +func (r SystemConfigRepo) SetOrUpdate(key, value string) bool { + existing, found := r.Get(key) + if !found { + return r.Insert(&model.SystemConfig{ConfigKey: key, ConfigValue: value}) + } + existing.ConfigValue = value + return r.Save(&existing) +} + +// List 获取所有参数。 +func (r SystemConfigRepo) List() []model.SystemConfig { + var items []model.SystemConfig + if r.Type(&items).Find(&items) { + return items + } + return nil +} + +// BulkUpsert 批量插入或更新。 +func (r SystemConfigRepo) BulkUpsert(items []model.SystemConfig) int { + count := 0 + for _, item := range items { + if r.SetOrUpdate(item.ConfigKey, item.ConfigValue) { + count++ + } + } + return count +} diff --git a/eai_agentplatform/backend-go/internal/repository/user.go b/eai_agentplatform/backend-go/internal/repository/user.go new file mode 100644 index 0000000..41ece8c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/repository/user.go @@ -0,0 +1,81 @@ +package repository + +import ( + "eai_agentplatform/backend/internal/model" +) + +// User 用户仓库。 +type UserRepo struct{ *QueryBuilder } + +// List 获取用户列表(分页)。 +func (r UserRepo) List(page, pageSize int, role, status string) []model.User { + q := r.Type(&model.User{}) + if role != "" { + q = q.Where("role = ?", role) + } + if status != "" { + q = q.Where("status = ?", status) + } + offset := (page - 1) * pageSize + var items []model.User + if q.Order("id ASC").Limit(pageSize).Offset(offset).Find(&items) { + return items + } + return nil +} + +// Total 统计用户总数。 +func (r UserRepo) Total(role, status string) int64 { + q := r.Inner().Model(&model.User{}) + if role != "" { + q = q.Where("role = ?", role) + } + if status != "" { + q = q.Where("status = ?", status) + } + var c int64 + q.Count(&c) + return c +} + +// GetByID 按 ID 获取。 +func (r UserRepo) GetByID(id uint) (model.User, bool) { + var u model.User + if r.Type(&u).Where("id = ?", id).First(&u) { + return u, true + } + return model.User{}, false +} + +// GetByUsername 按用户名获取。 +func (r UserRepo) GetByUsername(username string) (model.User, bool) { + var u model.User + if r.Type(&u).Where("username = ?", username).First(&u) { + return u, true + } + return model.User{}, false +} + +// GetByEmail 按邮箱获取。 +func (r UserRepo) GetByEmail(email string) (model.User, bool) { + var u model.User + if r.Type(&u).Where("email = ?", email).First(&u) { + return u, true + } + return model.User{}, false +} + +// Insert 创建用户。 +func (r UserRepo) Insert(u *model.User) bool { + return r.QueryBuilder.Insert(u) +} + +// Update 更新用户。 +func (r UserRepo) Update(u *model.User) bool { + return r.Save(u) +} + +// UpdateStatus 更新状态。 +func (r UserRepo) UpdateStatus(id uint, status string) bool { + return r.Type(&model.User{}).Where("id = ?", id).UpdateColumn("status", status) +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go new file mode 100644 index 0000000..9d8b137 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go @@ -0,0 +1,122 @@ +package skillapi + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + skillmodel "eai_agentplatform/backend/internal/skills/model" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +func CreateSkillDefinition(c *gin.Context) { + var req definitionReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if appErr := validateDefinitionReq(&req); appErr != nil { + web.Fail(c, appErr) + return + } + + item := skillmodel.SkillDefinition{ + Key: req.Key, + Label: req.Label, + Description: req.Description, + ObjectKind: req.ObjectKind, + Source: req.Source, + ObjectEntryRoute: req.ObjectEntryRoute, + 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 skillmodel.SkillDefinition + if err := store.DB.First(&item, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("技能定义不存在")) + return + } + + var req definitionReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if appErr := validateDefinitionReq(&req); appErr != nil { + web.Fail(c, appErr) + return + } + + item.Key = req.Key + item.Label = req.Label + item.Description = req.Description + item.ObjectKind = req.ObjectKind + item.Source = req.Source + item.ObjectEntryRoute = req.ObjectEntryRoute + 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 skillmodel.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 parseID(c *gin.Context, name string) (uint, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || id == 0 { + web.Fail(c, web.NewBadRequest("无效的 "+name)) + return 0, false + } + return uint(id), true +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/office_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/office_handlers.go new file mode 100644 index 0000000..1ab3285 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/office_handlers.go @@ -0,0 +1,471 @@ +package skillapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + skilloffice "eai_agentplatform/backend/internal/skills/runtime/office" + officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" + + "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:"-"` +} + +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 := skilloffice.BuiltinRegistry().ByKey(req.SkillKey) + if !ok { + web.Fail(c, web.NewNotFoundError("技能不存在")) + return + } + + result := skilloffice.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, _ := reloadTask(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.TaskRun + var artifact model.TaskArtifact + err = store.DB.Transaction(func(tx *gorm.DB) error { + run = model.TaskRun{ + 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.TaskArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: fmt.Sprintf("%s - OCR 识别结果", task.Title), + ArtifactType: "ocr_text", + Status: specialistruntime.ArtifactStatusDraft, + ContentText: ocrPayload.Text, + ContentJSON: string(artifactPayloadJSON), + SourceRefsJSON: string(sourceRefsJSON), + CreatedByRunID: &run.ID, + } + if err := tx.Create(&artifact).Error; err != nil { + return err + } + + contextJSON := specialistruntime.ParseTaskContextJSON(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 = specialistruntime.TaskStatusDraft + task.LastTriggeredAt = &now + return tx.Save(&task).Error + }) + if err != nil { + web.Fail(c, web.NewBadRequest("保存 OCR 结果失败")) + return + } + + freshTask, _ := reloadTask(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, + }) +} + +func persistOfficeExecution(task model.TaskRecord, definition officecontracts.Runtime, inputText string, result gin.H) (model.TaskRun, []model.TaskArtifact, 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.TaskRun{} + artifacts := make([]model.TaskArtifact, 0) + + err := store.DB.Transaction(func(tx *gorm.DB) error { + run = model.TaskRun{ + 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.TaskArtifact{ + TaskID: task.ID, + SpecialistKey: task.SpecialistKey, + Title: firstNonEmpty(asString(item["title"]), definition.ResultTitle), + ArtifactType: firstNonEmpty(asString(item["kind"]), definition.ActionType, "text"), + Status: specialistruntime.ArtifactStatusDraft, + 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 = specialistruntime.TaskStatusDraft + task.LastTriggeredAt = &now + return tx.Save(&task).Error + }) + return run, artifacts, err +} + +func loadMyOwnedTask(c *gin.Context, user *model.User, taskID uint) (model.TaskRecord, bool) { + var task model.TaskRecord + if err := specialistruntime.MyTaskQuery(user).Where("id = ?", taskID).First(&task).Error; err != nil { + web.Fail(c, web.NewNotFoundError("任务不存在")) + return task, false + } + return task, true +} + +func reloadTask(taskID uint) (model.TaskRecord, error) { + var task model.TaskRecord + err := store.DB.First(&task, taskID).Error + return task, err +} + +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 +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/query_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/query_handlers.go new file mode 100644 index 0000000..ad84ca6 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/query_handlers.go @@ -0,0 +1,45 @@ +package skillapi + +import ( + "strings" + + "github.com/gin-gonic/gin" + + skillmodel "eai_agentplatform/backend/internal/skills/model" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +func ListSkillDefinitions(c *gin.Context) { + q := store.DB.Model(&skillmodel.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 []skillmodel.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 skillmodel.SkillDefinition + if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil { + web.Fail(c, web.NewNotFoundError("技能定义不存在")) + return + } + web.OK(c, item) +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/report_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/report_handlers.go new file mode 100644 index 0000000..23a0980 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/report_handlers.go @@ -0,0 +1,175 @@ +package skillapi + +import ( + "strings" + "time" + + "github.com/gin-gonic/gin" + + "eai_agentplatform/backend/internal/middleware" + reportgeneration "eai_agentplatform/backend/internal/skills/packages/report_generation" + "eai_agentplatform/backend/internal/web" +) + +// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文 +func ReportGenerate(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req reportgeneration.ReportGenRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" { + web.Fail(c, web.NewBadRequest("topic 必填")) + return + } + + // 1. 检索知识库片段 + spaceKey := "" + if req.Knowledge != "" { + spaceKey = req.Knowledge + } + knowledgeContent := reportgeneration.RetrieveReportKnowledge(req.Topic, spaceKey) + + // 2. 构建 LLM 提示词 + var chapters []reportgeneration.ReportChapter + if len(req.Sections) > 0 { + // 使用用户指定的章节 + chapters = reportgeneration.PromptReportWithSections(req, knowledgeContent) + } else { + // LLM 自动规划章节 + chapters = reportgeneration.PromptReportAutoChapters(req, knowledgeContent) + } + + // 3. 组装报告 + report := reportgeneration.ReportContent{ + Topic: req.Topic, + Summary: strings.TrimSpace(req.Summary), + Chapters: chapters, + CreatedAt: time.Now().Format("2006-01-02 15:04:05"), + } + + web.OK(c, gin.H{ + "report": report, + }) +} + +// ReportExportPDF POST /api/report/export/pdf —— 导出报告为 PDF 文件 +func ReportExportPDF(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req struct { + Report reportgeneration.ReportContent `json:"report"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { + web.Fail(c, web.NewBadRequest("report 不能为空")) + return + } + + data, err := reportgeneration.ExportReportPDF(req.Report) + if err != nil { + web.Fail(c, web.NewBadRequest("PDF 生成失败: "+err.Error())) + return + } + + c.Header("Content-Type", "application/pdf") + c.Header("Content-Disposition", + "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pdf\"") + c.Data(200, "application/pdf", data) +} + +// ReportPreview GET /api/report/preview —— 预览报告(JSON,用于前端预览) +func ReportPreview(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req reportgeneration.ReportGenRequest + if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" { + web.Fail(c, web.NewBadRequest("topic 必填")) + return + } + + // 使用 LLM 生成报告内容 + report := reportgeneration.PromptReportPreview(req) + + web.OK(c, gin.H{ + "report": report, + }) +} + +// ReportExportXLSX POST /api/report/export/xlsx —— 导出报告为 Excel +func ReportExportXLSX(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req struct { + Report reportgeneration.ReportContent `json:"report"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { + web.Fail(c, web.NewBadRequest("report 不能为空")) + return + } + data, err := reportgeneration.ExportReportXLSX(req.Report) + if err != nil { + web.Fail(c, web.NewBadRequest("Excel 生成失败: "+err.Error())) + return + } + c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".xlsx\"") + c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) +} + +// ReportExportDOCX POST /api/report/export/docx —— 导出报告为 Word +func ReportExportDOCX(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req struct { + Report reportgeneration.ReportContent `json:"report"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { + web.Fail(c, web.NewBadRequest("report 不能为空")) + return + } + data, err := reportgeneration.ExportReportDOCX(req.Report) + if err != nil { + web.Fail(c, web.NewBadRequest("Word 生成失败: "+err.Error())) + return + } + c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") + c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".docx\"") + c.Data(200, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data) +} + +// ReportExportPPTX POST /api/report/export/pptx —— 导出报告为 PPTX +func ReportExportPPTX(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req struct { + Report reportgeneration.ReportContent `json:"report"` + } + if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { + web.Fail(c, web.NewBadRequest("report 不能为空")) + return + } + data, err := reportgeneration.ExportReportPPTX(req.Report) + if err != nil { + web.Fail(c, web.NewBadRequest("PPTX 生成失败: "+err.Error())) + return + } + c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.presentationml.presentation") + c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pptx\"") + c.Data(200, "application/vnd.openxmlformats-officedocument.presentationml.presentation", data) +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/request.go b/eai_agentplatform/backend-go/internal/skills/api/request.go new file mode 100644 index 0000000..bcf9e28 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/request.go @@ -0,0 +1,21 @@ +package skillapi + +type definitionReq struct { + Key string `json:"key"` + Label string `json:"label"` + Description string `json:"description"` + ObjectKind string `json:"object_kind"` + Source string `json:"source"` + ObjectEntryRoute string `json:"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"` +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/validation.go b/eai_agentplatform/backend-go/internal/skills/api/validation.go new file mode 100644 index 0000000..86a5867 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/api/validation.go @@ -0,0 +1,72 @@ +package skillapi + +import ( + "strings" + + "eai_agentplatform/backend/internal/jsonutil" + "eai_agentplatform/backend/internal/web" +) + +func normalizeDefinitionReq(req *definitionReq) { + req.Key = strings.TrimSpace(req.Key) + req.Label = strings.TrimSpace(req.Label) + req.Description = strings.TrimSpace(req.Description) + req.ObjectKind = strings.TrimSpace(req.ObjectKind) + req.Source = strings.TrimSpace(req.Source) + req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute) + 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 validateDefinitionReq(req *definitionReq) *web.AppError { + normalizeDefinitionReq(req) + if req.Key == "" || req.Label == "" { + return web.NewBadRequest("key、label 为必填") + } + if req.ObjectKind == "" { + req.ObjectKind = "skill" + } + switch req.ObjectKind { + case "specialist", "skill": + default: + return web.NewBadRequest("object_kind 只能是 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 !jsonutil.ValidateStringArrayJSON(req.StarterPromptsJSON) { + return web.NewBadRequest("starter_prompts_json 必须是字符串数组") + } + if !jsonutil.ValidateObjectJSON(req.InputSchemaJSON) { + return web.NewBadRequest("input_schema_json 必须是 JSON 对象") + } + if !jsonutil.ValidateObjectJSON(req.OutputSchemaJSON) { + return web.NewBadRequest("output_schema_json 必须是 JSON 对象") + } + if !jsonutil.ValidateObjectJSON(req.ArtifactSchemaJSON) { + return web.NewBadRequest("artifact_schema_json 必须是 JSON 对象") + } + if !jsonutil.ValidateStringArrayJSON(req.ActionRefsJSON) { + return web.NewBadRequest("action_refs_json 必须是字符串数组") + } + if !jsonutil.ValidateStringArrayJSON(req.PolicyRefsJSON) { + return web.NewBadRequest("policy_refs_json 必须是字符串数组") + } + if !jsonutil.ValidateObjectJSON(req.OntologyBindingJSON) { + return web.NewBadRequest("ontology_binding_json 必须是 JSON 对象") + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/skills/contracts/manifest.go b/eai_agentplatform/backend-go/internal/skills/contracts/manifest.go new file mode 100644 index 0000000..076c6be --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/contracts/manifest.go @@ -0,0 +1,32 @@ +package skillcontracts + +type SharedReferencePolicy string + +const ( + SharedReferenceReadonlyHistory SharedReferencePolicy = "readonly_history" + SharedReferenceTombstone SharedReferencePolicy = "convert_to_tombstone" + SharedReferenceDetach SharedReferencePolicy = "detach_reference" + SharedReferenceBlockUninstall SharedReferencePolicy = "block_uninstall" +) + +type UninstallMeta struct { + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} + +type Manifest struct { + Key string + Label string + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} + +func (m Manifest) UninstallMeta() UninstallMeta { + return UninstallMeta{ + OwnedDefinitionKeys: append([]string(nil), m.OwnedDefinitionKeys...), + OwnedCatalogEntries: append([]string(nil), m.OwnedCatalogEntries...), + ReferencePolicy: m.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go new file mode 100644 index 0000000..7abd052 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go @@ -0,0 +1,77 @@ +package skillcore + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +var ValidSkillKeys = buildValidSkillKeys() + +func buildValidSkillKeys() map[string]bool { + keys := make(map[string]bool) + for _, manifest := range builtinRegistry.Manifests() { + if strings.TrimSpace(manifest.Key) == "" { + continue + } + keys[manifest.Key] = true + } + return keys +} + +func SortedValidSkillKeys() []string { + keys := make([]string, 0, len(ValidSkillKeys)) + for k := range ValidSkillKeys { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func ParseAllowedSkills(raw string) []string { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + var keys []string + if err := json.Unmarshal([]byte(trimmed), &keys); err != nil { + return nil + } + out := make([]string, 0, len(keys)) + for _, k := range keys { + if k = strings.TrimSpace(k); k != "" { + out = append(out, k) + } + } + return out +} + +func MarshalAllowedSkills(keys []string) (string, error) { + if len(keys) == 0 { + return "", nil + } + raw, err := json.Marshal(keys) + if err != nil { + return "", err + } + return string(raw), nil +} + +func ValidateAllowedSkills(keys []string) error { + seen := make(map[string]bool, len(keys)) + for _, k := range keys { + k = strings.TrimSpace(k) + if k == "" { + continue + } + if !ValidSkillKeys[k] { + return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(SortedValidSkillKeys(), " / ")) + } + if seen[k] { + return fmt.Errorf("技能 key %q 重复绑定", k) + } + seen[k] = true + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/model/skill_keys_test.go b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go similarity index 66% rename from eai_agentplatform/backend-go/internal/model/skill_keys_test.go rename to eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go index c05e7bd..7ead923 100644 --- a/eai_agentplatform/backend-go/internal/model/skill_keys_test.go +++ b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go @@ -1,4 +1,4 @@ -package model +package skillcore import ( "os" @@ -9,34 +9,23 @@ import ( "testing" ) -// workbenchJSRelPath 前端技能目录的相对路径(相对本包目录)。 -const workbenchJSRelPath = "../../../frontend/src/config/workbench.js" +const packageDirGlob = "../../../../frontend/src/skills/packages/*/manifest.js" -// createSkillDefinitionRe 抓 staticSkillCatalog 数组里每个 createSkillDefinition 的字面量 key。 -// 依赖 workbench.js 里「createSkillDefinition({ 紧跟 key: '…'」这一写法。 -var createSkillDefinitionRe = regexp.MustCompile(`createSkillDefinition\(\{\s*key:\s*'([^']+)'`) +var manifestKeyRe = regexp.MustCompile(`key:\s*'([^']+)'`) -// TestValidSkillKeysMatchFrontend 守住 backend 的 ValidSkillKeys 与前端 staticSkillCatalog 一致。 -// -// 这两份清单天然会漂移:技能实现在前端,后端只持有 key 做录入校验。任何一侧增删技能 -// 而忘了另一侧,这里就会红——这正是本测试存在的唯一理由。 -// -// 交付形态是没有前端源码的(单二进制 + 整盘克隆),找不到文件时跳过而不是失败。 func TestValidSkillKeysMatchFrontend(t *testing.T) { - raw, err := os.ReadFile(filepath.Clean(workbenchJSRelPath)) - if err != nil { - t.Skipf("跳过:读不到前端 %s(交付形态无前端源码): %v", workbenchJSRelPath, err) + frontendKeys := make(map[string]bool) + + packageFiles, err := filepath.Glob(filepath.Clean(packageDirGlob)) + if err != nil || len(packageFiles) == 0 { + t.Fatalf("未找到前端 skill package manifests: %v", err) + } + for _, path := range packageFiles { + mergeKeysFromFile(t, path, manifestKeyRe, frontendKeys) } - matches := createSkillDefinitionRe.FindAllStringSubmatch(string(raw), -1) - if len(matches) == 0 { - t.Fatalf("在前端 %s 里没抓到任何 createSkillDefinition key —— "+ - "可能是正则失效(写法改了),请同步更新 createSkillDefinitionRe", workbenchJSRelPath) - } - - frontendKeys := make(map[string]bool, len(matches)) - for _, m := range matches { - frontendKeys[m[1]] = true + if len(frontendKeys) == 0 { + t.Fatal("前端 skill package manifest 未解析出任何 key,请同步更新测试正则") } var onlyBackend, onlyFrontend []string @@ -54,11 +43,11 @@ func TestValidSkillKeysMatchFrontend(t *testing.T) { sort.Strings(onlyFrontend) if len(onlyBackend) > 0 { - t.Errorf("ValidSkillKeys 里有、前端 staticSkillCatalog 里没有的 key(后端多写或前端已删):\n %s", + t.Errorf("ValidSkillKeys 里有、前端 skill manifests 里没有的 key:\n %s", strings.Join(onlyBackend, "\n ")) } if len(onlyFrontend) > 0 { - t.Errorf("前端 staticSkillCatalog 里有、ValidSkillKeys 里没有的 key(新增技能后忘了补后端清单):\n %s", + t.Errorf("前端 skill manifests 里有、ValidSkillKeys 里没有的 key:\n %s", strings.Join(onlyFrontend, "\n ")) } } @@ -150,3 +139,24 @@ func TestValidateAllowedSkills(t *testing.T) { } }) } + +func readFrontendFile(t *testing.T, relPath string) []byte { + t.Helper() + raw, err := os.ReadFile(filepath.Clean(relPath)) + if err != nil { + t.Skipf("跳过:读不到前端 %s(交付形态无前端源码): %v", relPath, err) + } + return raw +} + +func mergeKeysFromFile(t *testing.T, relPath string, re *regexp.Regexp, out map[string]bool) { + t.Helper() + raw := readFrontendFile(t, relPath) + matches := re.FindAllStringSubmatch(string(raw), -1) + if len(matches) == 0 { + t.Fatalf("在前端 %s 里没抓到任何技能 key,请同步更新测试正则", relPath) + } + for _, match := range matches { + out[match[1]] = true + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/core/builtin.go b/eai_agentplatform/backend-go/internal/skills/core/builtin.go new file mode 100644 index 0000000..a59bd18 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/core/builtin.go @@ -0,0 +1,55 @@ +package skillcore + +import ( + audiotranscribe "eai_agentplatform/backend/internal/skills/packages/audio_transcribe" + batchextract "eai_agentplatform/backend/internal/skills/packages/batch_extract" + contractbrief "eai_agentplatform/backend/internal/skills/packages/contract_brief" + contractreview "eai_agentplatform/backend/internal/skills/packages/contract_review" + copyproofreading "eai_agentplatform/backend/internal/skills/packages/copy_proofreading" + documenttranslate "eai_agentplatform/backend/internal/skills/packages/document_translate" + emaildrafting "eai_agentplatform/backend/internal/skills/packages/email_drafting" + interviewsummary "eai_agentplatform/backend/internal/skills/packages/interview_summary" + longformwriting "eai_agentplatform/backend/internal/skills/packages/longform_writing" + meetingminutes "eai_agentplatform/backend/internal/skills/packages/meeting_minutes" + mindmap "eai_agentplatform/backend/internal/skills/packages/mind_map" + ocrunderstanding "eai_agentplatform/backend/internal/skills/packages/ocr_understanding" + policyrewrite "eai_agentplatform/backend/internal/skills/packages/policy_rewrite" + pptgeneration "eai_agentplatform/backend/internal/skills/packages/ppt_generation" + progressreport "eai_agentplatform/backend/internal/skills/packages/progress_report" + projectplanning "eai_agentplatform/backend/internal/skills/packages/project_planning" + proposalsummary "eai_agentplatform/backend/internal/skills/packages/proposal_summary" + reportgeneration "eai_agentplatform/backend/internal/skills/packages/report_generation" + smartassistant "eai_agentplatform/backend/internal/skills/packages/smart_assistant" + surveysummary "eai_agentplatform/backend/internal/skills/packages/survey_summary" + tablecleanup "eai_agentplatform/backend/internal/skills/packages/table_cleanup" + texttoolkit "eai_agentplatform/backend/internal/skills/packages/text_toolkit" +) + +var builtinRegistry = NewRegistry( + smartassistant.Manifest, + documenttranslate.Manifest, + copyproofreading.Manifest, + audiotranscribe.Manifest, + batchextract.Manifest, + contractreview.Manifest, + reportgeneration.Manifest, + pptgeneration.Manifest, + mindmap.Manifest, + longformwriting.Manifest, + texttoolkit.Manifest, + ocrunderstanding.Manifest, + meetingminutes.Manifest, + projectplanning.Manifest, + emaildrafting.Manifest, + tablecleanup.Manifest, + proposalsummary.Manifest, + progressreport.Manifest, + contractbrief.Manifest, + interviewsummary.Manifest, + policyrewrite.Manifest, + surveysummary.Manifest, +) + +func BuiltinRegistry() *Registry { + return builtinRegistry +} diff --git a/eai_agentplatform/backend-go/internal/skills/core/registry.go b/eai_agentplatform/backend-go/internal/skills/core/registry.go new file mode 100644 index 0000000..058db00 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/core/registry.go @@ -0,0 +1,38 @@ +package skillcore + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +type Registry struct { + manifests []skillcontracts.Manifest + byKey map[string]skillcontracts.Manifest +} + +func NewRegistry(manifests ...skillcontracts.Manifest) *Registry { + items := make([]skillcontracts.Manifest, 0, len(manifests)) + byKey := make(map[string]skillcontracts.Manifest, len(manifests)) + for _, manifest := range manifests { + if manifest.Key == "" { + continue + } + items = append(items, manifest) + byKey[manifest.Key] = manifest + } + return &Registry{manifests: items, byKey: byKey} +} + +func (r *Registry) Manifests() []skillcontracts.Manifest { + if r == nil { + return nil + } + items := make([]skillcontracts.Manifest, len(r.manifests)) + copy(items, r.manifests) + return items +} + +func (r *Registry) ByKey(key string) (skillcontracts.Manifest, bool) { + if r == nil { + return skillcontracts.Manifest{}, false + } + item, ok := r.byKey[key] + return item, ok +} diff --git a/eai_agentplatform/backend-go/internal/skills/core/uninstall.go b/eai_agentplatform/backend-go/internal/skills/core/uninstall.go new file mode 100644 index 0000000..15593d8 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/core/uninstall.go @@ -0,0 +1,18 @@ +package skillcore + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +type UninstallPreview struct { + DefinitionKeys []string + CatalogEntries []string + Policy skillcontracts.SharedReferencePolicy +} + +func BuildUninstallPreview(manifest skillcontracts.Manifest) UninstallPreview { + meta := manifest.UninstallMeta() + return UninstallPreview{ + DefinitionKeys: meta.OwnedDefinitionKeys, + CatalogEntries: meta.OwnedCatalogEntries, + Policy: meta.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/model/skill_definition.go b/eai_agentplatform/backend-go/internal/skills/model/skill_definition.go similarity index 91% rename from eai_agentplatform/backend-go/internal/model/skill_definition.go rename to eai_agentplatform/backend-go/internal/skills/model/skill_definition.go index f19fab8..ebdd54c 100644 --- a/eai_agentplatform/backend-go/internal/model/skill_definition.go +++ b/eai_agentplatform/backend-go/internal/skills/model/skill_definition.go @@ -1,4 +1,4 @@ -package model +package skillmodel import "time" @@ -10,8 +10,8 @@ type SkillDefinition struct { 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"` - ObjectKind string `gorm:"column:object_kind;size:32;not null;default:skill;index" json:"object_kind"` // assistant / specialist / skill - Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom + ObjectKind string `gorm:"column:object_kind;size:32;not null;default:skill;index" json:"object_kind"` + Source string `gorm:"size:32;not null;default:eai;index" json:"source"` ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;default:''" json:"object_entry_route"` ExposedToUser bool `gorm:"not null;default:true;index" json:"exposed_to_user"` StarterPromptsJSON string `gorm:"type:text" json:"starter_prompts_json"` diff --git a/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/manifest.go new file mode 100644 index 0000000..059562d --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/audio_transcribe/manifest.go @@ -0,0 +1,11 @@ +package audiotranscribe + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "audio-transcribe", + Label: "语音转写", + OwnedDefinitionKeys: []string{"skill.audio-transcribe.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.audio-transcribe"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/batch_extract/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/batch_extract/manifest.go new file mode 100644 index 0000000..a7f79d6 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/batch_extract/manifest.go @@ -0,0 +1,11 @@ +package batchextract + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "batch-extract", + Label: "批量提取", + OwnedDefinitionKeys: []string{"skill.batch-extract.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.batch-extract"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/contract_brief/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/contract_brief/manifest.go new file mode 100644 index 0000000..ceb338f --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/contract_brief/manifest.go @@ -0,0 +1,11 @@ +package contractbrief + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "contract-brief", + Label: "合同要点提炼", + OwnedDefinitionKeys: []string{"skill.contract-brief.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.contract-brief"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/contract_brief/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/contract_brief/runtime.go new file mode 100644 index 0000000..04357e3 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/contract_brief/runtime.go @@ -0,0 +1,14 @@ +package contractbrief + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "contract-brief", + Label: "合同要点提炼", + PlanTitle: "合同要点提炼流程", + PlanSteps: []string{"识别合同结构", "提炼核心条款", "标记风险事项", "输出摘要版本"}, + ResultTitle: "合同要点摘要", + Summary: "已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。", + Highlights: []string{"适合采购、合作、服务类合同", "自动压缩核心条款与风险点", "默认沉淀摘要、风险和待确认项"}, + ActionType: "summary", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/contract_review/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/contract_review/manifest.go new file mode 100644 index 0000000..3820c63 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/contract_review/manifest.go @@ -0,0 +1,11 @@ +package contractreview + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "contract-review", + Label: "合同审查", + OwnedDefinitionKeys: []string{"skill.contract-review.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.contract-review"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/copy_proofreading/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/copy_proofreading/manifest.go new file mode 100644 index 0000000..3ab2993 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/copy_proofreading/manifest.go @@ -0,0 +1,11 @@ +package copyproofreading + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "copy-proofreading", + Label: "文案校对", + OwnedDefinitionKeys: []string{"skill.copy-proofreading.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.copy-proofreading"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/document_translate/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/document_translate/manifest.go new file mode 100644 index 0000000..1b42bc9 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/document_translate/manifest.go @@ -0,0 +1,11 @@ +package documenttranslate + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "document-translate", + Label: "文档翻译", + OwnedDefinitionKeys: []string{"skill.document-translate.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.document-translate"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/email_drafting/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/email_drafting/manifest.go new file mode 100644 index 0000000..a38e97c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/email_drafting/manifest.go @@ -0,0 +1,11 @@ +package emaildrafting + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "email-drafting", + Label: "邮件起草", + OwnedDefinitionKeys: []string{"skill.email-drafting.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.email-drafting"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/email_drafting/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/email_drafting/runtime.go new file mode 100644 index 0000000..1fcf7d8 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/email_drafting/runtime.go @@ -0,0 +1,14 @@ +package emaildrafting + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "email-drafting", + Label: "邮件起草", + PlanTitle: "邮件起草流程", + PlanSteps: []string{"识别沟通目的", "整理背景与重点", "生成正式邮件", "补充简短跟进版本"}, + ResultTitle: "邮件草稿", + Summary: "已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。", + Highlights: []string{"适合汇报、催办、确认和通知", "兼顾正式语气与清晰表达", "默认沉淀长邮件和短消息版本"}, + ActionType: "communication", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/interview_summary/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/interview_summary/manifest.go new file mode 100644 index 0000000..46ec577 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/interview_summary/manifest.go @@ -0,0 +1,11 @@ +package interviewsummary + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "interview-summary", + Label: "访谈整理", + OwnedDefinitionKeys: []string{"skill.interview-summary.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.interview-summary"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/interview_summary/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/interview_summary/runtime.go new file mode 100644 index 0000000..28c8dd1 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/interview_summary/runtime.go @@ -0,0 +1,14 @@ +package interviewsummary + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "interview-summary", + Label: "访谈整理", + PlanTitle: "访谈整理流程", + PlanSteps: []string{"归并访谈内容", "提炼主题观点", "总结关键洞察", "输出整理版本"}, + ResultTitle: "访谈整理稿", + Summary: "已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。", + Highlights: []string{"适合用户访谈、需求调研、深度沟通", "自动提炼主题与洞察", "默认沉淀摘要、洞察和行动建议"}, + ActionType: "document", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/longform_writing/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/longform_writing/manifest.go new file mode 100644 index 0000000..fe574f2 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/longform_writing/manifest.go @@ -0,0 +1,11 @@ +package longformwriting + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "longform-writing", + Label: "长文创作", + OwnedDefinitionKeys: []string{"skill.longform-writing.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.longform-writing"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/longform_writing/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/longform_writing/runtime.go new file mode 100644 index 0000000..e3fdae3 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/longform_writing/runtime.go @@ -0,0 +1,14 @@ +package longformwriting + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "longform-writing", + Label: "长文创作", + PlanTitle: "长文创作流程", + PlanSteps: []string{"明确文体与受众", "规划章节大纲", "生成正文骨架", "整理摘要与交付稿"}, + ResultTitle: "长文草稿", + Summary: "已输出一版长文大纲和正文骨架,方便继续扩写为正式稿。", + Highlights: []string{"先给大纲再给正文草稿", "适合方案、制度和长文章", "结果可下载继续改写"}, + ActionType: "document", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/meeting_minutes/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/meeting_minutes/manifest.go new file mode 100644 index 0000000..05dea98 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/meeting_minutes/manifest.go @@ -0,0 +1,11 @@ +package meetingminutes + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "meeting-minutes", + Label: "会议纪要", + OwnedDefinitionKeys: []string{"skill.meeting-minutes.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.meeting-minutes"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/meeting_minutes/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/meeting_minutes/runtime.go new file mode 100644 index 0000000..b1823e8 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/meeting_minutes/runtime.go @@ -0,0 +1,14 @@ +package meetingminutes + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "meeting-minutes", + Label: "会议纪要", + PlanTitle: "会议纪要整理流程", + PlanSteps: []string{"识别会议主题", "提炼关键结论", "整理行动项", "输出同步版本"}, + ResultTitle: "会议纪要草稿", + Summary: "已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。", + Highlights: []string{"自动提炼结论与待办", "适合例会、评审会和复盘会", "默认沉淀纪要正文和行动项清单"}, + ActionType: "document", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/mind_map/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/mind_map/manifest.go new file mode 100644 index 0000000..1533315 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/mind_map/manifest.go @@ -0,0 +1,11 @@ +package mindmap + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "mind-map", + Label: "思维导图", + OwnedDefinitionKeys: []string{"skill.mind-map.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.mind-map"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/mind_map/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/mind_map/runtime.go new file mode 100644 index 0000000..cec4ac3 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/mind_map/runtime.go @@ -0,0 +1,14 @@ +package mindmap + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "mind-map", + Label: "思维导图", + PlanTitle: "思维导图生成流程", + PlanSteps: []string{"识别主题主线", "拆分一级分支", "补齐关键节点", "整理导图源结构"}, + ResultTitle: "导图结构草案", + Summary: "已把输入内容压成导图结构,并生成可继续分享和编辑的导图结果。", + Highlights: []string{"可下载导图 SVG", "保留导图文字结构", "适合任务拆解与知识梳理"}, + ActionType: "mindmap", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/ocr_understanding/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/ocr_understanding/manifest.go new file mode 100644 index 0000000..d175f70 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/ocr_understanding/manifest.go @@ -0,0 +1,11 @@ +package ocrunderstanding + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "ocr-understanding", + Label: "OCR / 图文理解", + OwnedDefinitionKeys: []string{"skill.ocr-understanding.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.ocr-understanding"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/ocr_understanding/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/ocr_understanding/runtime.go new file mode 100644 index 0000000..7059615 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/ocr_understanding/runtime.go @@ -0,0 +1,14 @@ +package ocrunderstanding + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "ocr-understanding", + Label: "OCR / 图文理解", + PlanTitle: "图文理解流程", + PlanSteps: []string{"识别图像内容", "抽取文字与字段", "整理结构摘要", "输出可复用结果"}, + ResultTitle: "识别结果摘要", + Summary: "上传图片后,会优先使用 PaddleOCR 识别文字,并把结果写入当前任务产物。", + Highlights: []string{"走后端正式 OCR 链路", "识别结果会写入任务产物", "上传图片会长期保留到任务输入"}, + ActionType: "ocr", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/policy_rewrite/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/policy_rewrite/manifest.go new file mode 100644 index 0000000..abe65d8 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/policy_rewrite/manifest.go @@ -0,0 +1,11 @@ +package policyrewrite + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "policy-rewrite", + Label: "制度改写", + OwnedDefinitionKeys: []string{"skill.policy-rewrite.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.policy-rewrite"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/policy_rewrite/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/policy_rewrite/runtime.go new file mode 100644 index 0000000..ce1f4d5 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/policy_rewrite/runtime.go @@ -0,0 +1,14 @@ +package policyrewrite + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "policy-rewrite", + Label: "制度改写", + PlanTitle: "制度改写流程", + PlanSteps: []string{"识别制度目标", "统一结构口径", "重写正式正文", "补充修订说明"}, + ResultTitle: "制度改写稿", + Summary: "已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。", + Highlights: []string{"适合制度、流程、规范文档", "兼顾正式表达与清晰可读", "默认沉淀改写正文和修订说明"}, + ActionType: "document", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/ppt_generation/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/ppt_generation/manifest.go new file mode 100644 index 0000000..d47dc3a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/ppt_generation/manifest.go @@ -0,0 +1,11 @@ +package pptgeneration + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "ppt-generation", + Label: "PPT 生成", + OwnedDefinitionKeys: []string{"skill.ppt-generation.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.ppt-generation"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/ppt_generation/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/ppt_generation/runtime.go new file mode 100644 index 0000000..b34e78a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/ppt_generation/runtime.go @@ -0,0 +1,14 @@ +package pptgeneration + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "ppt-generation", + Label: "PPT 生成", + PlanTitle: "PPT 生成流程", + PlanSteps: []string{"确认汇报目标", "规划页面结构", "提炼每页要点", "整理讲稿备注"}, + ResultTitle: "演示文稿骨架", + Summary: "已按输入内容整理出演示逻辑,并生成可继续下载和细化的 PPT 结果。", + Highlights: []string{"支持直接下载 .pptx", "同时保留大纲与讲稿备注", "适合汇报、方案和复盘"}, + ActionType: "presentation", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/progress_report/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/progress_report/manifest.go new file mode 100644 index 0000000..1143365 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/progress_report/manifest.go @@ -0,0 +1,11 @@ +package progressreport + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "progress-report", + Label: "周报 / 日报", + OwnedDefinitionKeys: []string{"skill.progress-report.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.progress-report"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/progress_report/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/progress_report/runtime.go new file mode 100644 index 0000000..96b3d78 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/progress_report/runtime.go @@ -0,0 +1,14 @@ +package progressreport + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "progress-report", + Label: "周报 / 日报", + PlanTitle: "进展汇报流程", + PlanSteps: []string{"归并本期工作", "提炼关键进展", "整理风险事项", "输出汇报版本"}, + ResultTitle: "进展汇报草稿", + Summary: "已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。", + Highlights: []string{"适合日报、周报和阶段同步", "自动补齐风险与下步计划", "默认沉淀正式汇报版和简短摘要版"}, + ActionType: "report", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/project_planning/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/project_planning/manifest.go new file mode 100644 index 0000000..c9355ea --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/project_planning/manifest.go @@ -0,0 +1,11 @@ +package projectplanning + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "project-planning", + Label: "项目规划", + OwnedDefinitionKeys: []string{"skill.project-planning.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.project-planning"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/project_planning/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/project_planning/runtime.go new file mode 100644 index 0000000..d8fc53d --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/project_planning/runtime.go @@ -0,0 +1,14 @@ +package projectplanning + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "project-planning", + Label: "项目规划", + PlanTitle: "项目规划流程", + PlanSteps: []string{"明确目标边界", "拆解阶段路径", "整理里程碑", "补齐风险与依赖"}, + ResultTitle: "项目规划草案", + Summary: "已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。", + Highlights: []string{"适合启动、排期和推进对齐", "默认沉淀阶段计划与风险清单", "结果适合继续流转到任务治理"}, + ActionType: "plan", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/proposal_summary/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/proposal_summary/manifest.go new file mode 100644 index 0000000..0c0ed20 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/proposal_summary/manifest.go @@ -0,0 +1,11 @@ +package proposalsummary + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "proposal-summary", + Label: "方案摘要", + OwnedDefinitionKeys: []string{"skill.proposal-summary.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.proposal-summary"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/proposal_summary/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/proposal_summary/runtime.go new file mode 100644 index 0000000..1c3cd80 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/proposal_summary/runtime.go @@ -0,0 +1,14 @@ +package proposalsummary + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "proposal-summary", + Label: "方案摘要", + PlanTitle: "方案摘要流程", + PlanSteps: []string{"识别方案目标", "提炼核心亮点", "压缩实施路径", "输出摘要版本"}, + ResultTitle: "方案摘要稿", + Summary: "已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。", + Highlights: []string{"适合方案汇报和管理层预读", "自动压缩价值点与实施路径", "默认沉淀摘要正文和亮点清单"}, + ActionType: "summary", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/report_generation/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/report_generation/manifest.go new file mode 100644 index 0000000..f3e288a --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/report_generation/manifest.go @@ -0,0 +1,11 @@ +package reportgeneration + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "report-generation", + Label: "报告生成", + OwnedDefinitionKeys: []string{"skill.report-generation.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.report-generation"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/api/report_gen.go b/eai_agentplatform/backend-go/internal/skills/packages/report_generation/report_content.go similarity index 51% rename from eai_agentplatform/backend-go/internal/api/report_gen.go rename to eai_agentplatform/backend-go/internal/skills/packages/report_generation/report_content.go index 5107cf9..33b1f57 100644 --- a/eai_agentplatform/backend-go/internal/api/report_gen.go +++ b/eai_agentplatform/backend-go/internal/skills/packages/report_generation/report_content.go @@ -1,4 +1,4 @@ -package api +package reportgeneration import ( "encoding/json" @@ -6,17 +6,13 @@ import ( "strings" "time" - "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/store" - "eai_agentplatform/backend/internal/web" ) -// ReportGenRequest POST /api/report/generate 报告生成请求 +// ReportGenRequest 报告生成请求 type ReportGenRequest struct { Topic string `json:"topic"` // 报告主题,必填 Summary string `json:"summary"` // 报告摘要/背景 @@ -25,65 +21,22 @@ type ReportGenRequest struct { Knowledge string `json:"knowledge_key"` // 关联知识库 key } -// reportChapter 报告章节结构 -type reportChapter struct { +// ReportChapter 报告章节结构 +type ReportChapter struct { Title string `json:"title"` Body string `json:"body"` } -// reportContent 完整报告结构 -type reportContent struct { +// ReportContent 完整报告结构 +type ReportContent struct { Topic string `json:"topic"` Summary string `json:"summary"` - Chapters []reportChapter `json:"chapters"` + Chapters []ReportChapter `json:"chapters"` CreatedAt string `json:"created_at"` } -// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文 -func ReportGenerate(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var req ReportGenRequest - if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" { - web.Fail(c, web.NewBadRequest("topic 必填")) - return - } - - // 1. 检索知识库片段 - spaceKey := "" - if req.Knowledge != "" { - spaceKey = req.Knowledge - } - knowledgeContent := retrieveReportKnowledge(req.Topic, spaceKey) - - // 2. 构建 LLM 提示词 - chapters := make([]reportChapter, 0) - if len(req.Sections) > 0 { - // 使用用户指定的章节 - chapters = promptReportWithSections(req, knowledgeContent) - } else { - // LLM 自动规划章节 - chapters = promptReportAutoChapters(req, knowledgeContent) - } - - // 3. 组装报告 - report := reportContent{ - Topic: req.Topic, - Summary: strings.TrimSpace(req.Summary), - Chapters: chapters, - CreatedAt: time.Now().Format("2006-01-02 15:04:05"), - } - - web.OK(c, gin.H{ - "report": report, - }) -} - -// retrieveReportKnowledge 为报告生成检索知识库片段 -func retrieveReportKnowledge(topic string, spaceKey string) string { +// RetrieveReportKnowledge 为报告生成检索知识库片段 +func RetrieveReportKnowledge(topic string, spaceKey string) string { if spaceKey != "" { // 按空间检索 var space model.KnowledgeSpace @@ -110,17 +63,13 @@ func retrieveReportKnowledge(topic string, spaceKey string) string { Find(&chunks) parts := make([]string, 0, len(chunks)) for _, ch := range chunks { - // 这里原本是 fmt.Sprintf("【%s】%s", ch.Content) —— 两个占位符只给了一个参数, - // 实际拼出来是「【%!s(MISSING)】正文」,这段字符串是直接进 LLM prompt 的, - // 等于每个片段前面糊一句乱码。KnowledgeChunk 没有 title 字段, - // 没有东西可以填进【】,所以和上面按空间检索的分支对齐,直接取正文。 parts = append(parts, ch.Content) } return strings.Join(parts, "\n\n") } -// promptReportWithSections 使用用户指定章节生成报告 -func promptReportWithSections(req ReportGenRequest, knowledge string) []reportChapter { +// PromptReportWithSections 使用用户指定章节生成报告 +func PromptReportWithSections(req ReportGenRequest, knowledge string) []ReportChapter { sectionsStr := strings.Join(req.Sections, "、") systemPrompt := `你是 EAI 数字员工平台的报告生成引擎。你的任务是输出一份结构化的正式报告,语言为中文。` userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。 @@ -156,8 +105,8 @@ func promptReportWithSections(req ReportGenRequest, knowledge string) []reportCh return parseReportMarkdown(aiContent, req.Sections) } -// promptReportAutoChapters 让 LLM 自动规划报告章节 -func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportChapter { +// PromptReportAutoChapters 让 LLM 自动规划报告章节 +func PromptReportAutoChapters(req ReportGenRequest, knowledge string) []ReportChapter { systemPrompt := `你是 EAI 数字员工平台的报告生成引擎。先规划报告章节,再撰写内容。输出必须严格为 JSON 数组,不要输出其他内容。` userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。 @@ -193,13 +142,13 @@ func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportCh } // parseReportMarkdown 从 Markdown 格式解析报告为章节数组 -func parseReportMarkdown(content string, defaultSections []string) []reportChapter { +func parseReportMarkdown(content string, defaultSections []string) []ReportChapter { if len(defaultSections) == 0 { defaultSections = []string{"背景概述", "核心内容", "关键数据", "实施方案", "总结建议"} } lines := strings.Split(content, "\n") - chapters := make([]reportChapter, 0, len(defaultSections)) + chapters := make([]ReportChapter, 0, len(defaultSections)) currentTitle := "" currentBody := "" @@ -235,7 +184,7 @@ func parseReportMarkdown(content string, defaultSections []string) []reportChapt if len(chapters) < 10 && isChapterTitle(line) { if currentTitle != "" && currentBody != "" { - chapters = append(chapters, reportChapter{ + chapters = append(chapters, ReportChapter{ Title: strings.TrimSpace(currentTitle), Body: strings.TrimSpace(currentBody), }) @@ -252,7 +201,7 @@ func parseReportMarkdown(content string, defaultSections []string) []reportChapt } if currentTitle != "" && currentBody != "" { - chapters = append(chapters, reportChapter{ + chapters = append(chapters, ReportChapter{ Title: strings.TrimSpace(currentTitle), Body: strings.TrimSpace(currentBody), }) @@ -260,7 +209,7 @@ func parseReportMarkdown(content string, defaultSections []string) []reportChapt if len(chapters) == 0 { for _, title := range defaultSections { - chapters = append(chapters, reportChapter{ + chapters = append(chapters, ReportChapter{ Title: title, Body: content, }) @@ -275,7 +224,7 @@ func parseReportMarkdown(content string, defaultSections []string) []reportChapt } // parseReportJSON 从 LLM 返回的 JSON 解析报告 -func parseReportJSON(content string) []reportChapter { +func parseReportJSON(content string) []ReportChapter { // 提取 JSON 块 jsonStr := content if idx := strings.Index(content, "["); idx >= 0 { @@ -285,7 +234,7 @@ func parseReportJSON(content string) []reportChapter { jsonStr = jsonStr[:idx+1] } - var chapters []reportChapter + var chapters []ReportChapter if err := json.Unmarshal([]byte(jsonStr), &chapters); err == nil && len(chapters) > 0 { return chapters } @@ -305,12 +254,12 @@ func parseReportJSON(content string) []reportChapter { } // fallbackReport 无 LLM 时的默认报告模板 -func fallbackReport(topic string) []reportChapter { +func fallbackReport(topic string) []ReportChapter { title := topic if title == "" { title = "分析报告" } - return []reportChapter{ + return []ReportChapter{ { Title: "一、背景概述", Body: fmt.Sprintf("本报告围绕「%s」主题展开,旨在梳理相关背景信息与核心要点,为内部学习与汇报提供参考。", title), @@ -329,143 +278,23 @@ func fallbackReport(topic string) []reportChapter { }, { Title: "五、总结建议", - Body: "整体而言,「%s」是当前业务发展的核心方向之一。建议持续投入资源,建立系统化培训体系,结合 AI 工具提升效率,确保团队能力与业务发展同步。" + title, + Body: fmt.Sprintf("整体而言,「%s」是当前业务发展的核心方向之一。建议持续投入资源,建立系统化培训体系,结合 AI 工具提升效率,确保团队能力与业务发展同步。", title), }, } } -// ReportExportPDF POST /api/report/export/pdf —— 导出报告为 PDF 文件 -func ReportExportPDF(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var req struct { - Report reportContent `json:"report"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { - web.Fail(c, web.NewBadRequest("report 不能为空")) - return - } - - data, err := ExportReportPDF(req.Report) - if err != nil { - web.Fail(c, web.NewBadRequest("PDF 生成失败: "+err.Error())) - return - } - - c.Header("Content-Type", "application/pdf") - c.Header("Content-Disposition", - "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pdf\"") - c.Data(200, "application/pdf", data) -} - -// ReportPreview GET /api/report/preview —— 预览报告(JSON,用于前端预览) -func ReportPreview(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var req ReportGenRequest - if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" { - web.Fail(c, web.NewBadRequest("topic 必填")) - return - } - - // 使用 LLM 生成报告内容 - report := promptReportPreview(req) - - web.OK(c, gin.H{ - "report": report, - }) -} - -// promptReportPreview 预览模式:返回精简报告 -func promptReportPreview(req ReportGenRequest) reportContent { - chapters := []reportChapter{} +// PromptReportPreview 预览模式:返回精简报告 +func PromptReportPreview(req ReportGenRequest) ReportContent { + var chapters []ReportChapter if len(req.Sections) > 0 { - chapters = promptReportWithSections(req, retrieveReportKnowledge(req.Topic, req.Knowledge)) + chapters = PromptReportWithSections(req, RetrieveReportKnowledge(req.Topic, req.Knowledge)) } else { - chapters = promptReportAutoChapters(req, retrieveReportKnowledge(req.Topic, req.Knowledge)) + chapters = PromptReportAutoChapters(req, RetrieveReportKnowledge(req.Topic, req.Knowledge)) } - return reportContent{ + return ReportContent{ Topic: req.Topic, Summary: strings.TrimSpace(req.Summary), Chapters: chapters, CreatedAt: time.Now().Format("2006-01-02 15:04:05"), } } - -// ReportExportXLSX POST /api/report/export/xlsx —— 导出报告为 Excel -func ReportExportXLSX(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var req struct { - Report reportContent `json:"report"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { - web.Fail(c, web.NewBadRequest("report 不能为空")) - return - } - data, err := ExportReportXLSX(req.Report) - if err != nil { - web.Fail(c, web.NewBadRequest("Excel 生成失败: "+err.Error())) - return - } - c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".xlsx\"") - c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data) -} - -// ReportExportDOCX POST /api/report/export/docx —— 导出报告为 Word -func ReportExportDOCX(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var req struct { - Report reportContent `json:"report"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { - web.Fail(c, web.NewBadRequest("report 不能为空")) - return - } - data, err := ExportReportDOCX(req.Report) - if err != nil { - web.Fail(c, web.NewBadRequest("Word 生成失败: "+err.Error())) - return - } - c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document") - c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".docx\"") - c.Data(200, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data) -} - -// ReportExportPPTX POST /api/report/export/pptx —— 导出报告为 PPTX -func ReportExportPPTX(c *gin.Context) { - user := middleware.CurrentUser(c) - if user == nil { - web.Fail(c, web.NewAuthError("未登录")) - return - } - var req struct { - Report reportContent `json:"report"` - } - if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" { - web.Fail(c, web.NewBadRequest("report 不能为空")) - return - } - data, err := ExportReportPPTX(req.Report) - if err != nil { - web.Fail(c, web.NewBadRequest("PPTX 生成失败: "+err.Error())) - return - } - c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.presentationml.presentation") - c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pptx\"") - c.Data(200, "application/vnd.openxmlformats-officedocument.presentationml.presentation", data) -} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/report_generation/report_export.go b/eai_agentplatform/backend-go/internal/skills/packages/report_generation/report_export.go new file mode 100644 index 0000000..bbd7759 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/report_generation/report_export.go @@ -0,0 +1,258 @@ +package reportgeneration + +import ( + "archive/zip" + "bytes" + "fmt" + "os" + "strings" + + "github.com/jung-kurt/gofpdf/v2" +) + +// ExportReportPDF 导出报告为 PDF 字节 +func ExportReportPDF(report ReportContent) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + + fontLoaded := false + for _, candidate := range []string{ + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", + "/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", + } { + if _, err := os.Stat(candidate); err == nil { + pdf.AddUTF8Font("czk", "", candidate) + fontLoaded = true + break + } + } + + pdf.AddPage() + + // 标题 + if fontLoaded { + pdf.SetFont("czk", "B", 20) + } else { + pdf.SetFont("Helvetica", "B", 20) + } + pdf.CellFormat(170, 12, report.Topic, "", 1, "C", false, 0, "") + pdf.Ln(8) + + // 摘要 + if strings.TrimSpace(report.Summary) != "" { + if fontLoaded { + pdf.SetFont("czk", "", 10) + } else { + pdf.SetFont("Helvetica", "", 10) + } + pdf.MultiCell(170, 5, report.Summary, "", "L", false) + pdf.Ln(6) + } + + // 目录 + if fontLoaded { + pdf.SetFont("czk", "B", 14) + } else { + pdf.SetFont("Helvetica", "B", 14) + } + pdf.Cell(0, 8, "目录") + pdf.Ln(4) + for _, ch := range report.Chapters { + if fontLoaded { + pdf.SetFont("czk", "", 10) + } else { + pdf.SetFont("Helvetica", "", 10) + } + pdf.Cell(0, 6, " "+ch.Title) + pdf.Ln(5) + } + pdf.Ln(6) + + // 章节 + for _, ch := range report.Chapters { + pdf.AddPage() + + if fontLoaded { + pdf.SetFont("czk", "B", 14) + } else { + pdf.SetFont("Helvetica", "B", 14) + } + pdf.Cell(0, 8, ch.Title) + pdf.Ln(2) + + if fontLoaded { + pdf.SetFont("czk", "", 10) + } else { + pdf.SetFont("Helvetica", "", 10) + } + pdf.MultiCell(170, 5, ch.Body, "", "L", false) + pdf.Ln(6) + } + + var buf bytes.Buffer + if err := pdf.Output(&buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// zipWriteFile helper: create a file in zip and write content +func zipWriteFile(zw *zip.Writer, name, content string) error { + f, err := zw.Create(name) + if err != nil { + return err + } + _, err = f.Write([]byte(content)) + return err +} + +// escapeXML escapes HTML/XML special characters +func escapeXML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "'", "'") + s = strings.ReplaceAll(s, `"`, """) + return s +} + +// timeStr returns a fixed string for pptx sldId +func timeStr() string { + return "20060102T150405" +} + +func ExportReportDOCX(report ReportContent) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + zipWriteFile(zw, "[Content_Types].xml", ``) + zipWriteFile(zw, "_rels/.rels", ``) + var doc strings.Builder + doc.WriteString(`` + ``) + doc.WriteString(`` + escapeXML(report.Topic) + ``) + doc.WriteString(`摘要`) + doc.WriteString(`` + escapeXML(report.Summary) + ``) + for _, ch := range report.Chapters { + doc.WriteString(`` + escapeXML(ch.Title) + ``) + doc.WriteString(`` + escapeXML(ch.Body) + ``) + } + doc.WriteString(``) + zipWriteFile(zw, "word/document.xml", doc.String()) + zipWriteFile(zw, "word/styles.xml", ``) + zipWriteFile(zw, "word/_rels/document.xml.rels", ``) + zipWriteFile(zw, "docProps/core.xml", ``+escapeXML(report.Topic)+``) + zipWriteFile(zw, "docProps/app.xml", `Microsoft Word`) + zw.Close() + return buf.Bytes(), nil +} + +func ExportReportPPTX(report ReportContent) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + layoutRefs := make([]string, 0) + for i := 0; i < len(report.Chapters)+1; i++ { + layoutRefs = append(layoutRefs, "rId"+fmt.Sprint(i+1)) + } + slideRels := strings.Join(layoutRefs, "") + + zipWriteFile(zw, "[Content_Types].xml", ``) + zipWriteFile(zw, "_rels/.rels", ``) + + preXML := `` + + `` + + `` + + `` + slideRels + `` + zipWriteFile(zw, "ppt/presentation.xml", preXML) + + zipWriteFile(zw, "ppt/_rels/presentation.xml.rels", ``) + + titleSlide := buildSlideTitle(report.Topic, report.Summary) + zipWriteFile(zw, "ppt/slides/slide1.xml", titleSlide) + zipWriteFile(zw, "ppt/slides/_rels/slide1.xml.rels", ``) + + for i, ch := range report.Chapters { + slideNum := i + 2 + slidePath := fmt.Sprintf("ppt/slides/slide%d.xml", slideNum) + relnPath := fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", slideNum) + zipWriteFile(zw, slidePath, buildSlideChapter(ch.Title, ch.Body)) + zipWriteFile(zw, relnPath, ``) + } + + zipWriteFile(zw, "ppt/slideLayouts/slideLayout1.xml", `标题-457200"/>内容`) + + zipWriteFile(zw, "ppt/theme/theme1.xml", ``) + + zipWriteFile(zw, "ppt/media/", "") + zw.Close() + return buf.Bytes(), nil +} + +func buildSlideTitle(title, summary string) string { + return `` + + `` + + `` + + `` + + `` + escapeXML(title) + `` + + `` + + `-457200"/>` + escapeXML(summary) + `` + + `` +} + +func buildSlideChapter(title, body string) string { + return `` + + `` + + `` + + `` + + `` + escapeXML(title) + `` + + `` + + `-457200"/>` + escapeXML(body) + `` + + `` +} + +func ExportReportXLSX(report ReportContent) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + ss := []string{} + addSS := func(s string) int { + s = strings.TrimSpace(s) + if s == "" { + return len(ss) + } + ss = append(ss, s) + return len(ss) - 1 + } + addSS(report.Topic) + addSS(report.Summary) + for _, ch := range report.Chapters { + addSS(ch.Title) + addSS(ch.Body) + } + zipWriteFile(zw, "[Content_Types].xml", ``) + zipWriteFile(zw, "_rels/.rels", ``) + zipWriteFile(zw, "xl/workbook.xml", ``) + zipWriteFile(zw, "xl/_rels/workbook.xml.rels", ``) + var sb strings.Builder + sb.WriteString(``) + for _, s := range ss { + sb.WriteString(`` + escapeXML(s) + ``) + } + sb.WriteString(``) + zipWriteFile(zw, "xl/sharedStrings.xml", sb.String()) + var sb2 strings.Builder + sb2.WriteString(``) + row := 1 + sb2.WriteString(`` + escapeXML(report.Topic) + ``) + row = 2 + sb2.WriteString(`摘要` + escapeXML(report.Summary) + ``) + row = 3 + sb2.WriteString(`章节内容`) + for _, ch := range report.Chapters { + row++ + sb2.WriteString(`` + escapeXML(ch.Title) + `` + escapeXML(ch.Body) + ``) + } + sb2.WriteString(``) + zipWriteFile(zw, "xl/worksheets/sheet1.xml", sb2.String()) + zw.Close() + return buf.Bytes(), nil +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/smart_assistant/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/smart_assistant/manifest.go new file mode 100644 index 0000000..e3c1075 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/smart_assistant/manifest.go @@ -0,0 +1,11 @@ +package smartassistant + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "smart-assistant", + Label: "通用助手", + OwnedDefinitionKeys: []string{"skill.smart-assistant.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.smart-assistant"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/survey_summary/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/survey_summary/manifest.go new file mode 100644 index 0000000..af7e37c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/survey_summary/manifest.go @@ -0,0 +1,11 @@ +package surveysummary + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "survey-summary", + Label: "表单问卷汇总", + OwnedDefinitionKeys: []string{"skill.survey-summary.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.survey-summary"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/survey_summary/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/survey_summary/runtime.go new file mode 100644 index 0000000..856629f --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/survey_summary/runtime.go @@ -0,0 +1,14 @@ +package surveysummary + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "survey-summary", + Label: "表单问卷汇总", + PlanTitle: "问卷汇总流程", + PlanSteps: []string{"归并表单结果", "提炼重点发现", "整理问题与建议", "输出汇总版本"}, + ResultTitle: "问卷汇总稿", + Summary: "已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。", + Highlights: []string{"适合反馈表、报名表、调查问卷", "自动压缩重点发现和改进建议", "默认沉淀汇总报告和行动建议"}, + ActionType: "report", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/table_cleanup/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/table_cleanup/manifest.go new file mode 100644 index 0000000..cc7126c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/table_cleanup/manifest.go @@ -0,0 +1,11 @@ +package tablecleanup + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "table-cleanup", + Label: "表格整理", + OwnedDefinitionKeys: []string{"skill.table-cleanup.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.table-cleanup"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/table_cleanup/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/table_cleanup/runtime.go new file mode 100644 index 0000000..3c98830 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/table_cleanup/runtime.go @@ -0,0 +1,14 @@ +package tablecleanup + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "table-cleanup", + Label: "表格整理", + PlanTitle: "表格整理流程", + PlanSteps: []string{"识别字段结构", "清洗原始数据", "整理表格内容", "补充字段说明"}, + ResultTitle: "表格整理结果", + Summary: "已把输入内容整理成结构化表格,并补了字段说明和清洗备注。", + Highlights: []string{"适合名单、台账、记录整理", "默认沉淀表格正文和字段说明", "便于继续导入 Excel 或系统"}, + ActionType: "table", +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/text_toolkit/manifest.go b/eai_agentplatform/backend-go/internal/skills/packages/text_toolkit/manifest.go new file mode 100644 index 0000000..c08d895 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/text_toolkit/manifest.go @@ -0,0 +1,11 @@ +package texttoolkit + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +var Manifest = skillcontracts.Manifest{ + Key: "text-toolkit", + Label: "文本处理", + OwnedDefinitionKeys: []string{"skill.text-toolkit.definition"}, + OwnedCatalogEntries: []string{"catalog.skill.text-toolkit"}, + ReferencePolicy: skillcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/skills/packages/text_toolkit/runtime.go b/eai_agentplatform/backend-go/internal/skills/packages/text_toolkit/runtime.go new file mode 100644 index 0000000..ec2223e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/packages/text_toolkit/runtime.go @@ -0,0 +1,14 @@ +package texttoolkit + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +var OfficeRuntime = officecontracts.Runtime{ + Key: "text-toolkit", + Label: "文本处理", + PlanTitle: "文本处理流程", + PlanSteps: []string{"识别清洗目标", "去除噪音符号", "统一格式与标点", "输出可复用文本"}, + ResultTitle: "清洗后文本", + Summary: "已完成文本清洗、格式整理和统计,可直接复制或下载结果。", + Highlights: []string{"去 Markdown 噪音", "输出文本统计", "适合高频复制到 Word 和 IM"}, + ActionType: "text", +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_communication.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_communication.go new file mode 100644 index 0000000..459cd27 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_communication.go @@ -0,0 +1,271 @@ +package office + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +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 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 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, + }, + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_structure.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_structure.go new file mode 100644 index 0000000..a51164f --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_structure.go @@ -0,0 +1,312 @@ +package office + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +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 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 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, + }, + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_writing.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_writing.go new file mode 100644 index 0000000..a78d75d --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/builders_writing.go @@ -0,0 +1,488 @@ +package office + +import ( + "fmt" + "strings" + + "github.com/gin-gonic/gin" +) + +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 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 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, + }, + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/builtin.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/builtin.go new file mode 100644 index 0000000..a3c66d8 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/builtin.go @@ -0,0 +1,48 @@ +package office + +import ( + contractbrief "eai_agentplatform/backend/internal/skills/packages/contract_brief" + emaildrafting "eai_agentplatform/backend/internal/skills/packages/email_drafting" + interviewsummary "eai_agentplatform/backend/internal/skills/packages/interview_summary" + longformwriting "eai_agentplatform/backend/internal/skills/packages/longform_writing" + meetingminutes "eai_agentplatform/backend/internal/skills/packages/meeting_minutes" + mindmap "eai_agentplatform/backend/internal/skills/packages/mind_map" + ocrunderstanding "eai_agentplatform/backend/internal/skills/packages/ocr_understanding" + policyrewrite "eai_agentplatform/backend/internal/skills/packages/policy_rewrite" + pptgeneration "eai_agentplatform/backend/internal/skills/packages/ppt_generation" + progressreport "eai_agentplatform/backend/internal/skills/packages/progress_report" + projectplanning "eai_agentplatform/backend/internal/skills/packages/project_planning" + proposalsummary "eai_agentplatform/backend/internal/skills/packages/proposal_summary" + surveysummary "eai_agentplatform/backend/internal/skills/packages/survey_summary" + tablecleanup "eai_agentplatform/backend/internal/skills/packages/table_cleanup" + texttoolkit "eai_agentplatform/backend/internal/skills/packages/text_toolkit" + officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + "github.com/gin-gonic/gin" +) + +func withBuild(item officecontracts.Runtime, builder func(string) []gin.H) officecontracts.Runtime { + item.Build = builder + return item +} + +var builtinRegistry = NewRegistry( + withBuild(pptgeneration.OfficeRuntime, BuildPPTArtifacts), + withBuild(mindmap.OfficeRuntime, BuildMindMapArtifacts), + withBuild(longformwriting.OfficeRuntime, BuildLongformArtifacts), + withBuild(texttoolkit.OfficeRuntime, BuildTextToolkitArtifacts), + ocrunderstanding.OfficeRuntime, + withBuild(meetingminutes.OfficeRuntime, BuildMeetingMinutesArtifacts), + withBuild(projectplanning.OfficeRuntime, BuildProjectPlanningArtifacts), + withBuild(emaildrafting.OfficeRuntime, BuildEmailDraftingArtifacts), + withBuild(tablecleanup.OfficeRuntime, BuildTableCleanupArtifacts), + withBuild(proposalsummary.OfficeRuntime, BuildProposalSummaryArtifacts), + withBuild(progressreport.OfficeRuntime, BuildProgressReportArtifacts), + withBuild(contractbrief.OfficeRuntime, BuildContractBriefArtifacts), + withBuild(interviewsummary.OfficeRuntime, BuildInterviewSummaryArtifacts), + withBuild(policyrewrite.OfficeRuntime, BuildPolicyRewriteArtifacts), + withBuild(surveysummary.OfficeRuntime, BuildSurveySummaryArtifacts), +) + +func BuiltinRegistry() *Registry { + return builtinRegistry +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/contracts/runtime.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/contracts/runtime.go new file mode 100644 index 0000000..c265d1e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/contracts/runtime.go @@ -0,0 +1,15 @@ +package contracts + +import "github.com/gin-gonic/gin" + +type Runtime struct { + Key string + Label string + PlanTitle string + PlanSteps []string + ResultTitle string + Summary string + Highlights []string + ActionType string + Build func(inputText string) []gin.H +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/helpers_content.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/helpers_content.go new file mode 100644 index 0000000..f7581a9 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/helpers_content.go @@ -0,0 +1,177 @@ +package office + +import ( + "fmt" + "regexp" + "strings" + + "github.com/gin-gonic/gin" +) + +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 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 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 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) +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/helpers_structure.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/helpers_structure.go new file mode 100644 index 0000000..c13f439 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/helpers_structure.go @@ -0,0 +1,379 @@ +package office + +import ( + "fmt" + "regexp" + "strings" + + "github.com/gin-gonic/gin" +) + +var officeLineTrimPattern = regexp.MustCompile(`^[#>\-*\d.)、\s]+`) + +func firstNonEmpty(values ...string) string { + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + return value + } + } + return "" +} + +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 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 + } + out := make([]string, 0, len(fallback)) + for _, item := range fallback { + item = strings.TrimSpace(item) + if item != "" { + out = append(out, item) + } + } + return out +} + +func parseOfficeFields(text string, aliasMap map[string][]string) (map[string]string, string) { + result := make(map[string]string, len(aliasMap)) + lines := splitMeaningfulLines(text) + bodyLines := make([]string, 0, len(lines)) + for _, line := range lines { + matched := false + for canonical, aliases := range aliasMap { + for _, alias := range aliases { + pattern := regexp.MustCompile(`(?i)^` + regexp.QuoteMeta(strings.TrimSpace(alias)) + `\s*[::]\s*(.+)$`) + if groups := pattern.FindStringSubmatch(line); len(groups) == 2 { + result[canonical] = strings.TrimSpace(groups[1]) + matched = true + break + } + } + if matched { + break + } + } + if !matched { + bodyLines = append(bodyLines, line) + } + } + return result, strings.TrimSpace(strings.Join(bodyLines, "\n")) +} + +func parsePositiveInt(value string, fallback int) int { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + var parsed int + if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func firstNonEmptyStringSlice(items []string, fallback []string) []string { + if len(items) > 0 { + return items + } + return fallback +} + +func parseTableColumns(rawColumns string, points []string) []string { + columns := splitSimpleList(rawColumns) + if len(columns) >= 2 { + return columns[:minInt(len(columns), 6)] + } + inferred := make([]string, 0, 4) + for _, item := range points { + if groups := regexp.MustCompile(`^([^::]{1,12})[::]`).FindStringSubmatch(item); len(groups) == 2 { + inferred = append(inferred, strings.TrimSpace(groups[1])) + } + if len(inferred) >= 4 { + break + } + } + if len(inferred) >= 2 { + return inferred + } + 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 repeatString(value string, count int) []string { + items := make([]string, 0, count) + for index := 0; index < count; index++ { + items = append(items, value) + } + return items +} + +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 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 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/skills/runtime/office/registry.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/registry.go new file mode 100644 index 0000000..b64cdde --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/registry.go @@ -0,0 +1,26 @@ +package office + +import officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" + +type Registry struct { + items map[string]officecontracts.Runtime +} + +func NewRegistry(items ...officecontracts.Runtime) *Registry { + registry := &Registry{items: make(map[string]officecontracts.Runtime, len(items))} + for _, item := range items { + if item.Key == "" { + continue + } + registry.items[item.Key] = item + } + return registry +} + +func (r *Registry) ByKey(key string) (officecontracts.Runtime, bool) { + if r == nil { + return officecontracts.Runtime{}, false + } + item, ok := r.items[key] + return item, ok +} diff --git a/eai_agentplatform/backend-go/internal/skills/runtime/office/result.go b/eai_agentplatform/backend-go/internal/skills/runtime/office/result.go new file mode 100644 index 0000000..6b0ff78 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/runtime/office/result.go @@ -0,0 +1,60 @@ +package office + +import ( + "fmt" + + "github.com/gin-gonic/gin" + + officecontracts "eai_agentplatform/backend/internal/skills/runtime/office/contracts" +) + +// BuildOfficeSkillResultMap 把一个技能运行时的定义组装成对前端结果对象的统一形态。 +// 执行体本身属于各技能包(Build),这里只做结果装配,属于 office 运行时层的自有所有权。 +func BuildOfficeSkillResultMap(definition officecontracts.Runtime, inputText string) gin.H { + artifacts := buildOfficeResultArtifacts(definition, 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(definition officecontracts.Runtime, inputText string) []gin.H { + if definition.Build != nil { + return definition.Build(inputText) + } + return []gin.H{} +} + +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 +} diff --git a/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go b/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go new file mode 100644 index 0000000..72ae216 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go @@ -0,0 +1,168 @@ +package specialistapi + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +// CreateSpecialist POST /api/specialists (admin) +func CreateSpecialist(c *gin.Context) { + var req specialistReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if appErr := validateSpecialistReq(&req); appErr != nil { + web.Fail(c, appErr) + return + } + + var count int64 + store.DB.Model(&specialistmodel.Specialist{}).Where("key = ?", req.Key).Count(&count) + if count > 0 { + web.Fail(c, web.NewConflictError("专员 key 已存在")) + return + } + + item := specialistmodel.Specialist{ + Key: req.Key, + Label: req.Label, + DisplayCode: req.DisplayCode, + EAILogicCode: req.EAILogicCode, + Tier: req.Tier, + SpecialistMode: req.SpecialistMode, + ObjectEntryRoute: req.ObjectEntryRoute, + Summary: req.Summary, + InteractionCardJSON: req.InteractionCardJSON, + WorkStatus: req.WorkStatus, + RiskLabel: req.RiskLabel, + Color: req.Color, + Stage: req.Stage, + Progress: req.Progress, + MarketTag: req.MarketTag, + Version: req.Version, + ConnectorScope: req.ConnectorScope, + PermissionScope: req.PermissionScope, + ResourceBindings: req.ResourceBindings, + InfoSources: req.InfoSources, + BaseSkills: req.BaseSkills, + AIAssistance: req.AIAssistance, + GeneratedSkills: req.GeneratedSkills, + RuleFileMarkdown: req.RuleFileMarkdown, + AllowedSkills: req.AllowedSkills, + InputsRecordsJSON: req.InputsRecordsJSON, + PermissionRecordsJSON: req.PermissionRecordsJSON, + ActionRecordsJSON: req.ActionRecordsJSON, + ResultRecordsJSON: req.ResultRecordsJSON, + State: req.State, + SortOrder: req.SortOrder, + } + specialistruntime.EnsureStructuredRecords(&item) + if err := store.DB.Create(&item).Error; err != nil { + web.Fail(c, web.NewBadRequest("创建专员失败")) + return + } + web.OK(c, item) +} + +// UpdateSpecialist PUT /api/specialists/:id (admin) +func UpdateSpecialist(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var item specialistmodel.Specialist + if err := store.DB.First(&item, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + + var req specialistReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if appErr := validateSpecialistReq(&req); appErr != nil { + web.Fail(c, appErr) + return + } + + var count int64 + store.DB.Model(&specialistmodel.Specialist{}).Where("key = ? AND id <> ?", req.Key, id).Count(&count) + if count > 0 { + web.Fail(c, web.NewConflictError("专员 key 已存在")) + return + } + + item.Key = req.Key + item.Label = req.Label + item.DisplayCode = req.DisplayCode + item.EAILogicCode = req.EAILogicCode + item.Tier = req.Tier + item.SpecialistMode = req.SpecialistMode + item.ObjectEntryRoute = req.ObjectEntryRoute + item.Summary = req.Summary + item.InteractionCardJSON = req.InteractionCardJSON + item.WorkStatus = req.WorkStatus + item.RiskLabel = req.RiskLabel + item.Color = req.Color + item.Stage = req.Stage + item.Progress = req.Progress + item.MarketTag = req.MarketTag + item.Version = req.Version + item.ConnectorScope = req.ConnectorScope + item.PermissionScope = req.PermissionScope + item.ResourceBindings = req.ResourceBindings + item.InfoSources = req.InfoSources + item.BaseSkills = req.BaseSkills + item.AIAssistance = req.AIAssistance + item.GeneratedSkills = req.GeneratedSkills + item.RuleFileMarkdown = req.RuleFileMarkdown + item.AllowedSkills = req.AllowedSkills + item.InputsRecordsJSON = req.InputsRecordsJSON + item.PermissionRecordsJSON = req.PermissionRecordsJSON + item.ActionRecordsJSON = req.ActionRecordsJSON + item.ResultRecordsJSON = req.ResultRecordsJSON + item.State = req.State + item.SortOrder = req.SortOrder + specialistruntime.EnsureStructuredRecords(&item) + + if err := store.DB.Save(&item).Error; err != nil { + web.Fail(c, web.NewBadRequest("更新专员失败")) + return + } + web.OK(c, item) +} + +// DeleteSpecialist DELETE /api/specialists/:id (admin) +func DeleteSpecialist(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var item specialistmodel.Specialist + 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 parseID(c *gin.Context, name string) (uint, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || id == 0 { + web.Fail(c, web.NewBadRequest("无效的 "+name)) + return 0, false + } + return uint(id), true +} diff --git a/eai_agentplatform/backend-go/internal/specialists/api/query_handlers.go b/eai_agentplatform/backend-go/internal/specialists/api/query_handlers.go new file mode 100644 index 0000000..4e3e7dd --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/api/query_handlers.go @@ -0,0 +1,109 @@ +package specialistapi + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "eai_agentplatform/backend/internal/middleware" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +// ListSpecialists GET /api/specialists?tier=&state=&market_tag= +func ListSpecialists(c *gin.Context) { + q := store.DB.Model(&specialistmodel.Specialist{}) + if tier := c.Query("tier"); tier != "" { + q = q.Where("tier = ?", tier) + } + if marketTag := c.Query("market_tag"); marketTag != "" { + q = q.Where("market_tag = ?", marketTag) + } + user := middleware.CurrentUser(c) + isAdmin := user != nil && user.Role == "admin" + switch state := c.Query("state"); state { + case "": + q = q.Where("state = ?", "active") + case "all": + if !isAdmin { + q = q.Where("state = ?", "active") + } + case "system": + q = q.Where("state = ?", "system") + default: + q = q.Where("state = ?", state) + } + if c.Query("state") != "system" { + q = q.Where("state <> ?", "system") + } + + var items []specialistmodel.Specialist + if err := q.Order("sort_order ASC, id ASC").Find(&items).Error; err != nil { + web.Fail(c, web.NewBadRequest("查询专员目录失败")) + return + } + for i := range items { + specialistruntime.EnsureStructuredRecords(&items[i]) + } + web.OK(c, items) +} + +// GetSpecialistByKey GET /api/specialists/by-key/:key +func GetSpecialistByKey(c *gin.Context) { + key := strings.TrimSpace(c.Param("key")) + if key == "" { + web.Fail(c, web.NewBadRequest("专员 key 不能为空")) + return + } + + q := store.DB.Model(&specialistmodel.Specialist{}).Where("key = ?", key) + user := middleware.CurrentUser(c) + isAdmin := user != nil && user.Role == "admin" + if !isAdmin { + q = q.Where("state = ?", "active") + } + + var item specialistmodel.Specialist + if err := q.First(&item).Error; err != nil { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + specialistruntime.EnsureStructuredRecords(&item) + web.OK(c, item) +} + +// SpecialistSummary GET /api/specialists/summary +func SpecialistSummary(c *gin.Context) { + type summary struct { + Total int64 `json:"total"` + Generic int64 `json:"generic"` + Industry int64 `json:"industry"` + DW int64 `json:"dw"` + ADW int64 `json:"adw"` + Upgradeable int64 `json:"upgradeable"` + Trial int64 `json:"trial"` + } + + var resp summary + count := func(column string, value string) int64 { + var total int64 + q := store.DB.Model(&specialistmodel.Specialist{}).Where("state = ?", "active") + if column != "" { + q = q.Where(column+" = ?", value) + } + q.Count(&total) + return total + } + + resp.Total = count("", "") + resp.Generic = count("tier", "generic") + resp.Industry = count("tier", "industry") + resp.DW = count("specialist_mode", "dw") + resp.ADW = count("specialist_mode", "adw") + resp.Upgradeable = count("market_tag", "可升级") + resp.Trial = count("market_tag", "试用") + + web.OK(c, resp) +} diff --git a/eai_agentplatform/backend-go/internal/specialists/api/request.go b/eai_agentplatform/backend-go/internal/specialists/api/request.go new file mode 100644 index 0000000..0a6cb57 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/api/request.go @@ -0,0 +1,35 @@ +package specialistapi + +type specialistReq struct { + Key string `json:"key"` + Label string `json:"label"` + DisplayCode string `json:"display_code"` + EAILogicCode string `json:"eailogic_code"` + Tier string `json:"tier"` + SpecialistMode string `json:"specialist_mode"` + ObjectEntryRoute string `json:"object_entry_route"` + Summary string `json:"summary"` + InteractionCardJSON string `json:"interaction_card_json"` + WorkStatus string `json:"work_status"` + RiskLabel string `json:"risk_label"` + Color string `json:"color"` + Stage string `json:"stage"` + Progress int `json:"progress"` + MarketTag string `json:"market_tag"` + Version string `json:"version"` + ConnectorScope string `json:"connector_scope"` + PermissionScope string `json:"permission_scope"` + ResourceBindings string `json:"resource_bindings"` + InfoSources string `json:"info_sources"` + BaseSkills string `json:"base_skills"` + AIAssistance string `json:"ai_assistance"` + GeneratedSkills string `json:"generated_skills"` + RuleFileMarkdown string `json:"rule_file_markdown"` + AllowedSkills string `json:"allowed_skills"` + InputsRecordsJSON string `json:"inputs_records_json"` + PermissionRecordsJSON string `json:"permission_records_json"` + ActionRecordsJSON string `json:"action_records_json"` + ResultRecordsJSON string `json:"result_records_json"` + State string `json:"state"` + SortOrder int `json:"sort_order"` +} diff --git a/eai_agentplatform/backend-go/internal/specialists/api/validation.go b/eai_agentplatform/backend-go/internal/specialists/api/validation.go new file mode 100644 index 0000000..0d61c1b --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/api/validation.go @@ -0,0 +1,109 @@ +package specialistapi + +import ( + "strings" + + "eai_agentplatform/backend/internal/jsonutil" + skillcore "eai_agentplatform/backend/internal/skills/core" + "eai_agentplatform/backend/internal/web" +) + +func normalizeSpecialistReq(req *specialistReq) { + req.Key = strings.TrimSpace(req.Key) + req.Label = strings.TrimSpace(req.Label) + req.DisplayCode = strings.TrimSpace(req.DisplayCode) + req.EAILogicCode = strings.TrimSpace(req.EAILogicCode) + req.Tier = strings.TrimSpace(req.Tier) + req.SpecialistMode = strings.TrimSpace(req.SpecialistMode) + req.ObjectEntryRoute = strings.TrimSpace(req.ObjectEntryRoute) + req.Summary = strings.TrimSpace(req.Summary) + req.InteractionCardJSON = strings.TrimSpace(req.InteractionCardJSON) + req.WorkStatus = strings.TrimSpace(req.WorkStatus) + req.RiskLabel = strings.TrimSpace(req.RiskLabel) + req.Color = strings.TrimSpace(req.Color) + req.Stage = strings.TrimSpace(req.Stage) + req.MarketTag = strings.TrimSpace(req.MarketTag) + req.Version = strings.TrimSpace(req.Version) + req.ConnectorScope = strings.TrimSpace(req.ConnectorScope) + req.PermissionScope = strings.TrimSpace(req.PermissionScope) + req.ResourceBindings = strings.TrimSpace(req.ResourceBindings) + req.InfoSources = strings.TrimSpace(req.InfoSources) + req.BaseSkills = strings.TrimSpace(req.BaseSkills) + req.AIAssistance = strings.TrimSpace(req.AIAssistance) + req.GeneratedSkills = strings.TrimSpace(req.GeneratedSkills) + req.RuleFileMarkdown = strings.TrimSpace(req.RuleFileMarkdown) + req.AllowedSkills = strings.TrimSpace(req.AllowedSkills) + req.InputsRecordsJSON = strings.TrimSpace(req.InputsRecordsJSON) + req.PermissionRecordsJSON = strings.TrimSpace(req.PermissionRecordsJSON) + req.ActionRecordsJSON = strings.TrimSpace(req.ActionRecordsJSON) + req.ResultRecordsJSON = strings.TrimSpace(req.ResultRecordsJSON) + req.State = strings.TrimSpace(req.State) +} + +func normalizeSpecialistMode(req *specialistReq) { + if req.SpecialistMode == "" { + req.SpecialistMode = "dw" + } +} + +func validateSpecialistReq(req *specialistReq) *web.AppError { + normalizeSpecialistReq(req) + normalizeSpecialistMode(req) + if req.Key == "" || req.Label == "" || req.Tier == "" || req.ObjectEntryRoute == "" { + return web.NewBadRequest("key、label、tier、object_entry_route 为必填") + } + if req.Tier != "generic" && req.Tier != "industry" { + return web.NewBadRequest("tier 只能是 generic 或 industry") + } + if req.SpecialistMode != "dw" && req.SpecialistMode != "adw" { + return web.NewBadRequest("specialist_mode 只能是 dw 或 adw") + } + if req.State == "" { + req.State = "active" + } + if req.State != "active" && req.State != "inactive" { + return web.NewBadRequest("state 只能是 active 或 inactive") + } + if req.MarketTag == "" { + req.MarketTag = "已安装" + } + if req.Progress < 0 || req.Progress > 100 { + return web.NewBadRequest("progress 必须在 0 到 100 之间") + } + if !jsonutil.ValidateStructuredRecordsJSON(req.InputsRecordsJSON) { + return web.NewBadRequest("inputs_records_json 必须是 JSON 数组") + } + if !jsonutil.ValidateObjectJSON(req.InteractionCardJSON) { + return web.NewBadRequest("interaction_card_json 必须是 JSON 对象") + } + if !jsonutil.ValidateStructuredRecordsJSON(req.PermissionRecordsJSON) { + return web.NewBadRequest("permission_records_json 必须是 JSON 数组") + } + if !jsonutil.ValidateStructuredRecordsJSON(req.ActionRecordsJSON) { + return web.NewBadRequest("action_records_json 必须是 JSON 数组") + } + if !jsonutil.ValidateStructuredRecordsJSON(req.ResultRecordsJSON) { + return web.NewBadRequest("result_records_json 必须是 JSON 数组") + } + if normalized, appErr := normalizeAllowedSkills(req.AllowedSkills); appErr != nil { + return appErr + } else { + req.AllowedSkills = normalized + } + return nil +} + +func normalizeAllowedSkills(raw string) (string, *web.AppError) { + keys := skillcore.ParseAllowedSkills(raw) + if strings.TrimSpace(raw) != "" && keys == nil { + return "", web.NewBadRequest(`allowed_skills 必须是 JSON 字符串数组,例如 ["contract-review","batch-extract"]`) + } + if err := skillcore.ValidateAllowedSkills(keys); err != nil { + return "", web.NewBadRequest(err.Error()) + } + normalized, err := skillcore.MarshalAllowedSkills(keys) + if err != nil { + return "", web.NewBadRequest("allowed_skills 序列化失败") + } + return normalized, nil +} diff --git a/eai_agentplatform/backend-go/internal/specialists/contracts/manifest.go b/eai_agentplatform/backend-go/internal/specialists/contracts/manifest.go new file mode 100644 index 0000000..3f40678 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/contracts/manifest.go @@ -0,0 +1,34 @@ +package specialistcontracts + +type SharedReferencePolicy string + +const ( + SharedReferenceReadonlyHistory SharedReferencePolicy = "readonly_history" + SharedReferenceTombstone SharedReferencePolicy = "convert_to_tombstone" + SharedReferenceDetach SharedReferencePolicy = "detach_reference" + SharedReferenceBlockUninstall SharedReferencePolicy = "block_uninstall" +) + +type UninstallMeta struct { + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} + +type Manifest struct { + Key string + Label string + RuleFileMarkdown string + AllowedSkills []string + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} + +func (m Manifest) UninstallMeta() UninstallMeta { + return UninstallMeta{ + OwnedDefinitionKeys: append([]string(nil), m.OwnedDefinitionKeys...), + OwnedCatalogEntries: append([]string(nil), m.OwnedCatalogEntries...), + ReferencePolicy: m.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/apply.go b/eai_agentplatform/backend-go/internal/specialists/core/apply.go new file mode 100644 index 0000000..c9ded28 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/apply.go @@ -0,0 +1,62 @@ +package specialistcore + +import ( + "fmt" + "log" + + skillcore "eai_agentplatform/backend/internal/skills/core" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + "gorm.io/gorm" +) + +func RuleFileDrafts() map[string]string { + items := make(map[string]string) + for _, manifest := range builtinRegistry.Manifests() { + if manifest.RuleFileMarkdown != "" { + items[manifest.Key] = manifest.RuleFileMarkdown + } + } + return items +} + +func SkillBindings() map[string][]string { + items := make(map[string][]string) + for _, manifest := range builtinRegistry.Manifests() { + if len(manifest.AllowedSkills) > 0 { + items[manifest.Key] = append([]string(nil), manifest.AllowedSkills...) + } + } + return items +} + +func ApplyRuleFiles(items []specialistmodel.Specialist) error { + for i := range items { + manifest, ok := builtinRegistry.ByKey(items[i].Key) + if !ok { + continue + } + if manifest.RuleFileMarkdown != "" { + items[i].RuleFileMarkdown = manifest.RuleFileMarkdown + } + if len(manifest.AllowedSkills) == 0 { + continue + } + if err := skillcore.ValidateAllowedSkills(manifest.AllowedSkills); err != nil { + return fmt.Errorf("专员 %s 的技能绑定非法: %w", items[i].Key, err) + } + raw, err := skillcore.MarshalAllowedSkills(manifest.AllowedSkills) + if err != nil { + return fmt.Errorf("专员 %s 的技能绑定序列化失败: %w", items[i].Key, err) + } + items[i].AllowedSkills = raw + } + return nil +} + +func LogRuleFileCoverage(db *gorm.DB) { + var total, withRule, withSkills int64 + db.Model(&specialistmodel.Specialist{}).Where("state <> ?", "system").Count(&total) + db.Model(&specialistmodel.Specialist{}).Where("state <> ? AND rule_file_markdown <> ''", "system").Count(&withRule) + db.Model(&specialistmodel.Specialist{}).Where("state <> ? AND allowed_skills <> ''", "system").Count(&withSkills) + log.Printf("[OK] 专员岗位说明书 %d/%d,技能绑定 %d/%d", withRule, total, withSkills, total) +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/builtin.go b/eai_agentplatform/backend-go/internal/specialists/core/builtin.go new file mode 100644 index 0000000..abde618 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/builtin.go @@ -0,0 +1,35 @@ +package specialistcore + +import ( + contractreview "eai_agentplatform/backend/internal/specialists/packages/contract_review" + generalassistant "eai_agentplatform/backend/internal/specialists/packages/general_assistant" + hremailsorter "eai_agentplatform/backend/internal/specialists/packages/hr_email_sorter" + knowledgeoperations "eai_agentplatform/backend/internal/specialists/packages/knowledge_operations" + logisticsfulfillment "eai_agentplatform/backend/internal/specialists/packages/logistics_fulfillment" + presentationbriefing "eai_agentplatform/backend/internal/specialists/packages/presentation_briefing" + processcoordination "eai_agentplatform/backend/internal/specialists/packages/process_coordination" + reportgeneration "eai_agentplatform/backend/internal/specialists/packages/report_generation" + resumeprocessor "eai_agentplatform/backend/internal/specialists/packages/resume_processor" + solutionproposal "eai_agentplatform/backend/internal/specialists/packages/solution_proposal" + trainingdelivery "eai_agentplatform/backend/internal/specialists/packages/training_delivery" + wechatofficialaccount "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account" +) + +var builtinRegistry = NewRegistry( + generalassistant.Manifest, + trainingdelivery.Manifest, + knowledgeoperations.Manifest, + processcoordination.Manifest, + reportgeneration.Manifest, + wechatofficialaccount.Manifest, + contractreview.Manifest, + solutionproposal.Manifest, + presentationbriefing.Manifest, + logisticsfulfillment.Manifest, + hremailsorter.Manifest, + resumeprocessor.Manifest, +) + +func BuiltinRegistry() *Registry { + return builtinRegistry +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/registry.go b/eai_agentplatform/backend-go/internal/specialists/core/registry.go new file mode 100644 index 0000000..2ef74dd --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/registry.go @@ -0,0 +1,41 @@ +package specialistcore + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +type Registry struct { + manifests []specialistcontracts.Manifest + byKey map[string]specialistcontracts.Manifest +} + +func NewRegistry(manifests ...specialistcontracts.Manifest) *Registry { + items := make([]specialistcontracts.Manifest, 0, len(manifests)) + byKey := make(map[string]specialistcontracts.Manifest, len(manifests)) + for _, manifest := range manifests { + if manifest.Key == "" { + continue + } + items = append(items, manifest) + byKey[manifest.Key] = manifest + } + return &Registry{ + manifests: items, + byKey: byKey, + } +} + +func (r *Registry) Manifests() []specialistcontracts.Manifest { + if r == nil { + return nil + } + items := make([]specialistcontracts.Manifest, len(r.manifests)) + copy(items, r.manifests) + return items +} + +func (r *Registry) ByKey(key string) (specialistcontracts.Manifest, bool) { + if r == nil { + return specialistcontracts.Manifest{}, false + } + item, ok := r.byKey[key] + return item, ok +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go b/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go new file mode 100644 index 0000000..882e969 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go @@ -0,0 +1,18 @@ +package specialistcore + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +type UninstallPreview struct { + DefinitionKeys []string + CatalogEntries []string + Policy specialistcontracts.SharedReferencePolicy +} + +func BuildUninstallPreview(manifest specialistcontracts.Manifest) UninstallPreview { + meta := manifest.UninstallMeta() + return UninstallPreview{ + DefinitionKeys: meta.OwnedDefinitionKeys, + CatalogEntries: meta.OwnedCatalogEntries, + Policy: meta.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/specialists/model/specialist.go b/eai_agentplatform/backend-go/internal/specialists/model/specialist.go new file mode 100644 index 0000000..ed38d64 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/model/specialist.go @@ -0,0 +1,43 @@ +package specialistmodel + +import "time" + +// Specialist 数字员工专员目录 +type Specialist 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"` + Tier string `gorm:"size:16;not null;index" json:"tier"` // generic / industry + SpecialistMode string `gorm:"size:16;not null;default:dw;index" json:"specialist_mode"` + ObjectEntryRoute string `gorm:"column:object_entry_route;size:128;not null;default:''" json:"object_entry_route"` + Summary string `gorm:"type:text" json:"summary"` + InteractionCardJSON string `gorm:"column:interaction_card_json;type:text" json:"interaction_card_json"` + WorkStatus string `gorm:"size:64;default:''" json:"work_status"` + RiskLabel string `gorm:"size:64;default:''" json:"risk_label"` + Color string `gorm:"size:16;default:''" json:"color"` + Stage string `gorm:"size:64;default:''" json:"stage"` + Progress int `gorm:"not null;default:0" json:"progress"` + MarketTag string `gorm:"size:32;not null;default:installed;index" json:"market_tag"` // 已安装 / 可升级 / 试用 + Version string `gorm:"size:32;default:''" json:"version"` + ConnectorScope string `gorm:"type:text" json:"connector_scope"` + PermissionScope string `gorm:"type:text" json:"permission_scope"` + ResourceBindings string `gorm:"type:text" json:"resource_bindings"` + InfoSources string `gorm:"type:text" json:"info_sources"` + BaseSkills string `gorm:"type:text" json:"base_skills"` + AIAssistance string `gorm:"type:text" json:"ai_assistance"` + GeneratedSkills string `gorm:"type:text" json:"generated_skills"` + RuleFileMarkdown string `gorm:"type:text" json:"rule_file_markdown"` + AllowedSkills string `gorm:"type:text" json:"allowed_skills"` + InputsRecordsJSON string `gorm:"column:inputs_records_json;type:text" json:"inputs_records_json"` + PermissionRecordsJSON string `gorm:"type:text" json:"permission_records_json"` + ActionRecordsJSON string `gorm:"type:text" json:"action_records_json"` + ResultRecordsJSON string `gorm:"type:text" json:"result_records_json"` + State string `gorm:"size:16;not null;default:active;index" json:"state"` // active / inactive + SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (Specialist) TableName() string { return "specialist" } diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/contract_review/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/contract_review/manifest.go new file mode 100644 index 0000000..b704cf7 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/contract_review/manifest.go @@ -0,0 +1,31 @@ +package contractreview + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "contract-review", + Label: "合同审查专员", + RuleFileMarkdown: `# 合同审查专员 + +## 你是谁 +你负责合同审查:条款抽取、风险比对、红线生成、例外说明。 +你面对的是要签字的人,你的价值是把风险讲到他能做决定。 + +## 你怎么推进 +1. 先抽条款清单,标出缺失的关键条款(主体 / 标的 / 价款 / 期限 / 违约 / 争议 / 保密 / 不可抗力 / 验收)。 +2. 逐条比对风险,按 critical / high / medium / low 分级,每条给「原文片段 + 问题 + 建议改法」。 +3. 给红线清单:哪几条是必须改的底线,改不了就不建议签。 +4. 给例外说明:哪些风险属于行业惯例、可以接受,讲清理由。 + +## 输出要求 +- 引用合同原文必须逐字照抄,并标明章节。 +- 建议改法要给出可直接替换的措辞,不要只说「建议完善」。 + +## 边界 +- 你是辅助审查,不出具法律意见;重要合同提醒用户找执业律师复核。 +- 不确定的条款标「需人工确认」,不要强行定级。`, + AllowedSkills: []string{"contract-review", "contract-brief", "batch-extract"}, + OwnedDefinitionKeys: []string{"specialist.contract-review.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.contract-review"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/general_assistant/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/general_assistant/manifest.go new file mode 100644 index 0000000..460d595 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/general_assistant/manifest.go @@ -0,0 +1,30 @@ +package generalassistant + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "general-assistant", + Label: "通用助手", + RuleFileMarkdown: `# 通用助手 + +## 你是谁 +你是博昇数字员工平台的通用助手,是用户还没指定专员时的默认对话对象。 +你不假装自己是某个领域的专家,你的价值是把问题理清楚、把任务拆对、把用户引到合适的专员上。 + +## 你怎么推进 +1. 先判断用户要什么:闲聊答疑 / 查平台里的资料 / 一件要落地的事。 +2. 只在平台内确实有对应能力时才推荐专员,并说清「为什么是这个专员」和「它需要你提供什么」。 +3. 闲聊与答疑直接答完就结束,不要硬塞流程、不要为了推进而推进。 + +## 输出要求 +- 简洁直接,默认不展开思考过程。 +- 需要用户补充信息时,一次只问最关键的一两个问题。 + +## 边界 +- 平台里查不到的信息,直接说查不到,不要编。 +- 不代替用户做承诺、审批、对外发送这类动作。`, + AllowedSkills: []string{"smart-assistant"}, + OwnedDefinitionKeys: []string{"specialist.general-assistant.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.general-assistant"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/hr_email_sorter/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/hr_email_sorter/manifest.go new file mode 100644 index 0000000..d7c914c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/hr_email_sorter/manifest.go @@ -0,0 +1,31 @@ +package hremailsorter + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "hr-email-sorter", + Label: "HR 邮件整理专员", + RuleFileMarkdown: `# HR 邮件整理专员 + +## 你是谁 +你负责把 HR 邮箱里的简历邮件理清楚:简历邮件识别、候选人信息抽取、去重归类。 +你面对的是要快速筛出有效候选人的招聘同学。 + +## 你怎么推进 +1. 先分类:简历投递 / 面试安排 / 其他(通知、广告、内部邮件)。 +2. 简历邮件逐封抽字段:姓名、联系方式、应聘岗位、年限、学历、附件有没有。 +3. 去重:同一候选人多次投递的合并,列出重复来源。 +4. 输出归类清单,按岗位分组。 + +## 输出要求 +- 字段缺失写「未提供」,不要用工整的占位内容填充。 +- 附件解析失败的单列出来,标明原因。 + +## 边界 +- 候选人个人信息只出现在整理结果里,不外传、不写入其他文档。 +- 不做录用判断——那是简历处理专员和用人部门的事。`, + AllowedSkills: []string{"email-drafting", "batch-extract", "table-cleanup", "ocr-understanding"}, + OwnedDefinitionKeys: []string{"specialist.hr-email-sorter.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.hr-email-sorter"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/knowledge_operations/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/knowledge_operations/manifest.go new file mode 100644 index 0000000..87bbf4c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/knowledge_operations/manifest.go @@ -0,0 +1,30 @@ +package knowledgeoperations + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "knowledge-operations", + Label: "知识运营专员", + RuleFileMarkdown: `# 知识运营专员 + +## 你是谁 +你负责知识资产的整理:对象映射、证据抽取、规则同步、发布检查。 +你的产出要能被人复核,所以每条结论都必须带回原文出处。 + +## 你怎么推进 +1. 先确认这次处理的是哪份知识资产、目标对象字典是哪一版。 +2. 抽取时逐条给出「字段 / 取值 / 证据片段 / 出处位置」,拿不准的单独列成「待确认」。 +3. 收尾做发布检查:字段有没有缺、同一字段前后取值是否冲突、有没有遗留占位符。 + +## 输出要求 +- 证据片段必须原文照抄,不要改写、不要概括。 +- 冲突项要给出两个取值各自的出处,让管理员自己判。 + +## 边界 +- 不自作主张删改知识资产原文;发现该改的,列成建议交管理员。 +- 映射不确定时宁可标「待确认」,不要猜一个填上。`, + AllowedSkills: []string{"batch-extract", "table-cleanup", "policy-rewrite"}, + OwnedDefinitionKeys: []string{"specialist.knowledge-operations.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.knowledge-operations"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/logistics_fulfillment/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/logistics_fulfillment/manifest.go new file mode 100644 index 0000000..6d3eb5e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/logistics_fulfillment/manifest.go @@ -0,0 +1,31 @@ +package logisticsfulfillment + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "logistics-fulfillment", + Label: "履约跟单专员", + RuleFileMarkdown: `# 履约跟单专员 + +## 你是谁 +你负责订单履约的过程跟踪:节点跟踪、异常识别、催办升级、状态回写。 +你面对的是盯着一批单子的人,他要的是「哪几单要出事了」。 + +## 你怎么推进 +1. 先给总览:在途多少单、正常多少、异常多少。 +2. 异常单逐条给出「单号 / 卡在哪个节点 / 卡了多久 / 原因 / 建议动作」。 +3. 按节点时效判断该催谁,给出可直接发送的催办话术。 +4. 收尾确认状态已回写,没回写的列出来。 + +## 输出要求 +- 时间口径统一(统一时区、标明统计截止时点)。 +- 异常分级:影响交付日期的算高优先级,只影响体感的往下降。 + +## 边界 +- 只读订单与物流数据,不代替用户改单、取消单、承诺新的交期。 +- 拿不到物流节点信息时如实说,不要按经验推测位置。`, + AllowedSkills: []string{"progress-report", "table-cleanup", "email-drafting"}, + OwnedDefinitionKeys: []string{"specialist.logistics-fulfillment.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.logistics-fulfillment"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/presentation_briefing/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/presentation_briefing/manifest.go new file mode 100644 index 0000000..d266a0c --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/presentation_briefing/manifest.go @@ -0,0 +1,33 @@ +package presentationbriefing + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "presentation-briefing", + Label: "汇报专员", + RuleFileMarkdown: `# 汇报专员 + +## 你是谁 +你负责把零散材料收成能讲、能交、能继续细化的汇报稿:经营汇报、方案汇报、培训课件、项目复盘。 +你面对的是马上要上台讲的人,他要的是一版结构清楚、页数合适、重点能讲明白的演示稿。 + +## 你怎么推进 +1. 先确认汇报对象、场景和页数限制:给管理层、给客户、给培训学员,写法完全不同。 +2. 先出结构,不急着写满内容:按「开场结论 → 现状 / 问题 → 方案 / 进展 → 风险 / 下一步」组织。 +3. 每页只保留一层主结论,宁可留白,不要把 Word 直接搬进 PPT。 +4. 最后补讲稿备注:每页写清楚要讲什么、强调什么、哪些数字必须口径一致。 + +## 输出要求 +- 先给页纲,再给每页要点,再补讲稿备注。 +- 所有数字、时间、范围要带口径;没有依据的地方写「待补」,不要自己补齐。 +- 如果页数明显超了,优先帮用户压缩,而不是机械地继续加页。 + +## 边界 +- 不编造数据、案例、客户反馈。 +- 不把未确认方案写成既定事实。 +- 视觉美化可以建议,但当前重点是结构与表达,不承诺设计成品。`, + AllowedSkills: []string{"ppt-generation", "proposal-summary", "mind-map", "progress-report"}, + OwnedDefinitionKeys: []string{"specialist.presentation-briefing.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.presentation-briefing"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/process_coordination/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/process_coordination/manifest.go new file mode 100644 index 0000000..dc3c275 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/process_coordination/manifest.go @@ -0,0 +1,30 @@ +package processcoordination + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "process-coordination", + Label: "流程推进专员", + RuleFileMarkdown: `# 流程推进专员 + +## 你是谁 +你负责跨部门事项的推进:催办、checkpoint 跟进、阻塞清理、责任路由。 +你面对的是被卡住的事,用户要的是「下一步谁做什么」。 + +## 你怎么推进 +1. 先定位卡在哪一个 checkpoint、卡在谁那里、卡了多久。 +2. 判断这是「没看到」「没时间」还是「有分歧」——三者的催法完全不同。 +3. 给出升级路径:本级催办无效时,下一步找谁、以什么理由。 + +## 输出要求 +- 每条待办写全「事项 / 责任人 / 当前卡点 / 建议动作 / 建议时限」。 +- 催办话术要能直接复制发出去,不要写成「请尽快处理」这种空话。 + +## 边界 +- 只起草催办与升级建议,不代替用户对外发送。 +- 不评价同事的工作态度,只陈述事项状态与时间事实。`, + AllowedSkills: []string{"progress-report", "project-planning", "email-drafting"}, + OwnedDefinitionKeys: []string{"specialist.process-coordination.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.process-coordination"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/report_generation/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/report_generation/manifest.go new file mode 100644 index 0000000..40002fc --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/report_generation/manifest.go @@ -0,0 +1,30 @@ +package reportgeneration + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "report-generation", + Label: "报告生成专员", + RuleFileMarkdown: `# 报告生成专员 + +## 你是谁 +你负责把散落的进展整理成能交上去的报告:日报、周报、复盘摘要、交付说明。 +你面对的是要拿这份报告去汇报的人。 + +## 你怎么推进 +1. 先问清报告的读者和用途——给客户看和给自己团队看,写法完全不同。 +2. 按「结论 → 依据 → 下一步」组织,结论放最前面。 +3. 数据只用手上有出处的,缺的地方写「待补」,不要用估计值填空。 + +## 输出要求 +- 一份报告一个主线,不要把三件事揉成一段。 +- 风险与未完成项不要藏,单独成节。 + +## 边界 +- 不编造未发生的进展,不把「计划做」写成「已做」。 +- 封面/落款等信息缺就问,不要自己拟一个。`, + AllowedSkills: []string{"report-generation", "progress-report", "ppt-generation", "meeting-minutes"}, + OwnedDefinitionKeys: []string{"specialist.report-generation.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.report-generation"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/resume_processor/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/resume_processor/manifest.go new file mode 100644 index 0000000..29302a2 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/resume_processor/manifest.go @@ -0,0 +1,31 @@ +package resumeprocessor + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "resume-processor", + Label: "简历处理专员", + RuleFileMarkdown: `# 简历处理专员 + +## 你是谁 +你负责简历筛选到面试安排:筛选、评分排序、跟进标记、面试安排。 +你面对的是用人部门,他们要的是一份能直接拿去约面试的名单。 + +## 你怎么推进 +1. 先确认岗位要求与硬性门槛(学历、年限、必备技能),门槛项单独标。 +2. 逐份评分:按岗位要求逐项对照,给出总分与排序,并写明「为什么排这个位置」。 +3. 标出不确定项:信息缺失、表述含糊、需要电话确认的点。 +4. 给面试安排建议:分批、每批人选、建议面试重点。 + +## 输出要求 +- 评分要能追溯到岗位要求的具体条目,不要只给一个分数。 +- 排序理由写事实(做过什么、做了多久),不写「感觉不错」。 + +## 边界 +- 不因性别、年龄、婚育、籍贯等与岗位无关的信息做筛选或排序。 +- 不做录用决定,只给排序与理由,决定权在用人部门。`, + AllowedSkills: []string{"ocr-understanding", "batch-extract", "table-cleanup", "interview-summary"}, + OwnedDefinitionKeys: []string{"specialist.resume-processor.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.resume-processor"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/solution_proposal/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/solution_proposal/manifest.go new file mode 100644 index 0000000..6192d45 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/solution_proposal/manifest.go @@ -0,0 +1,32 @@ +package solutionproposal + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "solution-proposal", + Label: "售前方案专员", + RuleFileMarkdown: `# 售前方案专员 + +## 你是谁 +你负责售前方案:需求澄清、方案草案、连接器范围规划、风险边界说明。 +你面对的是销售,他需要一份能拿到客户面前讲的东西。 + +## 你怎么推进 +1. 先澄清需求:客户要解决的具体问题、现状怎么做的、谁拍板、什么时候要。 + 信息不全时先列出「还不知道什么」,不要急着出方案。 +2. 出草案:按「客户现状 → 问题 → 方案 → 边界 → 报价口径」组织。 +3. 明确连接器范围:方案要用到客户哪些系统、谁提供接口、拿不到数据时怎么降级。 +4. 风险边界:哪些是本次不做的,讲清楚,避免交付时扯皮。 + +## 输出要求 +- 区分「已确认需求」和「我方假设」,假设要显式标出来让销售去核。 +- 涉及能力边界的表述,只说现有产品能做的。 + +## 边界 +- 不承诺工期、不承诺价格——这两项由销售与交付确认。 +- 不替研发承诺功能,不贬低竞品。`, + AllowedSkills: []string{"proposal-summary", "ppt-generation", "project-planning", "mind-map"}, + OwnedDefinitionKeys: []string{"specialist.solution-proposal.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.solution-proposal"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/training_delivery/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/training_delivery/manifest.go new file mode 100644 index 0000000..3e8cd0e --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/training_delivery/manifest.go @@ -0,0 +1,30 @@ +package trainingdelivery + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "training-delivery", + Label: "培训交付专员", + RuleFileMarkdown: `# 培训交付专员 + +## 你是谁 +你负责新人销售训练营的交付闭环:班级状态、考试预警、复盘归档、交付清单。 +你面对的是培训负责人,他关心的是「哪个班要出问题了」,不是过程数据。 + +## 你怎么推进 +1. 先给结论:本期班级整体交付状态(正常 / 有风险 / 已阻塞)+ 一句话原因。 +2. 再给异常清单:按风险高低排,每条写清「谁 / 什么信号 / 建议动作」。 +3. 最后给交付清单:本期还缺哪些材料、谁补、什么时候要。 + +## 输出要求 +- 数字要带口径(哪个班、哪场考试、统计截止到什么时候)。 +- 预警必须给出可执行的下一步,不要只说「需关注」。 + +## 边界 +- 只读班级资料与考试结果,不替学员报名、不替教练打分。 +- 涉及具体学员的评价,只陈述记录里的事实,不做主观评判。`, + AllowedSkills: []string{"progress-report", "report-generation", "ppt-generation", "meeting-minutes"}, + OwnedDefinitionKeys: []string{"specialist.training-delivery.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.training-delivery"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/api/official_account_article_service.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go similarity index 73% rename from eai_agentplatform/backend-go/internal/api/official_account_article_service.go rename to eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go index 29b4362..3634e2c 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_article_service.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go @@ -1,4 +1,4 @@ -package api +package wechatofficialaccountapi import ( "encoding/json" @@ -7,23 +7,24 @@ import ( "strings" "eai_agentplatform/backend/internal/model" + officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model" "eai_agentplatform/backend/internal/store" ) -func loadOfficialAccountArticle(taskID uint) (*model.OfficialAccountArticle, error) { - var article model.OfficialAccountArticle +func loadOfficialAccountArticle(taskID uint) (*officialaccountmodel.OfficialAccountArticle, error) { + var article officialaccountmodel.OfficialAccountArticle if err := store.DB.Where("task_id = ?", taskID).First(&article).Error; err != nil { return nil, err } return &article, nil } -func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccountWorkflowState) (*model.OfficialAccountArticle, error) { +func ensureOfficialAccountArticle(task model.TaskRecord, workflow officialAccountWorkflowState) (*officialaccountmodel.OfficialAccountArticle, error) { article, err := loadOfficialAccountArticle(task.ID) if err == nil { return article, nil } - row := &model.OfficialAccountArticle{ + row := &officialaccountmodel.OfficialAccountArticle{ TaskID: task.ID, SpecialistKey: task.SpecialistKey, BusinessDomain: resolveOfficialAccountBusinessDomain(workflow.Form.BusinessDomain, workflow.Form.Keyword), @@ -35,7 +36,7 @@ func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccoun OutlineStyle: normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle), OutlineWords: normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords), ContentTargetWords: normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords), - MaxContentImages: normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages), + MaxContentImages: normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages), Status: "draft", } if err := store.DB.Create(row).Error; err != nil { @@ -44,7 +45,7 @@ func ensureOfficialAccountArticle(task model.WorkerTask, workflow officialAccoun return row, nil } -func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle) { +func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle) { if workflow == nil || article == nil { return } @@ -57,7 +58,7 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta workflow.Form.OutlineStyle = normalizeOfficialAccountOutlineStyle(article.OutlineStyle) workflow.Form.OutlineWords = normalizeOfficialAccountOutlineWords(article.OutlineWords) workflow.Form.ContentTargetWords = normalizeOfficialAccountContentTargetWords(article.ContentTargetWords) - workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(article.MaxContentImages) + workflow.Form.MaxContentImages = normalizeOfficialAccountMaxContentImages(article.MaxContentImages) if strings.TrimSpace(article.TopicCandidatesJSON) != "" { var items []officialAccountTopicCandidate @@ -75,31 +76,31 @@ func syncWorkflowFromOfficialAccountArticle(workflow *officialAccountWorkflowSta workflow.Shared.SelectedTitle = article.SelectedTitle workflow.Shared.Outline = article.Outline workflow.Shared.Content = article.Content - workflow.Shared.ContentWithPrompts = article.ContentWithPrompts - if strings.TrimSpace(article.ImagePromptsJSON) != "" { - var items []officialAccountImagePrompt - if json.Unmarshal([]byte(article.ImagePromptsJSON), &items) == nil { - workflow.Shared.ImagePrompts = items - } - } - if strings.TrimSpace(article.ImageRefsJSON) != "" { - var items []officialAccountImageRef - if json.Unmarshal([]byte(article.ImageRefsJSON), &items) == nil { - workflow.Shared.ImageRefs = items - } - } - workflow.Shared.PreviewMarkdown = article.PreviewMarkdown - workflow.Shared.PreviewHTML = article.PreviewHTML - workflow.Shared.PageMarkdown = article.PageMarkdown - if strings.TrimSpace(article.PagesJSON) != "" { - var items []officialAccountPageChunk - if json.Unmarshal([]byte(article.PagesJSON), &items) == nil { - workflow.Shared.Pages = items - } - } + workflow.Shared.ContentWithPrompts = article.ContentWithPrompts + if strings.TrimSpace(article.ImagePromptsJSON) != "" { + var items []officialAccountImagePrompt + if json.Unmarshal([]byte(article.ImagePromptsJSON), &items) == nil { + workflow.Shared.ImagePrompts = items + } + } + if strings.TrimSpace(article.ImageRefsJSON) != "" { + var items []officialAccountImageRef + if json.Unmarshal([]byte(article.ImageRefsJSON), &items) == nil { + workflow.Shared.ImageRefs = items + } + } + workflow.Shared.PreviewMarkdown = article.PreviewMarkdown + workflow.Shared.PreviewHTML = article.PreviewHTML + workflow.Shared.PageMarkdown = article.PageMarkdown + if strings.TrimSpace(article.PagesJSON) != "" { + var items []officialAccountPageChunk + if json.Unmarshal([]byte(article.PagesJSON), &items) == nil { + workflow.Shared.Pages = items + } + } } -func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticle, workflow officialAccountWorkflowState) { +func syncOfficialAccountArticleFromWorkflow(article *officialaccountmodel.OfficialAccountArticle, workflow officialAccountWorkflowState) { if article == nil { return } @@ -112,40 +113,40 @@ func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticl article.OutlineStyle = normalizeOfficialAccountOutlineStyle(workflow.Form.OutlineStyle) article.OutlineWords = normalizeOfficialAccountOutlineWords(workflow.Form.OutlineWords) article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(workflow.Form.ContentTargetWords) - article.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages) + article.MaxContentImages = normalizeOfficialAccountMaxContentImages(workflow.Form.MaxContentImages) article.SelectedTopic = workflow.Shared.SelectedTopic article.SelectedTitle = workflow.Shared.SelectedTitle article.Outline = workflow.Shared.Outline article.Content = workflow.Shared.Content - article.ContentWithPrompts = workflow.Shared.ContentWithPrompts + article.ContentWithPrompts = workflow.Shared.ContentWithPrompts if data, err := json.Marshal(workflow.Shared.TopicCandidates); err == nil { article.TopicCandidatesJSON = string(data) } if data, err := json.Marshal(workflow.Shared.TitleCandidates); err == nil { article.TitleCandidatesJSON = string(data) } - if data, err := json.Marshal(workflow.Shared.ImagePrompts); err == nil { - article.ImagePromptsJSON = string(data) - } - if data, err := json.Marshal(workflow.Shared.ImageRefs); err == nil { - article.ImageRefsJSON = string(data) - } - article.PreviewMarkdown = workflow.Shared.PreviewMarkdown - article.PreviewHTML = workflow.Shared.PreviewHTML - article.PageMarkdown = workflow.Shared.PageMarkdown - if data, err := json.Marshal(workflow.Shared.Pages); err == nil { - article.PagesJSON = string(data) - } - if workflow.Shared.PageMarkdown != "" { - article.Status = "page_md_ready" - } else if workflow.Shared.PreviewHTML != "" { - article.Status = "preview_ready" - } else if len(workflow.Shared.ImageRefs) > 0 { - article.Status = "images_ready" - } else if len(workflow.Shared.ImagePrompts) > 0 || workflow.Shared.ContentWithPrompts != "" { - article.Status = "image_prompts_ready" - } else if workflow.Shared.Content != "" { - article.Status = "content_ready" + if data, err := json.Marshal(workflow.Shared.ImagePrompts); err == nil { + article.ImagePromptsJSON = string(data) + } + if data, err := json.Marshal(workflow.Shared.ImageRefs); err == nil { + article.ImageRefsJSON = string(data) + } + article.PreviewMarkdown = workflow.Shared.PreviewMarkdown + article.PreviewHTML = workflow.Shared.PreviewHTML + article.PageMarkdown = workflow.Shared.PageMarkdown + if data, err := json.Marshal(workflow.Shared.Pages); err == nil { + article.PagesJSON = string(data) + } + if workflow.Shared.PageMarkdown != "" { + article.Status = "page_md_ready" + } else if workflow.Shared.PreviewHTML != "" { + article.Status = "preview_ready" + } else if len(workflow.Shared.ImageRefs) > 0 { + article.Status = "images_ready" + } else if len(workflow.Shared.ImagePrompts) > 0 || workflow.Shared.ContentWithPrompts != "" { + article.Status = "image_prompts_ready" + } else if workflow.Shared.Content != "" { + article.Status = "content_ready" } else if workflow.Shared.Outline != "" { article.Status = "outline_ready" } else if workflow.Shared.SelectedTitle != "" { @@ -157,7 +158,7 @@ func syncOfficialAccountArticleFromWorkflow(article *model.OfficialAccountArticl } } -func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req officialAccountTaskUpdateReq) { +func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountArticle, req officialAccountTaskUpdateReq) { if article == nil { return } @@ -177,19 +178,19 @@ func resetOfficialAccountArticle(article *model.OfficialAccountArticle, req offi article.OutlineWords = normalizeOfficialAccountOutlineWords(req.OutlineWords) article.Outline = "" article.ContentTargetWords = normalizeOfficialAccountContentTargetWords(req.ContentTargetWords) - article.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages) + article.MaxContentImages = normalizeOfficialAccountMaxContentImages(req.MaxContentImages) article.Content = "" - article.ContentWithPrompts = "" - article.ImagePromptsJSON = "[]" - article.ImageRefsJSON = "[]" - article.PreviewMarkdown = "" - article.PreviewHTML = "" - article.PageMarkdown = "" - article.PagesJSON = "[]" + article.ContentWithPrompts = "" + article.ImagePromptsJSON = "[]" + article.ImageRefsJSON = "[]" + article.PreviewMarkdown = "" + article.PreviewHTML = "" + article.PageMarkdown = "" + article.PagesJSON = "[]" article.Status = "draft" } -func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []model.OfficialAccountHotspot) []officialAccountTopicCandidate { +func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) []officialAccountTopicCandidate { domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain) candidates := make([]officialAccountTopicCandidate, 0, 4) for _, item := range hotspots { @@ -256,7 +257,7 @@ func deriveOfficialAccountAngle(sourceTitle string) string { } } -func marshalOfficialAccountHotspots(hotspots []model.OfficialAccountHotspot) string { +func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAccountHotspot) string { if len(hotspots) == 0 { return "[]" } @@ -267,7 +268,7 @@ func marshalOfficialAccountHotspots(hotspots []model.OfficialAccountHotspot) str return string(data) } -func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []model.OfficialAccountHotspot) string { +func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) string { cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) lines := []string{ fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 4 个值得写的热点选题。", cfg.PromptLabel), @@ -292,7 +293,7 @@ func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []mode return strings.Join(lines, "\n") } -func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot) string { +func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, hotspots []officialaccountmodel.OfficialAccountHotspot) string { cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) lines := []string{ fmt.Sprintf("请判断这个公众号选题在当前%s热点里的热度和可写性,返回一个 JSON 对象,字段固定为 heat、reason。", cfg.PromptLabel), @@ -310,7 +311,7 @@ func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, return strings.Join(lines, "\n") } -func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []model.OfficialAccountHotspot) string { +func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidates []officialAccountTopicCandidate, hotspots []officialaccountmodel.OfficialAccountHotspot) string { cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) lines := []string{ fmt.Sprintf("你是公众号选题评审助手,请基于当前%s热点,对下面多个公众号选题候选做一次批量评判。", cfg.PromptLabel), @@ -339,8 +340,8 @@ func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidat return strings.Join(lines, "\n") } -func sortOfficialAccountHotspots(items []model.OfficialAccountHotspot) []model.OfficialAccountHotspot { - cloned := append([]model.OfficialAccountHotspot(nil), items...) +func sortOfficialAccountHotspots(items []officialaccountmodel.OfficialAccountHotspot) []officialaccountmodel.OfficialAccountHotspot { + cloned := append([]officialaccountmodel.OfficialAccountHotspot(nil), items...) sort.Slice(cloned, func(i, j int) bool { if cloned[i].DomainScore == cloned[j].DomainScore { return cloned[i].FetchedAt.After(cloned[j].FetchedAt) diff --git a/eai_agentplatform/backend-go/internal/api/official_account_delivery.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go similarity index 95% rename from eai_agentplatform/backend-go/internal/api/official_account_delivery.go rename to eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go index 9745b98..682e2ad 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_delivery.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go @@ -1,4 +1,4 @@ -package api +package wechatofficialaccountapi import ( "bytes" @@ -17,6 +17,7 @@ import ( "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model" "eai_agentplatform/backend/internal/web" ) @@ -25,7 +26,7 @@ type officialAccountSection struct { Content []string } -func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { prompts := buildOfficialAccountImagePrompts(workflow) contentWithPrompts := buildOfficialAccountContentWithPrompts(workflow.Shared.Content, prompts) workflow.Shared.ContentWithPrompts = contentWithPrompts @@ -38,10 +39,10 @@ func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowStat } return officialAccountStepExecution{ Summary: fmt.Sprintf("已生成 %d 条配图提示词。", len(prompts)), - TaskStatus: workerTaskStatusDraft, + TaskStatus: taskStatusDraft, ArtifactTitle: "公众号配图提示词", ArtifactType: "image_prompts", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: contentWithPrompts, Output: output, Logs: []string{ @@ -51,7 +52,7 @@ func executeOfficialAccountImagePromptStep(workflow *officialAccountWorkflowStat } } -func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { +func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { prompts := workflow.Shared.ImagePrompts if len(prompts) == 0 { prompts = buildOfficialAccountImagePrompts(workflow) @@ -73,10 +74,10 @@ func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflow } return officialAccountStepExecution{ Summary: fmt.Sprintf("已生成 %d 张图片结果。", len(refs)), - TaskStatus: workerTaskStatusDraft, + TaskStatus: taskStatusDraft, ArtifactTitle: "公众号配图结果", ArtifactType: "images", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: buildOfficialAccountImageArtifact(refs), Output: output, Logs: []string{ @@ -86,7 +87,7 @@ func executeOfficialAccountImageGenerationStep(workflow *officialAccountWorkflow }, nil } -func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { previewMarkdown := buildOfficialAccountPreviewMarkdown(workflow) previewHTML := renderOfficialAccountHTML(previewMarkdown) workflow.Shared.PreviewMarkdown = previewMarkdown @@ -99,10 +100,10 @@ func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, a } return officialAccountStepExecution{ Summary: "预览稿已生成,可直接检查排版并导出。", - TaskStatus: workerTaskStatusDraft, + TaskStatus: taskStatusDraft, ArtifactTitle: "公众号预览稿", ArtifactType: "preview", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: previewMarkdown, Output: output, Logs: []string{ @@ -112,7 +113,7 @@ func executeOfficialAccountPreviewStep(workflow *officialAccountWorkflowState, a } } -func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { pages := buildOfficialAccountPages(workflow) pageMarkdown := buildOfficialAccountPageMarkdown(pages) workflow.Shared.Pages = pages @@ -131,10 +132,10 @@ func executeOfficialAccountPageMarkdownStep(workflow *officialAccountWorkflowSta } return officialAccountStepExecution{ Summary: fmt.Sprintf("分页 MD 已生成,共 %d 页。", len(pages)), - TaskStatus: workerTaskStatusDraft, + TaskStatus: taskStatusDraft, ArtifactTitle: "公众号分页 MD", ArtifactType: "page_markdown", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: pageMarkdown, Output: output, Logs: []string{ @@ -246,7 +247,7 @@ func generateOfficialAccountImageRefWithRoute(client *http.Client, aiRoute *conf latencyMs := int(time.Since(started) / time.Millisecond) ai.LogCall(ai.LogEntry{ UserID: user.ID, - Capability: ai.CapabilityImageGen, + UsageKind: ai.UsageKindImageGen, Provider: aiRoute.Provider, AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, @@ -567,7 +568,7 @@ func parseOfficialAccountImageMarkdown(line string) (string, string) { func ExportOfficialAccountDocument(c *gin.Context) { user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) + task, ok := loadAccessibleTask(c, user) if !ok { return } diff --git a/eai_agentplatform/backend-go/internal/api/official_account_domain_config.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_domain_config.go similarity index 99% rename from eai_agentplatform/backend-go/internal/api/official_account_domain_config.go rename to eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_domain_config.go index b727337..428512c 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_domain_config.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_domain_config.go @@ -1,4 +1,4 @@ -package api +package wechatofficialaccountapi import "strings" diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_generated_image.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_generated_image.go new file mode 100644 index 0000000..f3d33d1 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_generated_image.go @@ -0,0 +1,176 @@ +package wechatofficialaccountapi + +import ( + "crypto/sha1" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + + "eai_agentplatform/backend/internal/config" + "eai_agentplatform/backend/internal/model" + officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" +) + +func officialAccountGeneratedImageDir() string { + cfg := config.Load() + return filepath.Join(filepath.Dir(cfg.DBPath), "official_account_generated_images") +} + +func normalizeOfficialAccountMediaForResponse(task *model.TaskRecord, workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle) error { + if workflow == nil || article == nil || task == nil { + return nil + } + refs, replacements, changed, err := persistOfficialAccountImageRefs(task.ID, workflow.Shared.ImageRefs) + if err != nil { + return err + } + if !changed { + return nil + } + + workflow.Shared.ImageRefs = refs + workflow.Shared.PreviewMarkdown = replaceOfficialAccountURLs(workflow.Shared.PreviewMarkdown, replacements) + workflow.Shared.PreviewHTML = replaceOfficialAccountURLs(workflow.Shared.PreviewHTML, replacements) + workflow.Shared.PageMarkdown = replaceOfficialAccountURLs(workflow.Shared.PageMarkdown, replacements) + for idx := range workflow.Shared.Pages { + workflow.Shared.Pages[idx].Content = replaceOfficialAccountURLs(workflow.Shared.Pages[idx].Content, replacements) + } + syncOfficialAccountArticleFromWorkflow(article, *workflow) + task.ContextJSON = marshalOfficialAccountWorkflow(*workflow) + + if err := store.DB.Save(article).Error; err != nil { + return err + } + if err := store.DB.Save(task).Error; err != nil { + return err + } + return nil +} + +func persistOfficialAccountImageRefs(taskID uint, refs []officialAccountImageRef) ([]officialAccountImageRef, map[string]string, bool, error) { + if len(refs) == 0 { + return refs, map[string]string{}, false, nil + } + next := make([]officialAccountImageRef, len(refs)) + replacements := map[string]string{} + changed := false + for idx, item := range refs { + next[idx] = item + if !strings.HasPrefix(strings.TrimSpace(item.URL), "data:image/") { + continue + } + localURL, err := persistOfficialAccountDataURL(taskID, item.Key, item.URL) + if err != nil { + return refs, nil, false, err + } + replacements[item.URL] = localURL + next[idx].URL = localURL + changed = true + } + return next, replacements, changed, nil +} + +func persistOfficialAccountDataURL(taskID uint, key, raw string) (string, error) { + parts := strings.SplitN(strings.TrimSpace(raw), ",", 2) + if len(parts) != 2 { + return "", fmt.Errorf("图片数据格式错误") + } + header := parts[0] + body := parts[1] + if !strings.Contains(header, ";base64") { + return "", fmt.Errorf("图片数据缺少 base64 头") + } + ext := ".png" + switch { + case strings.HasPrefix(header, "data:image/jpeg"): + ext = ".jpg" + case strings.HasPrefix(header, "data:image/webp"): + ext = ".webp" + case strings.HasPrefix(header, "data:image/gif"): + ext = ".gif" + } + payload, err := base64.StdEncoding.DecodeString(body) + if err != nil { + return "", err + } + dir := officialAccountGeneratedImageDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + hash := sha1.Sum(payload) + filename := fmt.Sprintf("task_%d_%s_%x%s", taskID, sanitizeOfficialAccountImageKey(key), hash[:6], ext) + fullPath := filepath.Join(dir, filename) + if _, err := os.Stat(fullPath); err != nil { + if writeErr := os.WriteFile(fullPath, payload, 0o644); writeErr != nil { + return "", writeErr + } + } + return "/api/official-account/generated-images/" + filename, nil +} + +func sanitizeOfficialAccountImageKey(key string) string { + trimmed := strings.TrimSpace(strings.ToLower(key)) + trimmed = strings.ReplaceAll(trimmed, " ", "_") + trimmed = strings.ReplaceAll(trimmed, "/", "_") + trimmed = strings.ReplaceAll(trimmed, "\\", "_") + if trimmed == "" { + return "image" + } + return trimmed +} + +func replaceOfficialAccountURLs(content string, replacements map[string]string) string { + next := content + for oldURL, newURL := range replacements { + next = strings.ReplaceAll(next, oldURL, newURL) + } + return next +} + +func ServeOfficialAccountGeneratedImage(c *gin.Context) { + filename := filepath.Base(strings.TrimSpace(c.Param("filename"))) + if filename == "." || filename == "" { + web.Fail(c, web.NewNotFoundError("图片不存在")) + return + } + fullPath := filepath.Join(officialAccountGeneratedImageDir(), filename) + if _, err := os.Stat(fullPath); err != nil { + web.Fail(c, web.NewNotFoundError("图片不存在")) + return + } + c.File(fullPath) +} + +func compactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRun { + next := make([]model.TaskRun, len(items)) + for idx, item := range items { + next[idx] = item + if len(item.OutputJSON) > 8192 || strings.Contains(item.OutputJSON, "data:image/") { + next[idx].OutputJSON = "" + } + if len(item.InputJSON) > 4096 { + next[idx].InputJSON = "" + } + } + return next +} + +func compactOfficialAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact { + next := make([]model.TaskArtifact, len(items)) + for idx, item := range items { + next[idx] = item + if len(item.ContentJSON) > 8192 || strings.Contains(item.ContentJSON, "data:image/") { + next[idx].ContentJSON = "" + } + if len(item.ContentText) > 20000 { + next[idx].ContentText = item.ContentText[:20000] + "\n\n[内容过长,已在画布接口中省略]" + } + } + return next +} diff --git a/eai_agentplatform/backend-go/internal/api/official_account_hotspot_service.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_hotspot_service.go similarity index 94% rename from eai_agentplatform/backend-go/internal/api/official_account_hotspot_service.go rename to eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_hotspot_service.go index b6992a2..ab0b58a 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_hotspot_service.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_hotspot_service.go @@ -1,4 +1,4 @@ -package api +package wechatofficialaccountapi import ( "bytes" @@ -17,7 +17,7 @@ import ( "golang.org/x/net/html/charset" "gorm.io/gorm/clause" - "eai_agentplatform/backend/internal/model" + officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model" "eai_agentplatform/backend/internal/store" ) @@ -59,7 +59,7 @@ type officialAccountRSS struct { } `xml:"entry"` } -func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]model.OfficialAccountHotspot, []string, error) { +func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]officialaccountmodel.OfficialAccountHotspot, []string, error) { domainKey := resolveOfficialAccountBusinessDomain(form.BusinessDomain, form.Keyword) cfg := getOfficialAccountBusinessDomainConfig(domainKey) logs := []string{} @@ -114,8 +114,8 @@ func ensureOfficialAccountHotspots(form officialAccountForm, force bool) ([]mode return latest, logs, nil } -func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]model.OfficialAccountHotspot, error) { - var items []model.OfficialAccountHotspot +func loadFreshOfficialAccountHotspots(domainKey, keyword string, maxAge time.Duration) ([]officialaccountmodel.OfficialAccountHotspot, error) { + var items []officialaccountmodel.OfficialAccountHotspot cutoff := time.Now().Add(-maxAge) err := store.DB. Where("business_domain = ? AND fetched_at >= ? AND domain_score > 0", domainKey, cutoff). @@ -268,14 +268,14 @@ func upsertOfficialAccountHotspots(domainKey, keyword string, items []officialAc } cfg := getOfficialAccountBusinessDomainConfig(domainKey) now := time.Now() - upserts := make([]model.OfficialAccountHotspot, 0, len(items)) + upserts := make([]officialaccountmodel.OfficialAccountHotspot, 0, len(items)) for _, item := range items { rawScore, domainScore, tags := scoreOfficialAccountHotspot(domainKey, keyword, item.Title, item.Summary) if domainScore <= 0 { continue } tagsJSON, _ := json.Marshal(tags) - upserts = append(upserts, model.OfficialAccountHotspot{ + upserts = append(upserts, officialaccountmodel.OfficialAccountHotspot{ BusinessDomain: domainKey, SourceKey: item.SourceKey, SourceLabel: item.SourceLabel, @@ -397,13 +397,13 @@ func containsOfficialAccountKeywords(text string, keywords []string) bool { return false } -func filterOfficialAccountHotspotsByKeyword(items []model.OfficialAccountHotspot, keyword string) []model.OfficialAccountHotspot { +func filterOfficialAccountHotspotsByKeyword(items []officialaccountmodel.OfficialAccountHotspot, keyword string) []officialaccountmodel.OfficialAccountHotspot { terms := buildOfficialAccountKeywordTerms(keyword) if len(terms) == 0 { return items } type scoredHotspot struct { - item model.OfficialAccountHotspot + item officialaccountmodel.OfficialAccountHotspot score int } scored := make([]scoredHotspot, 0, len(items)) @@ -428,7 +428,7 @@ func filterOfficialAccountHotspotsByKeyword(items []model.OfficialAccountHotspot } return scored[i].score > scored[j].score }) - result := make([]model.OfficialAccountHotspot, 0, len(scored)) + result := make([]officialaccountmodel.OfficialAccountHotspot, 0, len(scored)) for _, item := range scored { result = append(result, item.item) if len(result) >= 20 { diff --git a/eai_agentplatform/backend-go/internal/api/official_account_workflow.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go similarity index 95% rename from eai_agentplatform/backend-go/internal/api/official_account_workflow.go rename to eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go index 92fe7de..ff94ab3 100644 --- a/eai_agentplatform/backend-go/internal/api/official_account_workflow.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go @@ -1,4 +1,4 @@ -package api +package wechatofficialaccountapi import ( "encoding/json" @@ -13,13 +13,13 @@ import ( "eai_agentplatform/backend/internal/config" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model" "eai_agentplatform/backend/internal/store" "eai_agentplatform/backend/internal/web" ) const ( - officialAccountSpecialistKey = "wechat-official-account" - officialAccountStepKeyConfig = "task_configuration" officialAccountStepKeyTopic = "topic_recommendation" officialAccountStepKeyTitle = "title_generation" @@ -184,7 +184,7 @@ func CreateOfficialAccountTask(c *gin.Context) { return } - var specialist model.Specialist + var specialist specialistmodel.Specialist if err := store.DB.Where("key = ?", officialAccountSpecialistKey).First(&specialist).Error; err != nil { web.Fail(c, web.NewNotFoundError("公众号助手尚未配置")) return @@ -193,13 +193,13 @@ func CreateOfficialAccountTask(c *gin.Context) { workflow := newOfficialAccountWorkflow(req) contextJSON, _ := json.Marshal(workflow) dueAt := time.Now().Add(24 * time.Hour) - task := model.WorkerTask{ + task := model.TaskRecord{ SpecialistKey: specialist.Key, Title: buildOfficialAccountTaskTitle(req.Keyword), Summary: buildOfficialAccountTaskSummary(req), Owner: bootstrapOwner(user), Priority: "P2", - Status: workerTaskStatusPending, + Status: taskStatusPending, ContextJSON: string(contextJSON), DueAt: &dueAt, } @@ -224,7 +224,7 @@ func CreateOfficialAccountTask(c *gin.Context) { func GetOfficialAccountWorkflow(c *gin.Context) { user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) + task, ok := loadAccessibleTask(c, user) if !ok { return } @@ -237,7 +237,7 @@ func GetOfficialAccountWorkflow(c *gin.Context) { func UpdateOfficialAccountTask(c *gin.Context) { user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) + task, ok := loadAccessibleTask(c, user) if !ok { return } @@ -315,15 +315,15 @@ func UpdateOfficialAccountTask(c *gin.Context) { Keyword: req.Keyword, Audience: req.Audience, }) - task.Status = workerTaskStatusPending + task.Status = taskStatusPending task.CurrentResult = "" task.CurrentRunID = nil task.LastTriggeredAt = nil - if err := store.DB.Where("task_id = ?", task.ID).Delete(&model.WorkerRun{}).Error; err != nil { + if err := store.DB.Where("task_id = ?", task.ID).Delete(&model.TaskRun{}).Error; err != nil { web.Fail(c, web.NewBadRequest("清理旧运行记录失败")) return } - if err := store.DB.Where("task_id = ?", task.ID).Delete(&model.WorkerArtifact{}).Error; err != nil { + if err := store.DB.Where("task_id = ?", task.ID).Delete(&model.TaskArtifact{}).Error; err != nil { web.Fail(c, web.NewBadRequest("清理旧产物失败")) return } @@ -363,7 +363,7 @@ func UpdateOfficialAccountTask(c *gin.Context) { logsJSON, _ := json.Marshal([]string{ fmt.Sprintf("%s 已切换主题并重置流程", now.Format("15:04:05")), }) - run := model.WorkerRun{ + run := model.TaskRun{ TaskID: task.ID, SpecialistKey: task.SpecialistKey, ActionKey: officialAccountStepKeyConfig, @@ -387,7 +387,7 @@ func UpdateOfficialAccountTask(c *gin.Context) { func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) + task, ok := loadAccessibleTask(c, user) if !ok { return } @@ -460,7 +460,7 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { outputPayload["keyword"] = workflow.Form.Keyword outputJSON, _ := json.Marshal(outputPayload) logsJSON, _ := json.Marshal(execution.Logs) - run := model.WorkerRun{ + run := model.TaskRun{ TaskID: task.ID, SpecialistKey: task.SpecialistKey, ActionKey: stepKey, @@ -479,10 +479,10 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { } task.CurrentRunID = &run.ID - var artifact *model.WorkerArtifact + var artifact *model.TaskArtifact if execution.ArtifactTitle != "" { sourceRefsJSON, _ := json.Marshal(buildOfficialAccountSourceRefs(workflow, stepKey)) - artifact = &model.WorkerArtifact{ + artifact = &model.TaskArtifact{ TaskID: task.ID, SpecialistKey: task.SpecialistKey, Title: execution.ArtifactTitle, @@ -518,7 +518,7 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { func RegenerateOfficialAccountImage(c *gin.Context) { user := middleware.CurrentUser(c) - task, ok := loadAccessibleWorkerTask(c, user) + task, ok := loadAccessibleTask(c, user) if !ok { return } @@ -572,7 +572,7 @@ func RegenerateOfficialAccountImage(c *gin.Context) { } setOfficialAccountStepCompleted(&workflow, officialAccountStepKeyImages, output, logs, now) - task.Status = workerTaskStatusDraft + task.Status = taskStatusDraft task.Summary = buildOfficialAccountTaskSummaryFromWorkflow(workflow) task.Title = buildOfficialAccountTaskTitleFromWorkflow(workflow) task.ContextJSON = marshalOfficialAccountWorkflow(workflow) @@ -590,7 +590,7 @@ func RegenerateOfficialAccountImage(c *gin.Context) { outputPayload["keyword"] = workflow.Form.Keyword outputJSON, _ := json.Marshal(outputPayload) logsJSON, _ := json.Marshal(logs) - run := model.WorkerRun{ + run := model.TaskRun{ TaskID: task.ID, SpecialistKey: task.SpecialistKey, ActionKey: "image_regeneration", @@ -609,12 +609,12 @@ func RegenerateOfficialAccountImage(c *gin.Context) { } task.CurrentRunID = &run.ID - artifact := &model.WorkerArtifact{ + artifact := &model.TaskArtifact{ TaskID: task.ID, SpecialistKey: task.SpecialistKey, Title: fmt.Sprintf("单张重生成图片 - %s", firstNonEmpty(ref.Section, ref.Key)), ArtifactType: "images", - Status: workerArtifactStatusDraft, + Status: artifactStatusDraft, ContentText: buildOfficialAccountImageArtifact([]officialAccountImageRef{ref}), ContentJSON: string(outputJSON), SourceRefsJSON: "[]", @@ -643,7 +643,7 @@ func RegenerateOfficialAccountImage(c *gin.Context) { }) } -func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { +func respondOfficialAccountWorkflow(c *gin.Context, task model.TaskRecord) { workflow := parseOfficialAccountWorkflow(task.ContextJSON) article, _ := ensureOfficialAccountArticle(task, workflow) syncWorkflowFromOfficialAccountArticle(&workflow, article) @@ -652,13 +652,13 @@ func respondOfficialAccountWorkflow(c *gin.Context, task model.WorkerTask) { return } - var artifacts []model.WorkerArtifact + var artifacts []model.TaskArtifact if err := store.DB.Where("task_id = ?", task.ID).Order("created_at DESC, id DESC").Find(&artifacts).Error; err != nil { web.Fail(c, web.NewBadRequest("查询公众号产物失败")) return } - var runs []model.WorkerRun + var runs []model.TaskRun if err := store.DB.Where("task_id = ?", task.ID).Order("started_at DESC, id DESC").Find(&runs).Error; err != nil { web.Fail(c, web.NewBadRequest("查询公众号运行记录失败")) return @@ -1007,7 +1007,7 @@ func setOfficialAccountStepError(workflow *officialAccountWorkflowState, stepKey workflow.CurrentStep = stepKey } -func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, req officialAccountStepReq, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { +func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, req officialAccountStepReq, user *model.User, now time.Time) (officialAccountStepExecution, *web.AppError) { switch stepKey { case officialAccountStepKeyTopic: return executeOfficialAccountTopicStep(workflow, article, user, now), nil @@ -1076,7 +1076,7 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta } } -func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(workflow.Form, false) hotspots = sortOfficialAccountHotspots(hotspots) candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots) @@ -1098,10 +1098,10 @@ func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, art } return officialAccountStepExecution{ Summary: fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic), - TaskStatus: workerTaskStatusInProgress, + TaskStatus: taskStatusInProgress, ArtifactTitle: "公众号选题建议", ArtifactType: "topics", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: buildTopicArtifactContent(candidates, workflow.Shared.SelectedTopic), Output: output, Logs: append([]string{ @@ -1112,7 +1112,7 @@ func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, art } } -func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { titles := buildOfficialAccountTitles(workflow.Form, workflow.Shared.SelectedTopic) _ = tryFillOfficialAccountTitlesWithAI(workflow.Form, workflow.Shared.SelectedTopic, user, &titles) if workflow.Shared.SelectedTitle == "" && len(titles) > 0 { @@ -1133,10 +1133,10 @@ func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, art } return officialAccountStepExecution{ Summary: fmt.Sprintf("已完成标题生成,当前推荐标题为「%s」。", workflow.Shared.SelectedTitle), - TaskStatus: workerTaskStatusInProgress, + TaskStatus: taskStatusInProgress, ArtifactTitle: "公众号标题建议", ArtifactType: "titles", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: buildTitleArtifactContent(titles, workflow.Shared.SelectedTitle), Output: output, Logs: []string{ @@ -1147,7 +1147,7 @@ func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, art } } -func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { outline := buildOfficialAccountOutline(workflow.Form, workflow.Shared.SelectedTitle) aiOutline, ok := tryGenerateOfficialAccountOutlineWithAI(workflow.Form, workflow.Shared.SelectedTitle, user) if ok { @@ -1165,10 +1165,10 @@ func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, a } return officialAccountStepExecution{ Summary: "提纲已生成,可继续进入正文创作。", - TaskStatus: workerTaskStatusInProgress, + TaskStatus: taskStatusInProgress, ArtifactTitle: "公众号文章提纲", ArtifactType: "outline", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: outline, Output: output, Logs: []string{ @@ -1179,7 +1179,7 @@ func executeOfficialAccountOutlineStep(workflow *officialAccountWorkflowState, a } } -func executeOfficialAccountContentStep(workflow *officialAccountWorkflowState, article *model.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { +func executeOfficialAccountContentStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.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) if ok { @@ -1197,10 +1197,10 @@ func executeOfficialAccountContentStep(workflow *officialAccountWorkflowState, a } return officialAccountStepExecution{ Summary: "正文草稿已生成,可进入人工校对和发布环节。", - TaskStatus: workerTaskStatusDraft, + TaskStatus: taskStatusDraft, ArtifactTitle: firstNonEmpty(workflow.Shared.SelectedTitle, "公众号文章草稿"), ArtifactType: "article", - ArtifactStatus: workerArtifactStatusDraft, + ArtifactStatus: artifactStatusDraft, ArtifactContent: content, Output: output, Logs: []string{ @@ -1281,7 +1281,7 @@ func buildOfficialAccountContent(form officialAccountForm, title, outline string )) } -func tryFillOfficialAccountTopicsWithAI(form officialAccountForm, hotspots []model.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) bool { +func tryFillOfficialAccountTopicsWithAI(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) bool { var items []officialAccountTopicCandidate prompt := buildOfficialAccountHotspotPrompt(form, hotspots) if !generateOfficialAccountJSON(prompt, user, &items) || len(items) == 0 { @@ -1338,7 +1338,7 @@ type officialAccountTopicEvalResult struct { Score int `json:"score"` } -func evaluateOfficialAccountTopics(form officialAccountForm, hotspots []model.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) int { +func evaluateOfficialAccountTopics(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot, user *model.User, target *[]officialAccountTopicCandidate) int { if target == nil || len(*target) == 0 { return 0 } @@ -1362,7 +1362,7 @@ func evaluateOfficialAccountTopics(form officialAccountForm, hotspots []model.Of return evaluated } -func tryEvaluateOfficialAccountTopicWithAI(form officialAccountForm, topic string, hotspots []model.OfficialAccountHotspot, user *model.User) (officialAccountTopicEvalResult, bool) { +func tryEvaluateOfficialAccountTopicWithAI(form officialAccountForm, topic string, hotspots []officialaccountmodel.OfficialAccountHotspot, user *model.User) (officialAccountTopicEvalResult, bool) { var result officialAccountTopicEvalResult prompt := buildOfficialAccountTopicEvalPrompt(form, topic, hotspots) if !generateOfficialAccountJSON(prompt, user, &result) { @@ -1416,7 +1416,7 @@ func generateOfficialAccountText(prompt string, user *model.User) (string, bool) if err != nil || result == nil || strings.TrimSpace(result.Content) == "" { ai.LogCall(ai.LogEntry{ UserID: user.ID, - Capability: ai.CapabilityTextGen, + UsageKind: ai.UsageKindTextGen, Provider: aiRoute.Provider, AIRouteID: aiRoute.RouteID, Model: aiRoute.Model, @@ -1432,7 +1432,7 @@ func generateOfficialAccountText(prompt string, user *model.User) (string, bool) } ai.LogCall(ai.LogEntry{ UserID: user.ID, - Capability: ai.CapabilityTextGen, + UsageKind: ai.UsageKindTextGen, Provider: finalAiRoute.Provider, AIRouteID: finalAiRoute.RouteID, Model: finalAiRoute.Model, @@ -1556,21 +1556,21 @@ func cloneOutputMap(source map[string]any) map[string]any { return result } -func loadAccessibleWorkerTask(c *gin.Context, user *model.User) (model.WorkerTask, bool) { +func loadAccessibleTask(c *gin.Context, user *model.User) (model.TaskRecord, bool) { id, ok := parseID(c, "id") if !ok { - return model.WorkerTask{}, false + return model.TaskRecord{}, false } - query := store.DB.Model(&model.WorkerTask{}).Where("id = ?", id) + query := store.DB.Model(&model.TaskRecord{}).Where("id = ?", id) if user != nil && user.Role != "admin" { query = query.Where("owner IN ?", []string{user.FullName, user.Username}) } - var task model.WorkerTask + var task model.TaskRecord if err := query.First(&task).Error; err != nil { web.Fail(c, web.NewNotFoundError("事项不存在")) - return model.WorkerTask{}, false + return model.TaskRecord{}, false } return task, true } diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/runtime_helpers.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/runtime_helpers.go new file mode 100644 index 0000000..64e0431 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/runtime_helpers.go @@ -0,0 +1,82 @@ +package wechatofficialaccountapi + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + + "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/web" +) + +const ( + OfficialAccountSpecialistKey = "wechat-official-account" + officialAccountSpecialistKey = OfficialAccountSpecialistKey + + taskStatusPending = "待处理" + taskStatusInProgress = "进行中" + taskStatusDraft = "草案已生成" + + artifactStatusDraft = "draft" +) + +func CompactOfficialAccountRunsForResponse(items []model.TaskRun) []model.TaskRun { + return compactOfficialAccountRunsForResponse(items) +} + +func CompactOfficialAccountArtifactsForResponse(items []model.TaskArtifact) []model.TaskArtifact { + return compactOfficialAccountArtifactsForResponse(items) +} + +func bootstrapOwner(user *model.User) string { + if user != nil && strings.TrimSpace(user.FullName) != "" { + return strings.TrimSpace(user.FullName) + } + return "待指派" +} + +func parseID(c *gin.Context, name string) (uint, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || id == 0 { + web.Fail(c, web.NewBadRequest("无效的 "+name)) + return 0, false + } + return uint(id), true +} + +type taskRunOutput struct { + Summary string `json:"summary"` + Timeline []string `json:"timeline"` + Trace []string `json:"trace"` +} + +func buildBottomPanel(runs []model.TaskRun) gin.H { + panel := gin.H{ + "timeline": []string{}, + "replay": []string{}, + "trace": []string{}, + "logs": []string{}, + } + for _, run := range runs { + var output taskRunOutput + _ = json.Unmarshal([]byte(run.OutputJSON), &output) + var logs []string + _ = json.Unmarshal([]byte(run.LogsJSON), &logs) + + panel["timeline"] = appendStringSlice(panel["timeline"], output.Timeline) + panel["replay"] = appendStringSlice(panel["replay"], []string{ + fmt.Sprintf("%s · %s · %s", run.StartedAt.Format("01-02 15:04"), run.ActionTitle, output.Summary), + }) + panel["trace"] = appendStringSlice(panel["trace"], output.Trace) + panel["logs"] = appendStringSlice(panel["logs"], logs) + } + return panel +} + +func appendStringSlice(current any, values []string) []string { + base, _ := current.([]string) + return append(base, values...) +} diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/manifest.go new file mode 100644 index 0000000..0736eb2 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/manifest.go @@ -0,0 +1,31 @@ +package wechatofficialaccount + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +var Manifest = specialistcontracts.Manifest{ + Key: "wechat-official-account", + Label: "公众号助手", + RuleFileMarkdown: `# 公众号助手 + +## 你是谁 +你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。 +你面对的是要持续产出、又要保持调性的运营者。 + +## 你怎么推进 +1. 选题:给 3 个方向,每个说清「蹭的是什么热点 / 跟博昇业务什么关系 / 适合谁看」。 +2. 标题:一个方向给 3 个备选,标注哪个偏点击、哪个偏稳重。 +3. 提纲:三级以内,每节写出这节要论证的那一句话。 +4. 正文:按提纲展开,不要中途改结构。 + +## 输出要求 +- 观点要有依据,引用数据必须能指出来源。 +- 口语化但不油腻,不堆网络热词。 + +## 边界 +- 不编造案例、数据、用户评价。 +- 涉及公司政策、产品承诺的表述,只转述已有材料里的说法。`, + AllowedSkills: []string{"longform-writing", "copy-proofreading", "mind-map"}, + OwnedDefinitionKeys: []string{"specialist.wechat-official-account.definition"}, + OwnedCatalogEntries: []string{"catalog.specialist.wechat-official-account"}, + ReferencePolicy: specialistcontracts.SharedReferenceReadonlyHistory, +} diff --git a/eai_agentplatform/backend-go/internal/model/official_account_article.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/model/article.go similarity index 68% rename from eai_agentplatform/backend-go/internal/model/official_account_article.go rename to eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/model/article.go index 8284edf..c17ffd6 100644 --- a/eai_agentplatform/backend-go/internal/model/official_account_article.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/model/article.go @@ -1,13 +1,13 @@ -package model +package wechatofficialaccountmodel import "time" // OfficialAccountArticle 公众号文章状态表,对齐工作流中的主题/标题/提纲/正文/配图/预览/分页显式传递。 type OfficialAccountArticle struct { - ID uint `gorm:"primaryKey" json:"id"` + ID uint `gorm:"primaryKey" json:"id"` TaskID uint `gorm:"not null;uniqueIndex" json:"task_id"` SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"` - BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"` + BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"` Keyword string `gorm:"size:256;not null" json:"keyword"` Audience string `gorm:"size:128;not null" json:"audience"` Goal string `gorm:"size:128;not null" json:"goal"` @@ -23,15 +23,15 @@ type OfficialAccountArticle struct { OutlineWords int `gorm:"not null;default:600" json:"outline_words"` Outline string `gorm:"type:text" json:"outline"` ContentTargetWords int `gorm:"not null;default:1400" json:"content_target_words"` - MaxContentImages int `gorm:"not null;default:0" json:"max_content_images"` + MaxContentImages int `gorm:"not null;default:0" json:"max_content_images"` Content string `gorm:"type:text" json:"content"` - ContentWithPrompts string `gorm:"type:text" json:"content_with_prompts"` - ImagePromptsJSON string `gorm:"type:text" json:"image_prompts_json"` - ImageRefsJSON string `gorm:"type:text" json:"image_refs_json"` - PreviewMarkdown string `gorm:"type:text" json:"preview_markdown"` - PreviewHTML string `gorm:"type:text" json:"preview_html"` - PageMarkdown string `gorm:"type:text" json:"page_markdown"` - PagesJSON string `gorm:"type:text" json:"pages_json"` + ContentWithPrompts string `gorm:"type:text" json:"content_with_prompts"` + ImagePromptsJSON string `gorm:"type:text" json:"image_prompts_json"` + ImageRefsJSON string `gorm:"type:text" json:"image_refs_json"` + PreviewMarkdown string `gorm:"type:text" json:"preview_markdown"` + PreviewHTML string `gorm:"type:text" json:"preview_html"` + PageMarkdown string `gorm:"type:text" json:"page_markdown"` + PagesJSON string `gorm:"type:text" json:"pages_json"` Status string `gorm:"size:32;not null;default:draft;index" json:"status"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/model/hotspot.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/model/hotspot.go new file mode 100644 index 0000000..492c6d9 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/model/hotspot.go @@ -0,0 +1,25 @@ +package wechatofficialaccountmodel + +import "time" + +// OfficialAccountHotspot 业务域热点缓存表,承接 RSS/网页抓取结果及领域评分。 +type OfficialAccountHotspot struct { + ID uint `gorm:"primaryKey" json:"id"` + BusinessDomain string `gorm:"size:32;not null;default:ai;index" json:"business_domain"` + SourceKey string `gorm:"size:64;not null;index;uniqueIndex:idx_hotspot_source_url" json:"source_key"` + SourceLabel string `gorm:"size:128;not null" json:"source_label"` + SourceType string `gorm:"size:16;not null" json:"source_type"` + Title string `gorm:"size:512;not null" json:"title"` + URL string `gorm:"size:1024;not null;uniqueIndex:idx_hotspot_source_url" json:"url"` + Summary string `gorm:"type:text" json:"summary"` + PublishedAt *time.Time `json:"published_at"` + RawScore float64 `gorm:"not null;default:0" json:"raw_score"` + DomainScore float64 `gorm:"not null;default:0" json:"domain_score"` + HeatLabel string `gorm:"size:32;default:''" json:"heat_label"` + TagsJSON string `gorm:"type:text" json:"tags_json"` + FetchedAt time.Time `gorm:"index" json:"fetched_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (OfficialAccountHotspot) TableName() string { return "official_account_hotspot" } diff --git a/eai_agentplatform/backend-go/internal/store/specialist_records.go b/eai_agentplatform/backend-go/internal/specialists/runtime/records.go similarity index 64% rename from eai_agentplatform/backend-go/internal/store/specialist_records.go rename to eai_agentplatform/backend-go/internal/specialists/runtime/records.go index fc22c96..043dc31 100644 --- a/eai_agentplatform/backend-go/internal/store/specialist_records.go +++ b/eai_agentplatform/backend-go/internal/specialists/runtime/records.go @@ -1,37 +1,37 @@ -package store +package specialistruntime import ( - "encoding/json" "strings" - "eai_agentplatform/backend/internal/model" + "eai_agentplatform/backend/internal/jsonutil" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" ) -// EnsureSpecialistStructuredRecords 补齐四类结构化记录,供工作区 UI 直接消费。 -func EnsureSpecialistStructuredRecords(item *model.Specialist) { +// EnsureStructuredRecords 补齐四类结构化记录,供工作区 UI 直接消费。 +func EnsureStructuredRecords(item *specialistmodel.Specialist) { if item == nil { return } - EnsureSpecialistInteractionCard(item) + EnsureInteractionCard(item) if strings.TrimSpace(item.InputsRecordsJSON) == "" { - item.InputsRecordsJSON = mustJSON(buildInputsRecords(item)) + item.InputsRecordsJSON = jsonutil.MustJSON(buildInputsRecords(item)) } if strings.TrimSpace(item.PermissionRecordsJSON) == "" { - item.PermissionRecordsJSON = mustJSON(buildPermissionRecords(item)) + item.PermissionRecordsJSON = jsonutil.MustJSON(buildPermissionRecords(item)) } if strings.TrimSpace(item.ActionRecordsJSON) == "" { - item.ActionRecordsJSON = mustJSON(buildActionRecords(item)) + item.ActionRecordsJSON = jsonutil.MustJSON(buildActionRecords(item)) } if strings.TrimSpace(item.ResultRecordsJSON) == "" { - item.ResultRecordsJSON = mustJSON(buildResultRecords(item)) + item.ResultRecordsJSON = jsonutil.MustJSON(buildResultRecords(item)) } } -func EnsureSpecialistInteractionCard(item *model.Specialist) { +func EnsureInteractionCard(item *specialistmodel.Specialist) { if item == nil || strings.TrimSpace(item.InteractionCardJSON) != "" { return } - item.InteractionCardJSON = mustJSON(map[string]any{ + item.InteractionCardJSON = jsonutil.MustJSON(map[string]any{ "name": item.Label, "tagline": firstNonEmpty(item.Summary, item.WorkStatus, item.Label), "greeting": "我是" + item.Label + ",已经准备好接手这条任务并继续推进。", @@ -46,34 +46,7 @@ func EnsureSpecialistInteractionCard(item *model.Specialist) { }) } -func ValidateStructuredRecordsJSON(value string) bool { - value = strings.TrimSpace(value) - if value == "" { - return true - } - var arr []map[string]any - 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 { +func buildInputsRecords(item *specialistmodel.Specialist) []map[string]any { records := make([]map[string]any, 0) for _, value := range splitText(item.ConnectorScope) { records = append(records, map[string]any{ @@ -104,10 +77,10 @@ func buildInputsRecords(item *model.Specialist) []map[string]any { return records } -func buildPermissionRecords(item *model.Specialist) []map[string]any { +func buildPermissionRecords(item *specialistmodel.Specialist) []map[string]any { approvalReason := "关键动作需要人工确认" forbidden := []string{"自动发布", "批量删除", "跨系统迁移"} - if item.WorkerType == "adw" { + if item.SpecialistMode == "adw" { approvalReason = "中高风险动作进入审批或人工复核" forbidden = []string{"无审批自动发布", "无审批跨系统写回", "高风险批量改写"} } @@ -120,7 +93,7 @@ func buildPermissionRecords(item *model.Specialist) []map[string]any { "approval_role": []string{}, "approval_reason": "", "forbidden_actions": []string{}, - "delegation_allowed": item.WorkerType == "adw", + "delegation_allowed": item.SpecialistMode == "adw", }, { "title": "访问边界", @@ -130,7 +103,7 @@ func buildPermissionRecords(item *model.Specialist) []map[string]any { "approval_role": []string{}, "approval_reason": "", "forbidden_actions": []string{}, - "delegation_allowed": item.WorkerType == "adw", + "delegation_allowed": item.SpecialistMode == "adw", }, { "title": "审批要求", @@ -140,7 +113,7 @@ func buildPermissionRecords(item *model.Specialist) []map[string]any { "approval_role": []string{"管理员", "业务负责人"}, "approval_reason": approvalReason, "forbidden_actions": []string{}, - "delegation_allowed": item.WorkerType == "adw", + "delegation_allowed": item.SpecialistMode == "adw", }, { "title": "禁止动作", @@ -155,7 +128,7 @@ func buildPermissionRecords(item *model.Specialist) []map[string]any { } } -func buildActionRecords(item *model.Specialist) []map[string]any { +func buildActionRecords(item *specialistmodel.Specialist) []map[string]any { records := make([]map[string]any, 0) skills := splitText(item.BaseSkills) if len(skills) == 0 { @@ -167,13 +140,13 @@ func buildActionRecords(item *model.Specialist) []map[string]any { records = append(records, map[string]any{ "action_name": value, "action_type": inferActionType(value), - "trigger_mode": triggerMode(item.WorkerType), + "trigger_mode": triggerMode(item.SpecialistMode), "input_sources": infoSources, "expected_output": outputs, "risk_level": inferRiskLevel(value), "status": defaultActionStatus(i), - "approval_state": approvalState(item.WorkerType, value), - "operator": strings.ToUpper(firstNonEmpty(item.WorkerType, "dw")), + "approval_state": approvalState(item.SpecialistMode, value), + "operator": strings.ToUpper(firstNonEmpty(item.SpecialistMode, "dw")), "started_at": "", "finished_at": "", }) @@ -181,7 +154,7 @@ func buildActionRecords(item *model.Specialist) []map[string]any { return records } -func buildResultRecords(item *model.Specialist) []map[string]any { +func buildResultRecords(item *specialistmodel.Specialist) []map[string]any { results := make([]map[string]any, 0) derived := splitText(item.BaseSkills) for _, value := range splitText(item.GeneratedSkills) { @@ -215,7 +188,7 @@ func buildResultRecords(item *model.Specialist) []map[string]any { return results } -func defaultStarterPrompts(item *model.Specialist) []string { +func defaultStarterPrompts(item *specialistmodel.Specialist) []string { prompts := make([]string, 0, 3) for _, skill := range splitText(item.BaseSkills) { prompts = append(prompts, "帮我处理:"+skill) @@ -223,40 +196,87 @@ func defaultStarterPrompts(item *model.Specialist) []string { return prompts } } - if item.Summary != "" { - prompts = append(prompts, "继续推进这条事项") - } if len(prompts) == 0 { - prompts = []string{"先帮我梳理当前事项", "告诉我下一步怎么做", "先看有哪些待确认点"} + prompts = append(prompts, "帮我先梳理一下这件事") } return prompts } -func mustJSON(v any) string { - data, err := json.Marshal(v) - if err != nil { - return "[]" +func statusByMarketTag(tag string) string { + switch strings.TrimSpace(tag) { + case "可升级": + return "upgradable" + case "试用": + return "trial" + default: + return "ready" } - return string(data) +} + +func approvalState(mode string, action string) string { + if mode == "adw" && inferRiskLevel(action) != "low" { + return "pending" + } + return "ready" +} + +func defaultActionStatus(index int) string { + if index == 0 { + return "ready" + } + return "queued" +} + +func inferRiskLevel(value string) string { + value = strings.TrimSpace(value) + switch { + case strings.Contains(value, "发布"), strings.Contains(value, "审批"), strings.Contains(value, "同步"), strings.Contains(value, "回写"): + return "high" + case strings.Contains(value, "分析"), strings.Contains(value, "生成"), strings.Contains(value, "汇总"): + return "medium" + default: + return "low" + } +} + +func inferActionType(value string) string { + value = strings.TrimSpace(value) + switch { + case strings.Contains(value, "汇报"), strings.Contains(value, "报告"), strings.Contains(value, "生成"): + return "generate" + case strings.Contains(value, "分析"), strings.Contains(value, "抽取"), strings.Contains(value, "整理"): + return "analyze" + case strings.Contains(value, "催办"), strings.Contains(value, "推进"), strings.Contains(value, "协调"): + return "coordinate" + default: + return "execute" + } +} + +func triggerMode(mode string) string { + if strings.TrimSpace(mode) == "adw" { + return "delegated" + } + return "manual" } func splitText(value string) []string { - parts := strings.FieldsFunc(value, func(r rune) bool { + fields := strings.FieldsFunc(value, func(r rune) bool { switch r { - case '、', ',', ',', '/', '|', ';', ';', '\n': + case '\n', '\r', '\t', ',', ',', ';', ';', '、': return true default: return false } }) - items := make([]string, 0, len(parts)) - for _, item := range parts { - item = strings.TrimSpace(item) - if item != "" { - items = append(items, item) + out := make([]string, 0, len(fields)) + for _, field := range fields { + field = strings.TrimSpace(field) + if field != "" { + out = append(out, field) } } - return items + return out } func firstNonEmpty(values ...string) string { @@ -268,59 +288,3 @@ func firstNonEmpty(values ...string) string { } return "" } - -func inferActionType(value string) string { - switch { - case strings.Contains(value, "生成"), strings.Contains(value, "输出"), strings.Contains(value, "编排"): - return "generation" - case strings.Contains(value, "抽取"), strings.Contains(value, "识别"), strings.Contains(value, "比对"): - return "analysis" - case strings.Contains(value, "催办"), strings.Contains(value, "跟进"), strings.Contains(value, "升级"): - return "coordination" - default: - return "execution" - } -} - -func triggerMode(workerType string) string { - if workerType == "adw" { - return "auto_assisted" - } - return "manual_assisted" -} - -func inferRiskLevel(value string) string { - switch { - case strings.Contains(value, "升级"), strings.Contains(value, "写回"), strings.Contains(value, "审批"): - return "medium" - case strings.Contains(value, "删除"), strings.Contains(value, "发布"): - return "high" - default: - return "low" - } -} - -func defaultActionStatus(index int) string { - if index == 0 { - return "ready" - } - return "pending" -} - -func approvalState(workerType, value string) string { - if workerType == "adw" && (strings.Contains(value, "升级") || strings.Contains(value, "审批") || strings.Contains(value, "写回")) { - return "approval_required" - } - return "not_required" -} - -func statusByMarketTag(tag string) string { - switch tag { - case "可升级": - return "ready_for_upgrade" - case "试用": - return "trial" - default: - return "active" - } -} diff --git a/eai_agentplatform/backend-go/internal/specialists/runtime/task_runtime.go b/eai_agentplatform/backend-go/internal/specialists/runtime/task_runtime.go new file mode 100644 index 0000000..1e42f4d --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/runtime/task_runtime.go @@ -0,0 +1,689 @@ +package specialistruntime + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + + "eai_agentplatform/backend/internal/ai" + "eai_agentplatform/backend/internal/config" + connectorcontracts "eai_agentplatform/backend/internal/connectors/core/contracts" + connectorregistry "eai_agentplatform/backend/internal/connectors/registry" + "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + "eai_agentplatform/backend/internal/store" +) + +// Cfg 全局配置,由 internal/api.RegisterRoutes 注入。 +var Cfg *config.Config + +// TaskReq 任务请求体 +type TaskReq struct { + SpecialistKey string `json:"specialist_key"` + Title string `json:"title"` + Summary string `json:"summary"` + Owner string `json:"owner"` + Priority string `json:"priority"` + 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"` +} + +// TaskActionReq 任务动作请求体 +type TaskActionReq struct { + ActionKey string `json:"action_key"` + ActionTitle string `json:"action_title"` + ActionType string `json:"action_type"` + ActionDetail string `json:"action_detail"` + Inputs []string `json:"inputs"` + Outputs []string `json:"outputs"` + Approval string `json:"approval"` +} + +// TaskStatusReq 任务状态请求体 +type TaskStatusReq struct { + Status string `json:"status"` +} + +// ArtifactStatusReq 交付物状态请求体 +type ArtifactStatusReq struct { + Status string `json:"status"` + Remark string `json:"remark"` +} + +// TaskRunOutput 任务动作执行输出 +type TaskRunOutput struct { + Summary string `json:"summary"` + TaskStatus string `json:"task_status"` + ArtifactTitle string `json:"artifact_title"` + ArtifactType string `json:"artifact_type"` + ArtifactStatus string `json:"artifact_status"` + ArtifactContent string `json:"artifact_content"` + SourceRefs []string `json:"source_refs"` + Timeline []string `json:"timeline"` + Trace []string `json:"trace"` + Logs []string `json:"logs"` +} + +// TaskExecutionStrategy 任务动作执行策略 +type TaskExecutionStrategy struct { + UseAI bool + ConnectorKey string +} + +const ( + TaskStatusPending = "待处理" + TaskStatusInProgress = "进行中" + TaskStatusDraft = "草案已生成" + TaskStatusPendingReview = "待确认" + TaskStatusCompleted = "已完成" + TaskStatusArchived = "已归档" + + ArtifactStatusDraft = "draft" + ArtifactStatusReady = "ready" + ArtifactStatusApproved = "approved" + ArtifactStatusPublished = "published" +) + +// MyTaskOwners 「这条任务算谁的」归属判定。只写 FullName 的话没填全名的用户会跟别人混在一起。 +func MyTaskOwners(user *model.User) []string { + if user == nil { + return nil + } + owners := make([]string, 0, 2) + if name := strings.TrimSpace(user.FullName); name != "" { + owners = append(owners, name) + } + if name := strings.TrimSpace(user.Username); name != "" { + owners = append(owners, name) + } + return owners +} + +// MyTaskQuery 「我的任务」永远是"我自己的",管理员也只看自己的。 +func MyTaskQuery(user *model.User) *gorm.DB { + owners := MyTaskOwners(user) + query := store.DB.Model(&model.TaskRecord{}) + if len(owners) == 0 { + // 没有可用的归属标识,返回空集而不是全表 + return query.Where("1 = 0") + } + return query.Where("owner IN ?", owners) +} + +// ProjectQuery 「我的项目」永远只列自己的,归属判定复用 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) +} + +// BuildTaskFromReq 由请求体构建任务记录。 +func BuildTaskFromReq(req TaskReq, user *model.User) (model.TaskRecord, error) { + task := model.TaskRecord{ + SpecialistKey: strings.TrimSpace(req.SpecialistKey), + Title: strings.TrimSpace(req.Title), + Summary: strings.TrimSpace(req.Summary), + Owner: strings.TrimSpace(req.Owner), + Priority: strings.TrimSpace(req.Priority), + Status: NormalizeTaskStatus(req.Status), + } + if task.Owner == "" { + task.Owner = BootstrapOwner(user) + } + if task.Priority == "" { + task.Priority = "P2" + } + if task.Status == "" { + task.Status = TaskStatusPending + } + if len(req.Context) > 0 { + b, err := json.Marshal(req.Context) + if err != nil { + return task, fmt.Errorf("context 序列化失败") + } + task.ContextJSON = string(b) + } + if strings.TrimSpace(req.DueAt) != "" { + parsed, err := parseTaskDueAt(req.DueAt) + if err != nil { + return task, fmt.Errorf("due_at 格式错误,支持 RFC3339 或 2006-01-02 15:04") + } + task.DueAt = &parsed + } + // 挂项目:得确认这个项目是自己的,否则等于给别人的项目里塞任务。 + 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 + } + return task, nil +} + +func parseTaskDueAt(value string) (time.Time, error) { + value = strings.TrimSpace(value) + layouts := []string{time.RFC3339, "2006-01-02 15:04", "2006-01-02"} + var lastErr error + for _, layout := range layouts { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed, nil + } + lastErr = err + } + return time.Time{}, lastErr +} + +// BuildTaskRunOutput 构建任务动作执行的完整输出。 +func BuildTaskRunOutput(task model.TaskRecord, specialist specialistmodel.Specialist, req TaskActionReq, user *model.User, now time.Time) TaskRunOutput { + status := TaskStatusInProgress + artifactType := "text" + artifactStatus := ArtifactStatusDraft + artifactTitle := fmt.Sprintf("%s - %s", task.Title, req.ActionTitle) + summary := fmt.Sprintf("%s已执行,事项已更新。", req.ActionTitle) + + switch { + case containsAny(req.ActionTitle, "生成", "汇总", "草案", "方案"): + status = TaskStatusDraft + artifactType = "draft" + artifactStatus = ArtifactStatusDraft + summary = fmt.Sprintf("已生成《%s》草案,等待人工确认。", task.Title) + case containsAny(req.ActionTitle, "审批", "确认", "评审", "提交"): + status = TaskStatusPendingReview + artifactType = "approval_note" + artifactStatus = ArtifactStatusReady + summary = fmt.Sprintf("已提交《%s》的确认请求。", task.Title) + case containsAny(req.ActionTitle, "发布", "归档", "同步"): + status = TaskStatusArchived + artifactType = "delivery" + artifactStatus = ArtifactStatusPublished + summary = fmt.Sprintf("《%s》已进入发布/归档阶段。", task.Title) + } + + strategy := ChooseTaskStrategy(specialist, req) + connectorSummary, connectorSourceRefs, connectorTrace, connectorLogs := executeConnectorStep(specialist, task, req, strategy.ConnectorKey) + aiSummary, aiTrace, aiLogs := executeAIStep(task, specialist, req, user, strategy.UseAI) + + content := fmt.Sprintf( + "%s\n\n事项:%s\n当前摘要:%s\n动作说明:%s\n输入:%s\n预期输出:%s\n连接器结果:%s\nAI结果:%s\n执行时间:%s\n", + summary, + task.Title, + task.Summary, + fallbackText(req.ActionDetail, "根据当前事项上下文生成最小交付物。"), + joinTaskList(req.Inputs, "当前事项上下文"), + joinTaskList(req.Outputs, "交付草案"), + fallbackText(connectorSummary, "未触发"), + fallbackText(aiSummary, "未触发"), + now.Format("2006-01-02 15:04"), + ) + if aiSummary != "" { + summary = aiSummary + } + if connectorSummary != "" && !strings.Contains(summary, connectorSummary) { + summary = summary + " " + connectorSummary + } + + return TaskRunOutput{ + Summary: summary, + TaskStatus: status, + ArtifactTitle: artifactTitle, + ArtifactType: artifactType, + ArtifactStatus: artifactStatus, + ArtifactContent: content, + SourceRefs: append(filterNonEmptyParts(append([]string{}, req.Inputs...)), connectorSourceRefs...), + Timeline: []string{ + fmt.Sprintf("%s 读取事项上下文", now.Format("15:04")), + fmt.Sprintf("%s 执行动作:%s", now.Format("15:04"), req.ActionTitle), + fmt.Sprintf("%s 事项状态更新为:%s", now.Format("15:04"), status), + }, + Trace: append([]string{ + fmt.Sprintf("task.load(%d)", task.ID), + fmt.Sprintf("task.action.execute(%s)", req.ActionKey), + fmt.Sprintf("task.artifact.create(%s)", artifactType), + }, append(connectorTrace, aiTrace...)...), + Logs: append([]string{ + fmt.Sprintf("[INFO] task %d loaded", task.ID), + fmt.Sprintf("[INFO] action %s executed", req.ActionKey), + fmt.Sprintf("[INFO] task status updated to %s", status), + }, append(connectorLogs, aiLogs...)...), + } +} + +// ChooseTaskStrategy 根据动作选择执行策略。 +func ChooseTaskStrategy(specialist specialistmodel.Specialist, req TaskActionReq) TaskExecutionStrategy { + strategy := TaskExecutionStrategy{} + actionType := strings.ToLower(strings.TrimSpace(req.ActionType)) + if actionType == "generation" || actionType == "analysis" || containsAny(req.ActionTitle, "生成", "汇总", "分析", "对比", "方案", "草案", "总结") { + strategy.UseAI = true + } + strategy.ConnectorKey = selectTaskConnector(specialist, req) + if actionType == "coordination" && strategy.ConnectorKey == "" { + strategy.UseAI = true + } + return strategy +} + +func executeConnectorStep(specialist specialistmodel.Specialist, task model.TaskRecord, req TaskActionReq, connectorKey string) (string, []string, []string, []string) { + if connectorKey == "" || Cfg == nil { + return "", nil, nil, nil + } + definition, ok := connectorregistry.GetDefinition(Cfg, connectorKey) + if !ok || definition.Direction != "input" || len(definition.Objects) == 0 { + return "", nil, nil, nil + } + object := definition.Objects[0] + fieldKeys := object.DefaultFields + if len(fieldKeys) > 5 { + fieldKeys = fieldKeys[:5] + } + result, err := connectorregistry.Query(context.Background(), Cfg, connectorKey, connectorcontracts.QueryRequest{ + ObjectKey: object.Key, + FieldKeys: fieldKeys, + FilterString: task.Title, + Limit: 3, + UseDemo: true, + }) + if err != nil { + return "", nil, + []string{fmt.Sprintf("connector.query(%s,%s)", connectorKey, object.Key)}, + []string{fmt.Sprintf("[WARN] connector %s query failed: %v", connectorKey, err)} + } + count, _ := result.Outputs["count"].(int) + recordsJSON, _ := json.Marshal(result.Outputs["records"]) + summary := fmt.Sprintf("已从%s读取 %d 条%s数据。", definition.Label, count, object.Label) + sourceRef := fmt.Sprintf("%s / %s", definition.Label, object.Label) + if len(recordsJSON) > 0 { + summary = summary + " 样例:" + truncateTaskText(string(recordsJSON), 120) + } + return summary, + []string{sourceRef}, + []string{fmt.Sprintf("connector.query(%s,%s)", connectorKey, object.Key)}, + []string{fmt.Sprintf("[INFO] connector %s returned %d records", connectorKey, count)} +} + +func executeAIStep(task model.TaskRecord, specialist specialistmodel.Specialist, req TaskActionReq, user *model.User, enabled bool) (string, []string, []string) { + if !enabled || user == nil || Cfg == nil { + return "", nil, nil + } + if user.AiPoints <= 0 { + return "AI 点数不足,未触发智能生成。", nil, []string{"[WARN] ai execution skipped because credits are insufficient"} + } + aiRoute, err := config.GetRoute("title_gen") + if err != nil { + return "LLM 路由未配置,未触发智能生成。", nil, []string{fmt.Sprintf("[WARN] ai route missing: %v", err)} + } + contextJSON := ParseTaskContextJSON(task.ContextJSON) + knowledge := ai.Retrieve(Cfg, task.Title+" "+task.Summary+" "+specialist.InfoSources, 5) + systemPrompt := BuildSystemPrompt(contextJSON, knowledge) + "\n\n当前任务:" + buildTaskAIPrompt(task, specialist, req) + aiMessages := []ai.Message{ + {Role: "system", Content: systemPrompt}, + {Role: "user", Content: buildTaskUserPrompt(task, req)}, + } + + start := time.Now() + result, usedAiRoute, err := ai.GenerateFullWithFallback(aiRoute, aiMessages) + if err != nil { + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + UsageKind: ai.UsageKindTextGen, + Provider: aiRoute.Provider, + AIRouteID: aiRoute.RouteID, + Model: aiRoute.Model, + Success: false, + ErrorMessage: err.Error(), + LatencyMs: int(time.Since(start).Milliseconds()), + }) + return "AI 生成失败,已回退到规则结果。", []string{"ai.generate(task_action)"}, []string{fmt.Sprintf("[WARN] ai generation failed: %v", err)} + } + ai.LogCall(ai.LogEntry{ + UserID: user.ID, + UsageKind: ai.UsageKindTextGen, + 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()), + }) + return truncateTaskText(strings.TrimSpace(result.Content), 240), + []string{"ai.generate(task_action)"}, + []string{"[INFO] ai generation completed"} +} + +// AppendStringSlice 把一组字符串追加到已有的泛型切片上。 +func AppendStringSlice(current any, values []string) []string { + base, _ := current.([]string) + return append(base, values...) +} + +// DefaultTaskTitle 默认事项标题。 +func DefaultTaskTitle(specialist specialistmodel.Specialist) string { + if strings.TrimSpace(specialist.Summary) != "" { + return strings.TrimSpace(specialist.Summary) + } + return specialist.Label + "默认事项" +} + +// DefaultTaskSummary 默认事项摘要。 +func DefaultTaskSummary(specialist specialistmodel.Specialist) string { + parts := []string{strings.TrimSpace(specialist.Summary), strings.TrimSpace(specialist.InfoSources), strings.TrimSpace(specialist.GeneratedSkills)} + joined := strings.Join(filterNonEmptyParts(parts), " · ") + if joined != "" { + return joined + } + return "基于当前专员定义生成的默认事项。" +} + +// DefaultTaskStatus 默认事项状态。 +func DefaultTaskStatus(specialist specialistmodel.Specialist) string { + for _, value := range []string{specialist.Stage, specialist.WorkStatus} { + value = NormalizeTaskStatus(value) + if value != "" { + return value + } + } + return TaskStatusPending +} + +// NormalizeTaskActionKey 规范化动作 key。 +func NormalizeTaskActionKey(key, title string) string { + key = strings.TrimSpace(key) + if key != "" { + return key + } + title = strings.TrimSpace(title) + replacer := strings.NewReplacer(" ", "-", "/", "-", "\\", "-", "·", "-", "_", "-") + title = replacer.Replace(title) + title = strings.ToLower(title) + title = strings.ReplaceAll(title, "--", "-") + if title == "" { + return "action" + } + return title +} + +// BootstrapOwner 初始化任务归属人。 +func BootstrapOwner(user *model.User) string { + if user != nil && strings.TrimSpace(user.FullName) != "" { + return strings.TrimSpace(user.FullName) + } + return "待指派" +} + +func joinTaskList(items []string, fallback string) string { + trimmed := filterNonEmptyParts(items) + if len(trimmed) == 0 { + return fallback + } + return strings.Join(trimmed, " / ") +} + +func filterNonEmptyParts(items []string) []string { + result := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item != "" { + result = append(result, item) + } + } + return result +} + +func fallbackText(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +func containsAny(value string, items ...string) bool { + value = strings.TrimSpace(value) + for _, item := range items { + if strings.Contains(value, item) { + return true + } + } + return false +} + +func selectTaskConnector(specialist specialistmodel.Specialist, req TaskActionReq) string { + type inputRecord struct { + InputType string `json:"input_type"` + ConnectorName string `json:"connector_name"` + } + var records []inputRecord + if err := json.Unmarshal([]byte(firstNonEmptyJSON(specialist.InputsRecordsJSON, "[]")), &records); err == nil { + for _, item := range records { + key := normalizeConnectorKey(item.ConnectorName) + if item.InputType == "connector" && key != "" && key != "workspace_context" { + if _, ok := connectorregistry.GetDefinition(Cfg, key); ok { + return key + } + } + } + } + + searchText := strings.Join([]string{ + specialist.ConnectorScope, + specialist.InfoSources, + req.ActionTitle, + req.ActionDetail, + joinTaskList(req.Inputs, ""), + }, " ") + aliases := map[string]string{ + "邮件": "mail163_input", + "邮箱": "mail163_input", + "履约": "wecom_sheet_input", + "表格": "feishu_bitable_input", + "表单": "feishu_bitable_input", + "仓": "wms_input", + "库存": "wms_input", + "工单": "mes_input", + "产线": "mes_input", + "生产": "mes_input", + } + for keyword, key := range aliases { + if strings.Contains(searchText, keyword) { + return key + } + } + return "" +} + +// ParseTaskContextJSON 解析任务上下文 JSON。 +func ParseTaskContextJSON(value string) map[string]any { + if strings.TrimSpace(value) == "" { + return map[string]any{} + } + var out map[string]any + if err := json.Unmarshal([]byte(value), &out); err != nil { + return map[string]any{} + } + return out +} + +func buildTaskAIPrompt(task model.TaskRecord, specialist specialistmodel.Specialist, req TaskActionReq) string { + return strings.TrimSpace(fmt.Sprintf( + "你正在为数字员工平台执行专员动作。\n专员:%s\n事项:%s\n事项摘要:%s\n动作:%s\n动作类型:%s\n预期输出:%s\n请输出一段可以直接作为事项交付草案的中文结果,要求简洁、业务化、避免空话。", + specialist.Label, + task.Title, + task.Summary, + req.ActionTitle, + req.ActionType, + joinTaskList(req.Outputs, "交付草案"), + )) +} + +func buildTaskUserPrompt(task model.TaskRecord, req TaskActionReq) string { + return strings.TrimSpace(fmt.Sprintf( + "请基于当前事项上下文执行动作。\n事项标题:%s\n当前状态:%s\n动作说明:%s\n输入信源:%s\n预期输出:%s", + task.Title, + task.Status, + fallbackText(req.ActionDetail, "根据当前事项生成结果"), + joinTaskList(req.Inputs, "当前事项上下文"), + joinTaskList(req.Outputs, "交付草案"), + )) +} + +func truncateTaskText(value string, max int) string { + if max <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= max { + return value + } + return string(runes[:max]) + "..." +} + +func normalizeConnectorKey(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "" + } + replacer := strings.NewReplacer(" ", "_", "-", "_", "/", "_", "\\", "_") + return replacer.Replace(value) +} + +func firstNonEmptyJSON(value, fallback string) string { + value = strings.TrimSpace(value) + if value == "" { + return fallback + } + return value +} + +// NormalizeTaskStatus 规范化任务状态。 +func NormalizeTaskStatus(value string) string { + value = strings.TrimSpace(value) + switch { + case value == "", strings.Contains(value, "待处理"), strings.Contains(value, "待开始"): + return TaskStatusPending + case strings.Contains(value, "进行"), strings.Contains(value, "执行"), strings.Contains(value, "处理中"): + return TaskStatusInProgress + case strings.Contains(value, "草案"), strings.Contains(value, "方案生成"), strings.Contains(value, "人工复核"): + return TaskStatusDraft + case strings.Contains(value, "确认"), strings.Contains(value, "审批"), strings.Contains(value, "评审"): + return TaskStatusPendingReview + case strings.Contains(value, "完成"): + return TaskStatusCompleted + case strings.Contains(value, "归档"), strings.Contains(value, "发布"): + return TaskStatusArchived + default: + return value + } +} + +// IsAllowedTaskStatus 判断任务状态是否合法。 +func IsAllowedTaskStatus(value string) bool { + switch value { + case TaskStatusPending, TaskStatusInProgress, TaskStatusDraft, TaskStatusPendingReview, TaskStatusCompleted, TaskStatusArchived: + return true + default: + return false + } +} + +// NormalizeArtifactStatus 规范化交付物状态。 +func NormalizeArtifactStatus(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + switch value { + case ArtifactStatusDraft, ArtifactStatusReady, ArtifactStatusApproved, ArtifactStatusPublished: + return value + case "pending_review": + return ArtifactStatusReady + case "done": + return ArtifactStatusApproved + default: + return value + } +} + +// CanTransitionArtifactStatus 判断交付物状态是否允许流转。 +func CanTransitionArtifactStatus(current, next string) bool { + current = NormalizeArtifactStatus(current) + next = NormalizeArtifactStatus(next) + if current == next { + return true + } + switch current { + case ArtifactStatusDraft: + return next == ArtifactStatusReady + case ArtifactStatusReady: + return next == ArtifactStatusApproved || next == ArtifactStatusDraft + case ArtifactStatusApproved: + return next == ArtifactStatusPublished || next == ArtifactStatusDraft + case ArtifactStatusPublished: + return false + default: + return false + } +} + +// TaskStatusFromArtifactStatus 由交付物状态推导任务状态。 +func TaskStatusFromArtifactStatus(status string) string { + switch NormalizeArtifactStatus(status) { + case ArtifactStatusDraft: + return TaskStatusDraft + case ArtifactStatusReady: + return TaskStatusPendingReview + case ArtifactStatusApproved: + return TaskStatusCompleted + case ArtifactStatusPublished: + return TaskStatusArchived + default: + return TaskStatusInProgress + } +} + +// BuildArtifactStatusSummary 构建交付物状态更新后的摘要。 +func BuildArtifactStatusSummary(artifact model.TaskArtifact, remark string) string { + base := fmt.Sprintf("交付物《%s》状态已更新为%s。", artifact.Title, TaskStatusFromArtifactStatus(artifact.Status)) + if remark != "" { + return base + " 备注:" + remark + } + return base +} + +// BuildSystemPrompt 构建 AI 助手的系统提示词。 +func BuildSystemPrompt(ctx map[string]any, knowledge []string) string { + ctxJSON, _ := json.Marshal(ctx) + kc := strings.Join(knowledge, "\n\n") + return fmt.Sprintf(`你是 EAI 数字员工平台的 AI 助手。 + +职责: +1. 解答公司介绍、产品知识、佣金规则、销售话术、业务规则相关问题 +2. 严格依赖已审批知识库内容回答 +3. 知识库未找到相关资料时,明确回答「未找到相关资料」,不得臆测 + +禁止: +1. 禁止闲聊 +2. 禁止编造数据 +3. 禁止回答超出业务范围的问题 +4. 禁止泄露敏感信息 + +当前页面上下文: +%s + +知识库相关片段: +%s`, string(ctxJSON), kc) +} diff --git a/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go b/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go new file mode 100644 index 0000000..ae11ba6 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go @@ -0,0 +1,399 @@ +package specialistseeding + +import ( + "log" + "strings" + + "gorm.io/gorm" + + specialistcore "eai_agentplatform/backend/internal/specialists/core" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" + "eai_agentplatform/backend/internal/store" +) + +// SeedSpecialists 插入内置专员目录(幂等,可重复执行)。 +// +// 专员域对象逻辑统一落在 specialists 目录内:岗位说明书挂载(ApplyRuleFiles)、 +// 结构化记录补齐(EnsureStructuredRecords)、覆盖度日志(LogRuleFileCoverage) +// 都在这层编排。store 是纯数据层,低层不再反向感知专员域——由上层 +// (cmd/server)在 store.Init / store.SeedDefaults 之后显式调用本函数并传入 store.DB。 +func SeedSpecialists(db *gorm.DB) error { + items := applySpecialistCodes([]specialistmodel.Specialist{ + { + // 「新建任务」在还没选专员/工具之前,任务先落在这里。 + // state = system:它不是一个能装到工作区的专员,只是任务的默认归属, + // 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。 + Key: "general-assistant", + Label: "通用助手", + Tier: "generic", + SpecialistMode: "dw", + ObjectEntryRoute: "/home", + Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具", + Stage: "待处理", + Color: "#409eff", + MarketTag: "内置", + Version: "v1.0", + BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续", + State: "system", + SortOrder: 0, + }, + { + Key: "training-delivery", + Label: "培训交付专员", + Tier: "generic", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/training-delivery", + Summary: "新人销售训练营第 3 周", + WorkStatus: "5 个进行中", + RiskLabel: "0 个异常", + Color: "#67c23a", + Stage: "交付归档", + Progress: 84, + MarketTag: "已安装", + Version: "v1.1", + ConnectorScope: "连接课程系统 / 知识库 / 考试模块", + PermissionScope: "班级资料读取、考试结果读取、交付包生成", + ResourceBindings: "课程系统、考试模块、知识库、交付模板中心", + InfoSources: "班级排期、学员成绩、互动记录、教练点评", + BaseSkills: "班级日报、考试预警、复盘归档、交付清单生成", + AIAssistance: "自动总结班级状态并给出补练建议", + GeneratedSkills: "基于优秀班级复盘沉淀新训练营模板", + State: "active", + SortOrder: 10, + }, + { + Key: "knowledge-operations", + Label: "知识运营专员", + Tier: "generic", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/knowledge-operations", + Summary: "对象字典、证据索引与规则同步", + WorkStatus: "4 个待整理", + RiskLabel: "1 个映射待确认", + Color: "#8e6cf2", + Stage: "证据整理", + Progress: 63, + MarketTag: "已安装", + Version: "v1.0", + ConnectorScope: "连接知识资产 / 文档系统", + PermissionScope: "知识资产读取、规则发布建议、证据索引维护", + ResourceBindings: "知识库、文档系统、规则字典、对象映射表", + InfoSources: "对象字典、规则文档、证据片段、变更记录", + BaseSkills: "对象映射、证据抽取、规则同步、发布检查", + AIAssistance: "自动识别字段冲突并给出映射建议", + GeneratedSkills: "把高频清洗与映射动作沉淀为新规则技能", + State: "active", + SortOrder: 20, + }, + { + Key: "process-coordination", + Label: "流程推进专员", + Tier: "generic", + SpecialistMode: "adw", + ObjectEntryRoute: "/apps/process-coordination", + Summary: "跨部门催办、checkpoint 跟进与阻塞升级", + WorkStatus: "6 个待跟进", + RiskLabel: "2 个阻塞", + Color: "#14b8a6", + Stage: "阻塞清理", + Progress: 57, + MarketTag: "可升级", + Version: "v1.0", + ConnectorScope: "连接审批流 / 企微 / 项目事项", + PermissionScope: "事项读写、催办通知、阻塞升级、审批触发", + ResourceBindings: "审批流、项目事项、企微、日程提醒", + InfoSources: "任务看板、流程 checkpoint、阻塞日志、责任人状态", + BaseSkills: "催办升级、checkpoint 跟进、阻塞清理、责任路由", + AIAssistance: "自动判断升级路径并规划下一步协同动作", + GeneratedSkills: "把成功的跨部门推进套路沉淀为自动推进技能", + State: "active", + SortOrder: 30, + }, + { + Key: "report-generation", + Label: "报告生成专员", + Tier: "generic", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/report-generation", + Summary: "日报、周报、复盘和交付说明自动生成", + WorkStatus: "3 个待输出", + RiskLabel: "0 个异常", + Color: "#ec4899", + Stage: "报告汇总", + Progress: 76, + MarketTag: "可升级", + Version: "v1.0", + ConnectorScope: "连接交付物 / 知识库 / 模板中心", + PermissionScope: "交付物读取、模板套用、报告产出", + ResourceBindings: "交付模板、知识库、任务结果、证据引用", + InfoSources: "日报数据、周进度、复盘纪要、交付清单", + BaseSkills: "日报生成、周报汇总、复盘摘要、交付说明编排", + AIAssistance: "自动抽取重点变化并生成适配受众的版本", + GeneratedSkills: "把高质量报告结构沉淀为新模板技能", + State: "active", + SortOrder: 40, + }, + { + Key: "wechat-official-account", + Label: "公众号助手", + Tier: "generic", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/wechat-official-account", + Summary: "热点选题、标题、提纲、正文四步创作工作流", + WorkStatus: "4 个节点可执行", + RiskLabel: "0 个异常", + Color: "#2563eb", + Stage: "正文创作", + Progress: 0, + MarketTag: "已安装", + Version: "v1.0", + ConnectorScope: "连接知识库 / 公众号工作区上下文", + PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认", + ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿", + InfoSources: "选题关键词、目标受众、文章目标、风格要求", + BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作", + AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出", + GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿", + State: "active", + SortOrder: 45, + }, + { + Key: "contract-review", + Label: "合同审查专员", + Tier: "industry", + SpecialistMode: "adw", + ObjectEntryRoute: "/apps/contract-review", + Summary: "华东客户主协议修订", + WorkStatus: "2 个待确认", + RiskLabel: "1 个高风险", + Color: "#f56c6c", + Stage: "人工复核", + Progress: 72, + MarketTag: "已安装", + Version: "v1.4", + ConnectorScope: "连接 DMS / OA / 合同模板库", + PermissionScope: "合同文本读取、红线建议、审批流提交、例外升级", + ResourceBindings: "DMS、OA、模板库、法务规则库", + InfoSources: "合同正文、历史条款、谈判纪要、法务规则", + BaseSkills: "条款抽取、风险比对、红线生成、例外说明", + AIAssistance: "自动分析条款冲突并规划复核重点", + GeneratedSkills: "从人工复核结果中提炼新条款规则与红线模式", + State: "active", + SortOrder: 50, + }, + { + Key: "solution-proposal", + Label: "售前方案专员", + Tier: "industry", + SpecialistMode: "adw", + ObjectEntryRoute: "/apps/solution-proposal", + Summary: "A 客户智能培训升级方案", + WorkStatus: "3 个待办", + RiskLabel: "1 个依赖阻塞", + Color: "#409eff", + Stage: "方案生成", + Progress: 58, + MarketTag: "可升级", + Version: "v1.2", + ConnectorScope: "连接 CRM / OA / 知识库", + PermissionScope: "商机读取、方案草案生成、评审流发起、范围确认", + ResourceBindings: "CRM、OA、知识库、方案模板中心", + InfoSources: "商机信息、调研纪要、行业方案、交付案例", + BaseSkills: "需求澄清、方案草案、连接器范围规划、风险边界说明", + AIAssistance: "自动组合客户上下文并生成多版本方案草案", + GeneratedSkills: "把高转化方案结构沉淀为可复用售前技能", + State: "active", + SortOrder: 60, + }, + { + Key: "logistics-fulfillment", + Label: "履约跟单专员", + Tier: "industry", + SpecialistMode: "adw", + ObjectEntryRoute: "/apps/logistics-fulfillment", + Summary: "美西航线本周履约看板", + WorkStatus: "1 个异常", + RiskLabel: "2 个节点延迟", + Color: "#e6a23c", + Stage: "异常处置", + Progress: 49, + MarketTag: "试用", + Version: "v0.9", + ConnectorScope: "连接船司 / 邮件 / 企微", + PermissionScope: "节点读取、异常通知、履约状态写回、升级协同", + ResourceBindings: "船司系统、邮件、企微、履约看板", + InfoSources: "节点状态、异常邮件、订舱资料、客户承诺时间", + BaseSkills: "节点跟踪、异常识别、催办升级、状态回写", + AIAssistance: "自动判断异常影响范围并生成处置建议", + GeneratedSkills: "把高频异常处理流程沉淀为自动履约技能", + State: "active", + SortOrder: 70, + }, + { + Key: "hr-email-sorter", + Label: "HR 邮件整理专员", + Tier: "industry", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/hr-email-sorter", + Summary: "识别并整理招聘邮箱中的简历邮件", + WorkStatus: "3 封待处理", + RiskLabel: "0 个异常", + Color: "#409eff", + Stage: "邮件处理", + Progress: 0, + MarketTag: "试用", + Version: "v1.0", + ConnectorScope: "连接 HR 招聘邮箱 / 邮件系统", + PermissionScope: "招聘邮箱读取、候选人表写入", + ResourceBindings: "HR 招聘邮箱、候选人管理表", + InfoSources: "HR 招聘邮箱收件箱", + BaseSkills: "简历邮件识别、候选人信息抽取、去重归类", + AIAssistance: "自动识别简历邮件并抽取候选人字段", + GeneratedSkills: "从历史招聘中沉淀简历筛选规则", + State: "active", + SortOrder: 80, + }, + { + Key: "resume-processor", + Label: "简历处理专员", + Tier: "industry", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/resume-processor", + Summary: "筛选候选人并安排面试", + WorkStatus: "5 份待筛选", + RiskLabel: "1 个匹配待确认", + Color: "#67c23a", + Stage: "简历筛选", + Progress: 0, + MarketTag: "试用", + Version: "v1.0", + ConnectorScope: "连接候选人表 / 岗位需求表", + PermissionScope: "候选人表读写、面试安排表写入、岗位需求表读取", + ResourceBindings: "候选人表、岗位需求表、面试安排表", + InfoSources: "候选人表、岗位需求表", + BaseSkills: "简历筛选、评分排序、跟进标记、面试安排", + AIAssistance: "自动匹配岗位要求并给出排序建议", + GeneratedSkills: "从录用决策中沉淀筛选模型", + State: "active", + SortOrder: 90, + }, + { + Key: "presentation-briefing", + Label: "汇报专员", + Tier: "generic", + SpecialistMode: "dw", + ObjectEntryRoute: "/apps/presentation-briefing", + Summary: "经营汇报、方案汇报与培训课件统一出稿", + WorkStatus: "2 个待成稿", + RiskLabel: "1 个页数待压缩", + Color: "#4f46e5", + Stage: "演示编排", + Progress: 68, + MarketTag: "已安装", + Version: "v1.0", + ConnectorScope: "连接任务结果 / 知识库 / 模板中心", + PermissionScope: "读取材料、生成演示稿、导出讲稿备注", + ResourceBindings: "演示模板、知识库、任务产物、汇报素材", + InfoSources: "主题目标、受众、汇报材料、页数限制", + BaseSkills: "演示结构规划、页面要点提炼、讲稿备注生成、口径统一", + AIAssistance: "自动压缩材料并给出适合管理层、客户或培训场景的页面结构", + GeneratedSkills: "把高质量汇报结构沉淀为可复用的演示模板", + State: "active", + SortOrder: 100, + }, + }) + // 岗位说明书与技能绑定按 key 挂载(见 specialists/core.RuleFileDrafts / SkillBindings) + if err := specialistcore.ApplyRuleFiles(items); err != nil { + return err + } + + for _, item := range items { + specialistruntime.EnsureStructuredRecords(&item) + var existing specialistmodel.Specialist + if err := db.Where("key = ?", item.Key).First(&existing).Error; err != nil { + if err := db.Create(&item).Error; err != nil { + return err + } + continue + } + + updates := map[string]any{} + if existing.DisplayCode == "" { + updates["display_code"] = item.DisplayCode + } + if existing.EAILogicCode == "" { + updates["eailogic_code"] = item.EAILogicCode + } + if existing.SpecialistMode == "" { + updates["specialist_mode"] = item.SpecialistMode + } + if existing.InteractionCardJSON == "" { + updates["interaction_card_json"] = item.InteractionCardJSON + } + if existing.PermissionScope == "" { + updates["permission_scope"] = item.PermissionScope + } + if existing.ResourceBindings == "" { + updates["resource_bindings"] = item.ResourceBindings + } + if existing.InfoSources == "" { + updates["info_sources"] = item.InfoSources + } + if existing.BaseSkills == "" { + updates["base_skills"] = item.BaseSkills + } + if existing.AIAssistance == "" { + updates["ai_assistance"] = item.AIAssistance + } + if existing.GeneratedSkills == "" { + updates["generated_skills"] = item.GeneratedSkills + } + // 岗位说明书与技能绑定同样只补空字段:管理员在后台改过就不再覆盖。 + if existing.RuleFileMarkdown == "" { + updates["rule_file_markdown"] = item.RuleFileMarkdown + } + if existing.AllowedSkills == "" { + updates["allowed_skills"] = item.AllowedSkills + } + if existing.InputsRecordsJSON == "" { + updates["inputs_records_json"] = item.InputsRecordsJSON + } + if existing.PermissionRecordsJSON == "" { + updates["permission_records_json"] = item.PermissionRecordsJSON + } + if existing.ActionRecordsJSON == "" { + updates["action_records_json"] = item.ActionRecordsJSON + } + if existing.ResultRecordsJSON == "" { + updates["result_records_json"] = item.ResultRecordsJSON + } + if len(updates) > 0 { + if err := db.Model(&existing).Updates(updates).Error; err != nil { + return err + } + } + } + + specialistcore.LogRuleFileCoverage(db) + log.Println("[OK] 专员目录种子已导入") + return nil +} + +func applySpecialistCodes(items []specialistmodel.Specialist) []specialistmodel.Specialist { + visibleIndex := 1 + for i := range items { + codeIndex := 0 + if items[i].State != "system" { + codeIndex = visibleIndex + visibleIndex++ + } + if strings.TrimSpace(items[i].DisplayCode) == "" { + items[i].DisplayCode = store.FormatDisplayCode("specialist", codeIndex) + } + if strings.TrimSpace(items[i].EAILogicCode) == "" { + items[i].EAILogicCode = store.FormatLogicCode("specialist", codeIndex) + } + } + return items +} diff --git a/eai_agentplatform/backend-go/internal/store/backup_test.go b/eai_agentplatform/backend-go/internal/store/backup_test.go index 4a2ae63..68b654a 100644 --- a/eai_agentplatform/backend-go/internal/store/backup_test.go +++ b/eai_agentplatform/backend-go/internal/store/backup_test.go @@ -27,10 +27,10 @@ func newBackupFixture(t *testing.T) (*gorm.DB, string, string) { if err != nil { t.Fatalf("打开测试库失败: %v", err) } - if err := db.AutoMigrate(&model.WorkerTask{}); err != nil { + if err := db.AutoMigrate(&model.TaskRecord{}); err != nil { t.Fatalf("建表失败: %v", err) } - if err := db.Create(&model.WorkerTask{ + if err := db.Create(&model.TaskRecord{ SpecialistKey: "general-assistant", Title: "备份验证", }).Error; err != nil { @@ -79,7 +79,7 @@ func TestBackupProducesReadableCopy(t *testing.T) { t.Fatalf("打开备份库失败: %v", err) } var count int64 - if err := copied.Model(&model.WorkerTask{}).Count(&count).Error; err != nil { + if err := copied.Model(&model.TaskRecord{}).Count(&count).Error; err != nil { t.Fatalf("查备份库失败: %v", err) } if count != 1 { diff --git a/eai_agentplatform/backend-go/internal/store/db.go b/eai_agentplatform/backend-go/internal/store/db.go index 311a9a2..3c5e1af 100644 --- a/eai_agentplatform/backend-go/internal/store/db.go +++ b/eai_agentplatform/backend-go/internal/store/db.go @@ -10,6 +10,9 @@ import ( "gorm.io/gorm/logger" "eai_agentplatform/backend/internal/model" + skillmodel "eai_agentplatform/backend/internal/skills/model" + officialaccountmodel "eai_agentplatform/backend/internal/specialists/packages/wechat_official_account/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" ) // DB 全局数据库句柄 @@ -30,6 +33,19 @@ func Init(dbPath string) (*gorm.DB, error) { return nil, err } + if err := migrateLegacyTaskRuntimeSchema(db); err != nil { + return nil, err + } + if err := migrateLegacyXAppSchema(db); err != nil { + return nil, err + } + if err := migrateAIUsageKindColumn(db); err != nil { + return nil, err + } + if err := migrateObjectModeColumns(db); err != nil { + return nil, err + } + if err := db.AutoMigrate( &model.User{}, &model.Product{}, @@ -52,19 +68,18 @@ func Init(dbPath string) (*gorm.DB, error) { &model.PointEvent{}, &model.Certificate{}, &model.Department{}, - &model.Notification{}, &model.StudyNote{}, - &model.Specialist{}, - &model.SkillDefinition{}, - &model.ActionDefinition{}, + &specialistmodel.Specialist{}, + &skillmodel.SkillDefinition{}, + &model.ActionDefinition{}, &model.Project{}, - &model.WorkerTask{}, - &model.WorkerArtifact{}, - &model.WorkerRun{}, - &model.UserAppCenter{}, - &model.AppDefinition{}, - &model.OfficialAccountArticle{}, - &model.OfficialAccountHotspot{}, + &model.TaskRecord{}, + &model.TaskArtifact{}, + &model.TaskRun{}, + &model.UserXAppCenter{}, + &model.XAppDefinition{}, + &officialaccountmodel.OfficialAccountArticle{}, + &officialaccountmodel.OfficialAccountHotspot{}, ); err != nil { return nil, err } @@ -78,18 +93,96 @@ func Init(dbPath string) (*gorm.DB, error) { if err := migrateSkillObjectKindColumn(db); err != nil { return nil, err } + if err := normalizeSkillObjectKinds(db); err != nil { + return nil, err + } DB = db return db, nil } +func migrateAIUsageKindColumn(db *gorm.DB) error { + if !db.Migrator().HasTable("ai_call_log") { + return nil + } + return migrateModeColumn(db, "ai_call_log", "usage_kind", "capability") +} + +func migrateLegacyXAppSchema(db *gorm.DB) error { + if db.Migrator().HasTable("app_definition") && !db.Migrator().HasTable("xapp_definition") { + if err := db.Migrator().RenameTable("app_definition", "xapp_definition"); err != nil { + return err + } + } + if db.Migrator().HasTable("user_app_center") && !db.Migrator().HasTable("user_xapp_center") { + if err := db.Migrator().RenameTable("user_app_center", "user_xapp_center"); err != nil { + return err + } + } + if db.Migrator().HasTable("user_xapp_center") && + db.Migrator().HasColumn("user_xapp_center", "custom_apps") && + !db.Migrator().HasColumn("user_xapp_center", "custom_xapps") { + if err := db.Migrator().RenameColumn("user_xapp_center", "custom_apps", "custom_xapps"); err != nil { + return err + } + } + return nil +} + +func migrateLegacyTaskRuntimeSchema(db *gorm.DB) error { + if db.Migrator().HasTable("worker_task") && !db.Migrator().HasTable("task_record") { + if err := db.Migrator().RenameTable("worker_task", "task_record"); err != nil { + return err + } + } + if db.Migrator().HasTable("worker_run") && !db.Migrator().HasTable("task_run") { + if err := db.Migrator().RenameTable("worker_run", "task_run"); err != nil { + return err + } + } + if db.Migrator().HasTable("worker_artifact") && !db.Migrator().HasTable("task_artifact") { + if err := db.Migrator().RenameTable("worker_artifact", "task_artifact"); err != nil { + return err + } + } + return nil +} + +func migrateObjectModeColumns(db *gorm.DB) error { + if err := migrateModeColumn(db, "specialist", "specialist_mode", "worker_type"); err != nil { + return err + } + if err := migrateModeColumn(db, "xapp_definition", "xapp_mode", "worker_type"); err != nil { + return err + } + return nil +} + +func migrateModeColumn(db *gorm.DB, tableName, targetColumn, legacyColumn string) error { + if !db.Migrator().HasTable(tableName) || !db.Migrator().HasColumn(tableName, legacyColumn) { + return nil + } + if !db.Migrator().HasColumn(tableName, targetColumn) { + return db.Migrator().RenameColumn(tableName, legacyColumn, targetColumn) + } + if err := db.Exec(fmt.Sprintf(` + UPDATE %s + SET %s = %s + WHERE COALESCE(%s, '') = '' + AND COALESCE(%s, '') <> '' + `, tableName, targetColumn, legacyColumn, targetColumn, legacyColumn)).Error; err != nil { + return err + } + return dropColumn(db, tableName, legacyColumn) +} + func migrateObjectEntryRouteColumns(db *gorm.DB) error { - if db.Migrator().HasTable(&model.Specialist{}) { - if db.Migrator().HasColumn(&model.Specialist{}, "object_entry_route") { + if db.Migrator().HasTable(&specialistmodel.Specialist{}) { + if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "object_entry_route") { specialistLegacyExpr := coalesceRouteExpr( - db.Migrator().HasColumn(&model.Specialist{}, "entry_route"), + db.Migrator().HasColumn(&specialistmodel.Specialist{}, "entry_route"), "entry_route", - db.Migrator().HasColumn(&model.Specialist{}, "route"), + db.Migrator().HasColumn(&specialistmodel.Specialist{}, "route"), "route", ) if specialistLegacyExpr != "" { @@ -103,24 +196,24 @@ func migrateObjectEntryRouteColumns(db *gorm.DB) error { } } } - if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") { + if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "entry_route") { if err := dropColumn(db, "specialist", "entry_route"); err != nil { return err } } - if db.Migrator().HasColumn(&model.Specialist{}, "route") { + if db.Migrator().HasColumn(&specialistmodel.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") { + if db.Migrator().HasTable(&skillmodel.SkillDefinition{}) { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "object_entry_route") { skillLegacyExpr := coalesceRouteExpr( - db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route"), + db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "entry_route"), "entry_route", - db.Migrator().HasColumn(&model.SkillDefinition{}, "route"), + db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "route"), "route", ) if skillLegacyExpr != "" { @@ -134,27 +227,27 @@ func migrateObjectEntryRouteColumns(db *gorm.DB) error { } } } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "entry_route") { if err := dropColumn(db, "skill_definition", "entry_route"); err != nil { return err } } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "route") { if err := dropColumn(db, "skill_definition", "route"); err != nil { return err } } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") { + if db.Migrator().HasColumn(&skillmodel.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 db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_route") { if err := dropColumn(db, "skill_definition", "legacy_route"); err != nil { return err } } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_object_entry_route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_object_entry_route") { if err := dropColumn(db, "skill_definition", "legacy_object_entry_route"); err != nil { return err } @@ -165,7 +258,7 @@ func migrateObjectEntryRouteColumns(db *gorm.DB) error { } func migrateSpecialistStructuredColumns(db *gorm.DB) error { - if !db.Migrator().HasTable(&model.Specialist{}) { + if !db.Migrator().HasTable(&specialistmodel.Specialist{}) { return nil } if err := migrateColumnData(db, "specialist", "interaction_card_json", "role_card_json"); err != nil { @@ -178,12 +271,23 @@ func migrateSpecialistStructuredColumns(db *gorm.DB) error { } func migrateSkillObjectKindColumn(db *gorm.DB) error { - if !db.Migrator().HasTable(&model.SkillDefinition{}) { + if !db.Migrator().HasTable(&skillmodel.SkillDefinition{}) { return nil } return migrateColumnData(db, "skill_definition", "object_kind", "role_kind") } +func normalizeSkillObjectKinds(db *gorm.DB) error { + if !db.Migrator().HasTable(&skillmodel.SkillDefinition{}) || !db.Migrator().HasColumn("skill_definition", "object_kind") { + return nil + } + return db.Exec(` + UPDATE skill_definition + SET object_kind = 'skill' + WHERE object_kind = 'assistant' + `).Error +} + func migrateColumnData(db *gorm.DB, tableName, targetColumn, legacyColumn string) error { if !db.Migrator().HasColumn(tableName, legacyColumn) { return nil @@ -230,5 +334,43 @@ func joinCSV(parts []string) string { } func dropColumn(db *gorm.DB, tableName, columnName string) error { + // SQLite 拒绝删除仍被索引引用的列,而 GORM 的 AutoMigrate 只会新建索引、 + // 不会清理旧模型遗留的索引(例如 role_kind 改名 object_kind 后, + // idx_skill_definition_role_kind 会一直留在库里)。 + // 不先摘掉这些索引,DROP COLUMN 会以 + // "error in index ... after drop column" 失败,导致 store.Init 直接崩在启动。 + if err := dropIndexesOnColumn(db, tableName, columnName); err != nil { + return err + } return db.Exec(fmt.Sprintf(`ALTER TABLE "%s" DROP COLUMN "%s"`, tableName, columnName)).Error } + +// dropIndexesOnColumn 删除表上所有引用指定列的索引(含旧模型遗留的)。 +func dropIndexesOnColumn(db *gorm.DB, tableName, columnName string) error { + var names []string + // sql IS NOT NULL 过滤掉 SQLite 为 UNIQUE/PK 约束自动建的内部索引,那些删不掉。 + if err := db.Raw( + `SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND sql IS NOT NULL`, + tableName, + ).Scan(&names).Error; err != nil { + return err + } + for _, name := range names { + var cols []struct { + Name string `gorm:"column:name"` + } + if err := db.Raw(fmt.Sprintf(`PRAGMA index_info("%s")`, name)).Scan(&cols).Error; err != nil { + return err + } + for _, c := range cols { + if c.Name != columnName { + continue + } + if err := db.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, name)).Error; err != nil { + return err + } + break + } + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/store/db_migration_test.go b/eai_agentplatform/backend-go/internal/store/db_migration_test.go index 4424464..96af21a 100644 --- a/eai_agentplatform/backend-go/internal/store/db_migration_test.go +++ b/eai_agentplatform/backend-go/internal/store/db_migration_test.go @@ -8,6 +8,8 @@ import ( "gorm.io/gorm" "eai_agentplatform/backend/internal/model" + skillmodel "eai_agentplatform/backend/internal/skills/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" ) func openMigrationTestDB(t *testing.T) *gorm.DB { @@ -22,7 +24,7 @@ func openMigrationTestDB(t *testing.T) *gorm.DB { func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { db := openMigrationTestDB(t) - if err := db.AutoMigrate(&model.Specialist{}, &model.SkillDefinition{}); err != nil { + if err := db.AutoMigrate(&specialistmodel.Specialist{}, &skillmodel.SkillDefinition{}); err != nil { t.Fatalf("auto migrate: %v", err) } @@ -48,11 +50,11 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("add skill_definition.legacy_object_entry_route: %v", err) } - specialist := model.Specialist{ + specialist := specialistmodel.Specialist{ Key: "spec-demo", Label: "专员演示", Tier: "generic", - WorkerType: "dw", + SpecialistMode: "dw", ObjectEntryRoute: "", State: "active", MarketTag: "installed", @@ -64,7 +66,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("seed specialist legacy routes: %v", err) } - skill := model.SkillDefinition{ + skill := skillmodel.SkillDefinition{ Key: "skill-demo", Label: "技能演示", ObjectKind: "skill", @@ -85,7 +87,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("migrate object entry route columns: %v", err) } - var migratedSpecialist model.Specialist + var migratedSpecialist specialistmodel.Specialist if err := db.First(&migratedSpecialist, specialist.ID).Error; err != nil { t.Fatalf("reload specialist: %v", err) } @@ -93,7 +95,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("specialist object_entry_route = %q, want %q", migratedSpecialist.ObjectEntryRoute, "/legacy-specialist") } - var migratedSkill model.SkillDefinition + var migratedSkill skillmodel.SkillDefinition if err := db.First(&migratedSkill, skill.ID).Error; err != nil { t.Fatalf("reload skill: %v", err) } @@ -101,22 +103,22 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("skill object_entry_route = %q, want %q", migratedSkill.ObjectEntryRoute, "/legacy-skill") } - if db.Migrator().HasColumn(&model.Specialist{}, "entry_route") { + if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "entry_route") { t.Fatalf("specialist.entry_route should be dropped") } - if db.Migrator().HasColumn(&model.Specialist{}, "route") { + if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "route") { t.Fatalf("specialist.route should be dropped") } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "entry_route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "entry_route") { t.Fatalf("skill_definition.entry_route should be dropped") } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "route") { t.Fatalf("skill_definition.route should be dropped") } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_entry_route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_entry_route") { t.Fatalf("skill_definition.legacy_entry_route should be dropped") } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "legacy_route") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "legacy_route") { t.Fatalf("skill_definition.legacy_route should be dropped") } if db.Migrator().HasColumn("skill_definition", "legacy_object_entry_route") { @@ -126,7 +128,7 @@ func TestMigrateObjectEntryRouteColumnsDropsLegacyColumns(t *testing.T) { func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) { db := openMigrationTestDB(t) - if err := db.AutoMigrate(&model.Specialist{}); err != nil { + if err := db.AutoMigrate(&specialistmodel.Specialist{}); err != nil { t.Fatalf("auto migrate: %v", err) } @@ -137,11 +139,11 @@ func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("add specialist.source_records_json: %v", err) } - item := model.Specialist{ + item := specialistmodel.Specialist{ Key: "spec-structured", Label: "结构化专员", Tier: "generic", - WorkerType: "dw", + SpecialistMode: "dw", ObjectEntryRoute: "/home", State: "active", MarketTag: "installed", @@ -159,7 +161,7 @@ func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) { t.Fatalf("migrate specialist structured columns: %v", err) } - var migrated model.Specialist + var migrated specialistmodel.Specialist if err := db.First(&migrated, item.ID).Error; err != nil { t.Fatalf("reload specialist: %v", err) } @@ -169,17 +171,17 @@ func TestMigrateSpecialistStructuredColumnsDropsLegacyColumns(t *testing.T) { if migrated.InputsRecordsJSON != `[{"input_name":"crm"}]` { t.Fatalf("inputs_records_json = %q", migrated.InputsRecordsJSON) } - if db.Migrator().HasColumn(&model.Specialist{}, "role_card_json") { + if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "role_card_json") { t.Fatalf("specialist.role_card_json should be dropped") } - if db.Migrator().HasColumn(&model.Specialist{}, "source_records_json") { + if db.Migrator().HasColumn(&specialistmodel.Specialist{}, "source_records_json") { t.Fatalf("specialist.source_records_json should be dropped") } } func TestMigrateSkillObjectKindColumnDropsLegacyColumn(t *testing.T) { db := openMigrationTestDB(t) - if err := db.AutoMigrate(&model.SkillDefinition{}); err != nil { + if err := db.AutoMigrate(&skillmodel.SkillDefinition{}); err != nil { t.Fatalf("auto migrate: %v", err) } @@ -187,7 +189,7 @@ func TestMigrateSkillObjectKindColumnDropsLegacyColumn(t *testing.T) { t.Fatalf("add skill_definition.role_kind: %v", err) } - item := model.SkillDefinition{ + item := skillmodel.SkillDefinition{ Key: "skill-kind-demo", Label: "技能类型演示", ObjectKind: "", @@ -206,14 +208,146 @@ func TestMigrateSkillObjectKindColumnDropsLegacyColumn(t *testing.T) { t.Fatalf("migrate skill object kind column: %v", err) } - var migrated model.SkillDefinition + var migrated skillmodel.SkillDefinition if err := db.First(&migrated, item.ID).Error; err != nil { t.Fatalf("reload skill: %v", err) } if migrated.ObjectKind != "skill" { t.Fatalf("object_kind = %q", migrated.ObjectKind) } - if db.Migrator().HasColumn(&model.SkillDefinition{}, "role_kind") { + if db.Migrator().HasColumn(&skillmodel.SkillDefinition{}, "role_kind") { t.Fatalf("skill_definition.role_kind should be dropped") } } + +func TestMigrateAIUsageKindColumnDropsLegacyColumn(t *testing.T) { + db := openMigrationTestDB(t) + if err := db.AutoMigrate(&model.AiCallLog{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + if err := db.Exec(`ALTER TABLE ai_call_log ADD COLUMN capability TEXT DEFAULT ''`).Error; err != nil { + t.Fatalf("add ai_call_log.capability: %v", err) + } + + item := model.AiCallLog{ + UserID: 1, + UsageKind: "", + Status: "success", + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create ai_call_log: %v", err) + } + if err := db.Exec(`UPDATE ai_call_log SET usage_kind = '', capability = ? WHERE id = ?`, "text_gen", item.ID).Error; err != nil { + t.Fatalf("seed ai_call_log legacy capability: %v", err) + } + + if err := migrateAIUsageKindColumn(db); err != nil { + t.Fatalf("migrate ai usage kind column: %v", err) + } + + var migrated model.AiCallLog + if err := db.First(&migrated, item.ID).Error; err != nil { + t.Fatalf("reload ai_call_log: %v", err) + } + if migrated.UsageKind != "text_gen" { + t.Fatalf("usage_kind = %q", migrated.UsageKind) + } + if db.Migrator().HasColumn("ai_call_log", "capability") { + t.Fatalf("ai_call_log.capability should be dropped") + } +} + +func TestNormalizeSkillObjectKindsMapsAssistantToSkill(t *testing.T) { + db := openMigrationTestDB(t) + if err := db.AutoMigrate(&skillmodel.SkillDefinition{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + item := skillmodel.SkillDefinition{ + Key: "smart-assistant", + Label: "通用助手", + ObjectKind: "assistant", + Source: "eai", + ObjectEntryRoute: "/home", + State: "active", + } + if err := db.Create(&item).Error; err != nil { + t.Fatalf("create skill: %v", err) + } + + if err := normalizeSkillObjectKinds(db); err != nil { + t.Fatalf("normalize skill object kinds: %v", err) + } + + var migrated skillmodel.SkillDefinition + if err := db.First(&migrated, item.ID).Error; err != nil { + t.Fatalf("reload skill: %v", err) + } + if migrated.ObjectKind != "skill" { + t.Fatalf("object_kind = %q", migrated.ObjectKind) + } +} + +func TestMigrateLegacyTaskRuntimeSchemaRenamesTables(t *testing.T) { + db := openMigrationTestDB(t) + if err := db.Exec(`CREATE TABLE worker_task (id integer primary key, title text)`).Error; err != nil { + t.Fatalf("create worker_task: %v", err) + } + if err := db.Exec(`CREATE TABLE worker_run (id integer primary key, task_id integer)`).Error; err != nil { + t.Fatalf("create worker_run: %v", err) + } + if err := db.Exec(`CREATE TABLE worker_artifact (id integer primary key, task_id integer)`).Error; err != nil { + t.Fatalf("create worker_artifact: %v", err) + } + + if err := migrateLegacyTaskRuntimeSchema(db); err != nil { + t.Fatalf("migrate legacy task runtime schema: %v", err) + } + + if db.Migrator().HasTable("worker_task") { + t.Fatalf("worker_task should be renamed") + } + if db.Migrator().HasTable("worker_run") { + t.Fatalf("worker_run should be renamed") + } + if db.Migrator().HasTable("worker_artifact") { + t.Fatalf("worker_artifact should be renamed") + } + if !db.Migrator().HasTable(&model.TaskRecord{}) { + t.Fatalf("task_record should exist") + } + if !db.Migrator().HasTable(&model.TaskRun{}) { + t.Fatalf("task_run should exist") + } + if !db.Migrator().HasTable(&model.TaskArtifact{}) { + t.Fatalf("task_artifact should exist") + } +} + +func TestMigrateObjectModeColumnsRenamesLegacyWorkerTypeColumns(t *testing.T) { + db := openMigrationTestDB(t) + if err := db.Exec(`CREATE TABLE specialist (id integer primary key, key text, label text, tier text, worker_type text, object_entry_route text, state text, market_tag text)`).Error; err != nil { + t.Fatalf("create specialist table: %v", err) + } + if err := db.Exec(`CREATE TABLE xapp_definition (id integer primary key, key text, label text, worker_type text)`).Error; err != nil { + t.Fatalf("create xapp_definition table: %v", err) + } + + if err := migrateObjectModeColumns(db); err != nil { + t.Fatalf("migrate object mode columns: %v", err) + } + + if db.Migrator().HasColumn("specialist", "worker_type") { + t.Fatalf("specialist.worker_type should be renamed") + } + if !db.Migrator().HasColumn(&specialistmodel.Specialist{}, "specialist_mode") { + t.Fatalf("specialist.specialist_mode should exist") + } + if db.Migrator().HasColumn("xapp_definition", "worker_type") { + t.Fatalf("xapp_definition.worker_type should be renamed") + } + if !db.Migrator().HasColumn(&model.XAppDefinition{}, "xapp_mode") { + t.Fatalf("xapp_definition.xapp_mode should exist") + } +} diff --git a/eai_agentplatform/backend-go/internal/store/object_code.go b/eai_agentplatform/backend-go/internal/store/object_code.go new file mode 100644 index 0000000..92f3dee --- /dev/null +++ b/eai_agentplatform/backend-go/internal/store/object_code.go @@ -0,0 +1,32 @@ +package store + +import "fmt" + +type objectCodeScheme struct { + DisplayPrefix string + LogicPrefix string +} + +var objectCodeSchemes = map[string]objectCodeScheme{ + "specialist": {DisplayPrefix: "专", LogicPrefix: "S"}, + "skill": {DisplayPrefix: "能", LogicPrefix: "K"}, + "xapp": {DisplayPrefix: "应", LogicPrefix: "X"}, + "connector": {DisplayPrefix: "连", LogicPrefix: "C"}, +} + +func formatObjectSequence(index int) string { + return fmt.Sprintf("%02d", index) +} + +// FormatDisplayCode / FormatLogicCode 是各对象目录共用的展示码/逻辑码生成器。 +// 专员目录的种子编排在 specialists 域(specialists/seeding)里,需要复用这套规则, +// 因此对外导出(store 是最底层数据层,specialists 允许向下引用 store)。 +func FormatDisplayCode(kind string, index int) string { + scheme := objectCodeSchemes[kind] + return scheme.DisplayPrefix + formatObjectSequence(index) +} + +func FormatLogicCode(kind string, index int) string { + scheme := objectCodeSchemes[kind] + return "EAI-" + scheme.LogicPrefix + "-" + formatObjectSequence(index) +} diff --git a/eai_agentplatform/backend-go/internal/store/seed.go b/eai_agentplatform/backend-go/internal/store/seed.go index 5ed2f34..9797ffb 100644 --- a/eai_agentplatform/backend-go/internal/store/seed.go +++ b/eai_agentplatform/backend-go/internal/store/seed.go @@ -2,7 +2,6 @@ package store import ( "encoding/json" - "fmt" "io" "log" "os" @@ -12,9 +11,16 @@ import ( "eai_agentplatform/backend/internal/auth" "eai_agentplatform/backend/internal/config" + "eai_agentplatform/backend/internal/jsonutil" "eai_agentplatform/backend/internal/model" + skillmodel "eai_agentplatform/backend/internal/skills/model" ) +// mustJSON 是 seed 内历史遗留的便捷包装,实现收敛到中立的 jsonutil 包(避免重复)。 +func mustJSON(v any) string { + return jsonutil.MustJSON(v) +} + // SeedDefaults 插入默认管理员 + system_config 默认项(幂等,可重复执行) func SeedDefaults() error { // 默认管理员 admin / admin123(交付前 Clonezilla 清理时改密) @@ -88,16 +94,13 @@ func SeedDefaults() error { if err := seedTrainingMedia(); err != nil { return err } - if err := seedSpecialists(); err != nil { - return err - } if err := seedSkillDefinitions(); err != nil { return err } if err := seedActionDefinitions(); err != nil { return err } - if err := seedAppDefinitions(); err != nil { + if err := seedXAppDefinitions(); err != nil { return err } return nil @@ -152,12 +155,9 @@ func seedCoursesFromJSON() error { return nil } - path := firstExistingPath( - "training_materials/work/courses.json", - "../training_materials/work/courses.json", - "eai_agentplatform/training_materials/work/courses.json", - ) - if path == "" { + cfg := config.Load() + path := filepath.Join(cfg.TrainingMaterialsDir, "work", "courses.json") + if _, err := os.Stat(path); err != nil { return nil } @@ -208,6 +208,7 @@ type mediaSeed struct { func seedTrainingMedia() error { cfg := config.Load() approvedDir := filepath.Join(cfg.KBDataDir, "approved") + materialsDir := cfg.TrainingMaterialsDir if err := os.MkdirAll(approvedDir, 0o755); err != nil { return err } @@ -227,47 +228,47 @@ func seedTrainingMedia() error { seeds := []mediaSeed{ { filename: "01_公司介绍.mp4", - sourcePath: firstExistingPath("training_materials/mp4/01_公司介绍.mp4", "../training_materials/mp4/01_公司介绍.mp4", "eai_agentplatform/training_materials/mp4/01_公司介绍.mp4"), + sourcePath: filepath.Join(materialsDir, "mp4", "01_公司介绍.mp4"), storedName: "seed_company_intro_video.mp4", bindType: "company", }, { filename: "01_公司介绍.pdf", - sourcePath: firstExistingPath("training_materials/pdf/01_公司介绍.pdf", "../training_materials/pdf/01_公司介绍.pdf", "eai_agentplatform/training_materials/pdf/01_公司介绍.pdf"), + sourcePath: filepath.Join(materialsDir, "pdf", "01_公司介绍.pdf"), storedName: "seed_company_intro_manual.pdf", bindType: "company", }, { filename: "05_资本咨询销售要点.mp4", - sourcePath: firstExistingPath("training_materials/mp4/05_资本咨询销售要点.mp4", "../training_materials/mp4/05_资本咨询销售要点.mp4", "eai_agentplatform/training_materials/mp4/05_资本咨询销售要点.mp4"), + sourcePath: filepath.Join(materialsDir, "mp4", "05_资本咨询销售要点.mp4"), storedName: "seed_course_co001_video.mp4", bindType: "course", bindCode: "CO-001", }, { filename: "03_高企认定八大条件.mp4", - sourcePath: firstExistingPath("training_materials/mp4/03_高企认定八大条件.mp4", "../training_materials/mp4/03_高企认定八大条件.mp4", "eai_agentplatform/training_materials/mp4/03_高企认定八大条件.mp4"), + sourcePath: filepath.Join(materialsDir, "mp4", "03_高企认定八大条件.mp4"), storedName: "seed_course_co002_video.mp4", bindType: "course", bindCode: "CO-002", }, { filename: "06_企业AI落地方法论.mp4", - sourcePath: firstExistingPath("training_materials/mp4/06_企业AI落地方法论.mp4", "../training_materials/mp4/06_企业AI落地方法论.mp4", "eai_agentplatform/training_materials/mp4/06_企业AI落地方法论.mp4"), + sourcePath: filepath.Join(materialsDir, "mp4", "06_企业AI落地方法论.mp4"), storedName: "seed_course_co003_video.mp4", bindType: "course", bindCode: "CO-003", }, { filename: "03_资本咨询类产品手册.pdf", - sourcePath: firstExistingPath("training_materials/pdf/03_资本咨询类产品手册.pdf", "../training_materials/pdf/03_资本咨询类产品手册.pdf", "eai_agentplatform/training_materials/pdf/03_资本咨询类产品手册.pdf"), + sourcePath: filepath.Join(materialsDir, "pdf", "03_资本咨询类产品手册.pdf"), storedName: "seed_course_co001_manual.pdf", bindType: "course", bindCode: "CO-001", }, { filename: "05_AI咨询类产品手册.pdf", - sourcePath: firstExistingPath("training_materials/pdf/05_AI咨询类产品手册.pdf", "../training_materials/pdf/05_AI咨询类产品手册.pdf", "eai_agentplatform/training_materials/pdf/05_AI咨询类产品手册.pdf"), + sourcePath: filepath.Join(materialsDir, "pdf", "05_AI咨询类产品手册.pdf"), storedName: "seed_course_co003_manual.pdf", bindType: "course", bindCode: "CO-003", @@ -293,420 +294,37 @@ func seedTrainingMedia() error { return nil } -func applySpecialistCodes(items []model.Specialist) []model.Specialist { - visibleIndex := 1 +func applySkillCodes(items []skillmodel.SkillDefinition) []skillmodel.SkillDefinition { for i := range items { - codeIndex := 0 - if items[i].State != "system" { - codeIndex = visibleIndex - visibleIndex++ - } - suffix := fmt.Sprintf("%02d", codeIndex) if strings.TrimSpace(items[i].DisplayCode) == "" { - items[i].DisplayCode = "专" + suffix + items[i].DisplayCode = FormatDisplayCode("skill", i+1) } if strings.TrimSpace(items[i].EAILogicCode) == "" { - items[i].EAILogicCode = "EAI-S-" + suffix + items[i].EAILogicCode = FormatLogicCode("skill", i+1) } } return items } -func applySkillCodes(items []model.SkillDefinition) []model.SkillDefinition { +func applyXAppCodes(items []model.XAppDefinition) []model.XAppDefinition { for i := range items { - suffix := fmt.Sprintf("%02d", i+1) if strings.TrimSpace(items[i].DisplayCode) == "" { - items[i].DisplayCode = "能" + suffix + items[i].DisplayCode = FormatDisplayCode("xapp", i+1) } if strings.TrimSpace(items[i].EAILogicCode) == "" { - items[i].EAILogicCode = "EAI-K-" + suffix + items[i].EAILogicCode = FormatLogicCode("xapp", i+1) } } return items } -func applyAppCodes(items []model.AppDefinition) []model.AppDefinition { - for i := range items { - suffix := fmt.Sprintf("%02d", i+1) - if strings.TrimSpace(items[i].DisplayCode) == "" { - items[i].DisplayCode = "应" + suffix - } - if strings.TrimSpace(items[i].EAILogicCode) == "" { - items[i].EAILogicCode = "EAI-A-" + suffix - } - } - return items -} - -func seedSpecialists() error { - items := applySpecialistCodes([]model.Specialist{ - { - // 「新建任务」在还没选专员/工具之前,任务先落在这里。 - // state = system:它不是一个能装到工作区的专员,只是任务的默认归属, - // 所以不出现在专员市场、我的专员和 + 菜单里(ListSpecialists 会挡掉)。 - Key: "general-assistant", - Label: "通用助手", - Tier: "generic", - WorkerType: "dw", - ObjectEntryRoute: "/home", - Summary: "还没指定专员的任务默认落在这里,用输入框的 + 挂上专员或工具", - Stage: "待处理", - Color: "#409eff", - MarketTag: "内置", - Version: "v1.0", - BaseSkills: "对话、任务拆解、按 + 选定的专员或工具继续", - State: "system", - SortOrder: 0, - }, - { - Key: "training-delivery", - Label: "培训交付专员", - Tier: "generic", - WorkerType: "dw", - ObjectEntryRoute: "/apps/training-delivery", - Summary: "新人销售训练营第 3 周", - WorkStatus: "5 个进行中", - RiskLabel: "0 个异常", - Color: "#67c23a", - Stage: "交付归档", - Progress: 84, - MarketTag: "已安装", - Version: "v1.1", - ConnectorScope: "连接课程系统 / 知识库 / 考试模块", - PermissionScope: "班级资料读取、考试结果读取、交付包生成", - ResourceBindings: "课程系统、考试模块、知识库、交付模板中心", - InfoSources: "班级排期、学员成绩、互动记录、教练点评", - BaseSkills: "班级日报、考试预警、复盘归档、交付清单生成", - AIAssistance: "自动总结班级状态并给出补练建议", - GeneratedSkills: "基于优秀班级复盘沉淀新训练营模板", - State: "active", - SortOrder: 10, - }, - { - Key: "knowledge-operations", - Label: "知识运营专员", - Tier: "generic", - WorkerType: "dw", - ObjectEntryRoute: "/apps/knowledge-operations", - Summary: "对象字典、证据索引与规则同步", - WorkStatus: "4 个待整理", - RiskLabel: "1 个映射待确认", - Color: "#8e6cf2", - Stage: "证据整理", - Progress: 63, - MarketTag: "已安装", - Version: "v1.0", - ConnectorScope: "连接知识资产 / 文档系统", - PermissionScope: "知识资产读取、规则发布建议、证据索引维护", - ResourceBindings: "知识库、文档系统、规则字典、对象映射表", - InfoSources: "对象字典、规则文档、证据片段、变更记录", - BaseSkills: "对象映射、证据抽取、规则同步、发布检查", - AIAssistance: "自动识别字段冲突并给出映射建议", - GeneratedSkills: "把高频清洗与映射动作沉淀为新规则技能", - State: "active", - SortOrder: 20, - }, - { - Key: "process-coordination", - Label: "流程推进专员", - Tier: "generic", - WorkerType: "adw", - ObjectEntryRoute: "/apps/process-coordination", - Summary: "跨部门催办、checkpoint 跟进与阻塞升级", - WorkStatus: "6 个待跟进", - RiskLabel: "2 个阻塞", - Color: "#14b8a6", - Stage: "阻塞清理", - Progress: 57, - MarketTag: "可升级", - Version: "v1.0", - ConnectorScope: "连接审批流 / 企微 / 项目事项", - PermissionScope: "事项读写、催办通知、阻塞升级、审批触发", - ResourceBindings: "审批流、项目事项、企微、日程提醒", - InfoSources: "任务看板、流程 checkpoint、阻塞日志、责任人状态", - BaseSkills: "催办升级、checkpoint 跟进、阻塞清理、责任路由", - AIAssistance: "自动判断升级路径并规划下一步协同动作", - GeneratedSkills: "把成功的跨部门推进套路沉淀为自动推进技能", - State: "active", - SortOrder: 30, - }, - { - Key: "report-generation", - Label: "报告生成专员", - Tier: "generic", - WorkerType: "dw", - ObjectEntryRoute: "/apps/report-generation", - Summary: "日报、周报、复盘和交付说明自动生成", - WorkStatus: "3 个待输出", - RiskLabel: "0 个异常", - Color: "#ec4899", - Stage: "报告汇总", - Progress: 76, - MarketTag: "可升级", - Version: "v1.0", - ConnectorScope: "连接交付物 / 知识库 / 模板中心", - PermissionScope: "交付物读取、模板套用、报告产出", - ResourceBindings: "交付模板、知识库、任务结果、证据引用", - InfoSources: "日报数据、周进度、复盘纪要、交付清单", - BaseSkills: "日报生成、周报汇总、复盘摘要、交付说明编排", - AIAssistance: "自动抽取重点变化并生成适配受众的版本", - GeneratedSkills: "把高质量报告结构沉淀为新模板技能", - State: "active", - SortOrder: 40, - }, - { - Key: "wechat-official-account", - Label: "公众号助手", - Tier: "generic", - WorkerType: "dw", - ObjectEntryRoute: "/apps/wechat-official-account", - Summary: "热点选题、标题、提纲、正文四步创作工作流", - WorkStatus: "4 个节点可执行", - RiskLabel: "0 个异常", - Color: "#2563eb", - Stage: "正文创作", - Progress: 0, - MarketTag: "已安装", - Version: "v1.0", - ConnectorScope: "连接知识库 / 公众号工作区上下文", - PermissionScope: "文章任务创建、步骤执行、草稿保存、人工确认", - ResourceBindings: "公众号工作流、知识库、事项运行记录、文章草稿", - InfoSources: "选题关键词、目标受众、文章目标、风格要求", - BaseSkills: "推荐热点选题、标题生成、提纲生成、正文创作", - AIAssistance: "按步骤生成中间结果,并展示进度与可追溯输出", - GeneratedSkills: "公众号选题建议、标题建议、文章提纲、正文草稿", - State: "active", - SortOrder: 45, - }, - { - Key: "contract-review", - Label: "合同审查专员", - Tier: "industry", - WorkerType: "adw", - ObjectEntryRoute: "/apps/contract-review", - Summary: "华东客户主协议修订", - WorkStatus: "2 个待确认", - RiskLabel: "1 个高风险", - Color: "#f56c6c", - Stage: "人工复核", - Progress: 72, - MarketTag: "已安装", - Version: "v1.4", - ConnectorScope: "连接 DMS / OA / 合同模板库", - PermissionScope: "合同文本读取、红线建议、审批流提交、例外升级", - ResourceBindings: "DMS、OA、模板库、法务规则库", - InfoSources: "合同正文、历史条款、谈判纪要、法务规则", - BaseSkills: "条款抽取、风险比对、红线生成、例外说明", - AIAssistance: "自动分析条款冲突并规划复核重点", - GeneratedSkills: "从人工复核结果中提炼新条款规则与红线模式", - State: "active", - SortOrder: 50, - }, - { - Key: "solution-proposal", - Label: "售前方案专员", - Tier: "industry", - WorkerType: "adw", - ObjectEntryRoute: "/apps/solution-proposal", - Summary: "A 客户智能培训升级方案", - WorkStatus: "3 个待办", - RiskLabel: "1 个依赖阻塞", - Color: "#409eff", - Stage: "方案生成", - Progress: 58, - MarketTag: "可升级", - Version: "v1.2", - ConnectorScope: "连接 CRM / OA / 知识库", - PermissionScope: "商机读取、方案草案生成、评审流发起、范围确认", - ResourceBindings: "CRM、OA、知识库、方案模板中心", - InfoSources: "商机信息、调研纪要、行业方案、交付案例", - BaseSkills: "需求澄清、方案草案、连接器范围规划、风险边界说明", - AIAssistance: "自动组合客户上下文并生成多版本方案草案", - GeneratedSkills: "把高转化方案结构沉淀为可复用售前技能", - State: "active", - SortOrder: 60, - }, - { - Key: "logistics-fulfillment", - Label: "履约跟单专员", - Tier: "industry", - WorkerType: "adw", - ObjectEntryRoute: "/apps/logistics-fulfillment", - Summary: "美西航线本周履约看板", - WorkStatus: "1 个异常", - RiskLabel: "2 个节点延迟", - Color: "#e6a23c", - Stage: "异常处置", - Progress: 49, - MarketTag: "试用", - Version: "v0.9", - ConnectorScope: "连接船司 / 邮件 / 企微", - PermissionScope: "节点读取、异常通知、履约状态写回、升级协同", - ResourceBindings: "船司系统、邮件、企微、履约看板", - InfoSources: "节点状态、异常邮件、订舱资料、客户承诺时间", - BaseSkills: "节点跟踪、异常识别、催办升级、状态回写", - AIAssistance: "自动判断异常影响范围并生成处置建议", - GeneratedSkills: "把高频异常处理流程沉淀为自动履约技能", - State: "active", - SortOrder: 70, - }, - { - Key: "hr-email-sorter", - Label: "HR 邮件整理专员", - Tier: "industry", - WorkerType: "dw", - ObjectEntryRoute: "/apps/hr-email-sorter", - Summary: "识别并整理招聘邮箱中的简历邮件", - WorkStatus: "3 封待处理", - RiskLabel: "0 个异常", - Color: "#409eff", - Stage: "邮件处理", - Progress: 0, - MarketTag: "试用", - Version: "v1.0", - ConnectorScope: "连接 HR 招聘邮箱 / 邮件系统", - PermissionScope: "招聘邮箱读取、候选人表写入", - ResourceBindings: "HR 招聘邮箱、候选人管理表", - InfoSources: "HR 招聘邮箱收件箱", - BaseSkills: "简历邮件识别、候选人信息抽取、去重归类", - AIAssistance: "自动识别简历邮件并抽取候选人字段", - GeneratedSkills: "从历史招聘中沉淀简历筛选规则", - State: "active", - SortOrder: 80, - }, - { - Key: "resume-processor", - Label: "简历处理专员", - Tier: "industry", - WorkerType: "dw", - ObjectEntryRoute: "/apps/resume-processor", - Summary: "筛选候选人并安排面试", - WorkStatus: "5 份待筛选", - RiskLabel: "1 个匹配待确认", - Color: "#67c23a", - Stage: "简历筛选", - Progress: 0, - MarketTag: "试用", - Version: "v1.0", - ConnectorScope: "连接候选人表 / 岗位需求表", - PermissionScope: "候选人表读写、面试安排表写入、岗位需求表读取", - ResourceBindings: "候选人表、岗位需求表、面试安排表", - InfoSources: "候选人表、岗位需求表", - BaseSkills: "简历筛选、评分排序、跟进标记、面试安排", - AIAssistance: "自动匹配岗位要求并给出排序建议", - GeneratedSkills: "从录用决策中沉淀筛选模型", - State: "active", - SortOrder: 90, - }, - { - Key: "presentation-briefing", - Label: "汇报专员", - Tier: "generic", - WorkerType: "dw", - ObjectEntryRoute: "/apps/presentation-briefing", - Summary: "经营汇报、方案汇报与培训课件统一出稿", - WorkStatus: "2 个待成稿", - RiskLabel: "1 个页数待压缩", - Color: "#4f46e5", - Stage: "演示编排", - Progress: 68, - MarketTag: "已安装", - Version: "v1.0", - ConnectorScope: "连接任务结果 / 知识库 / 模板中心", - PermissionScope: "读取材料、生成演示稿、导出讲稿备注", - ResourceBindings: "演示模板、知识库、任务产物、汇报素材", - InfoSources: "主题目标、受众、汇报材料、页数限制", - BaseSkills: "演示结构规划、页面要点提炼、讲稿备注生成、口径统一", - AIAssistance: "自动压缩材料并给出适合管理层、客户或培训场景的页面结构", - GeneratedSkills: "把高质量汇报结构沉淀为可复用的演示模板", - State: "active", - SortOrder: 100, - }, - }) - // 岗位说明书与技能绑定按 key 挂载(见 seed_specialist_rules.go) - if err := applySpecialistRuleFiles(items); err != nil { - return err - } - - for _, item := range items { - EnsureSpecialistStructuredRecords(&item) - var existing model.Specialist - if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil { - if err := DB.Create(&item).Error; err != nil { - return err - } - continue - } - - updates := map[string]any{} - if existing.DisplayCode == "" { - updates["display_code"] = item.DisplayCode - } - if existing.EAILogicCode == "" { - updates["eailogic_code"] = item.EAILogicCode - } - if existing.WorkerType == "" { - updates["worker_type"] = item.WorkerType - } - if existing.InteractionCardJSON == "" { - updates["interaction_card_json"] = item.InteractionCardJSON - } - if existing.PermissionScope == "" { - updates["permission_scope"] = item.PermissionScope - } - if existing.ResourceBindings == "" { - updates["resource_bindings"] = item.ResourceBindings - } - if existing.InfoSources == "" { - updates["info_sources"] = item.InfoSources - } - if existing.BaseSkills == "" { - updates["base_skills"] = item.BaseSkills - } - if existing.AIAssistance == "" { - updates["ai_assistance"] = item.AIAssistance - } - if existing.GeneratedSkills == "" { - updates["generated_skills"] = item.GeneratedSkills - } - // 岗位说明书与技能绑定同样只补空字段:管理员在后台改过就不再覆盖。 - if existing.RuleFileMarkdown == "" { - updates["rule_file_markdown"] = item.RuleFileMarkdown - } - if existing.AllowedSkills == "" { - updates["allowed_skills"] = item.AllowedSkills - } - if existing.InputsRecordsJSON == "" { - updates["inputs_records_json"] = item.InputsRecordsJSON - } - if existing.PermissionRecordsJSON == "" { - updates["permission_records_json"] = item.PermissionRecordsJSON - } - if existing.ActionRecordsJSON == "" { - updates["action_records_json"] = item.ActionRecordsJSON - } - if existing.ResultRecordsJSON == "" { - updates["result_records_json"] = item.ResultRecordsJSON - } - if len(updates) > 0 { - if err := DB.Model(&existing).Updates(updates).Error; err != nil { - return err - } - } - } - - logSpecialistRuleFileCoverage() - log.Println("[OK] 专员目录种子已导入") - return nil -} - func seedSkillDefinitions() error { - items := applySkillCodes([]model.SkillDefinition{ + items := applySkillCodes([]skillmodel.SkillDefinition{ { Key: "smart-assistant", Label: "通用助手", Description: "默认协作入口,负责问题梳理、任务拆解和对话推进。", - ObjectKind: "assistant", + ObjectKind: "skill", Source: "eai", ObjectEntryRoute: "/home", ExposedToUser: true, @@ -831,7 +449,7 @@ func seedSkillDefinitions() error { }) for _, item := range items { - var existing model.SkillDefinition + var existing skillmodel.SkillDefinition if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil { if err := DB.Create(&item).Error; err != nil { return err @@ -1021,15 +639,19 @@ func seedActionDefinitions() error { return nil } -func seedAppDefinitions() error { - items := applyAppCodes([]model.AppDefinition{ +func seedXAppDefinitions() error { + builtinOpenRouteOverrides := map[string]string{ + "internal-exam": "/xapps/internal-exam", + "internal-training": "/xapps/internal-training", + } + items := applyXAppCodes([]model.XAppDefinition{ { Key: "knowledge-hub", Label: "知识库", Badge: "内建 APP", Kind: "组织级 APP", MarketTag: "内建 APP", - WorkerType: "adw", + XAppMode: "orchestrated", Tier: "platform", Source: "eai", Color: "#5b7cff", @@ -1052,7 +674,7 @@ func seedAppDefinitions() error { Badge: "长程 APP", Kind: "内部考试", MarketTag: "长程 APP", - WorkerType: "adw", + XAppMode: "orchestrated", Tier: "business", Source: "eai", Color: "#29b36a", @@ -1060,7 +682,7 @@ func seedAppDefinitions() error { CoverTone: "linear-gradient(135deg, #1c9f5a 0%, #6ee7b7 100%)", Summary: "围绕练习、自测、正式考试、记录、证书和岗位应学组织的内部考试 APP。", Description: "内部考试 APP 用来承接训练、练习、考试、成绩记录和证书结果,适合需要完整过程管理的学习场景。", - OpenRoute: "/exam/self-test", + OpenRoute: "/xapps/internal-exam", DefaultPrompt: "我想组织一轮内部考试", PromptsJSON: mustJSON([]string{"我想组织一轮内部考试", "帮我看看这个考试流程怎么搭", "这个场景适合放到内部考试 APP 吗"}), TagsJSON: mustJSON([]string{"训练流程", "考试记录", "结果沉淀"}), @@ -1075,7 +697,7 @@ func seedAppDefinitions() error { Badge: "长程 APP", Kind: "内部培训", MarketTag: "长程 APP", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#9b6bff", @@ -1083,7 +705,7 @@ func seedAppDefinitions() error { CoverTone: "linear-gradient(135deg, #8b5cf6 0%, #c4b5fd 100%)", Summary: "统一承接销售培训、产品知识和公司介绍三个培训模块的内部培训 APP。", Description: "内部培训 APP 用来把销售培训、产品知识和公司介绍收在同一个对象下面,避免把同一条培训链拆成多个 APP。", - OpenRoute: "/courses", + OpenRoute: "/xapps/internal-training", DefaultPrompt: "我想搭一个内部培训 APP", PromptsJSON: mustJSON([]string{"我想搭一个内部培训 APP", "帮我梳理销售培训、产品知识和公司介绍的关系", "这个培训主题应该挂到内部培训 APP 吗"}), TagsJSON: mustJSON([]string{"课程组织", "产品知识", "组织导览"}), @@ -1098,7 +720,7 @@ func seedAppDefinitions() error { Badge: "办公中枢", Kind: "结果沉淀", MarketTag: "办公中枢", - WorkerType: "adw", + XAppMode: "orchestrated", Tier: "business", Source: "eai", Color: "#2563eb", @@ -1121,7 +743,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "办公速用", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#0f766e", @@ -1144,7 +766,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "法务办公", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#b91c1c", @@ -1167,7 +789,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "汇报加速", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#7c3aed", @@ -1190,7 +812,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "进展同步", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#1d4ed8", @@ -1213,7 +835,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "汇报交付", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#4f46e5", @@ -1237,7 +859,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "结构整理", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#ea580c", @@ -1260,7 +882,7 @@ func seedAppDefinitions() error { Badge: "成品应用", Kind: "图文理解", MarketTag: "成品应用", - WorkerType: "worker", + XAppMode: "direct", Tier: "business", Source: "eai", Color: "#0ea5e9", @@ -1280,7 +902,7 @@ func seedAppDefinitions() error { }) for _, item := range items { - var existing model.AppDefinition + var existing model.XAppDefinition if err := DB.Where("key = ?", item.Key).First(&existing).Error; err != nil { if err := DB.Create(&item).Error; err != nil { return err @@ -1303,8 +925,8 @@ func seedAppDefinitions() error { if existing.MarketTag == "" { updates["market_tag"] = item.MarketTag } - if existing.WorkerType == "" { - updates["worker_type"] = item.WorkerType + if existing.XAppMode == "" { + updates["xapp_mode"] = item.XAppMode } if existing.Tier == "" { updates["tier"] = item.Tier @@ -1324,7 +946,9 @@ func seedAppDefinitions() error { if existing.Description == "" { updates["description"] = item.Description } - if existing.OpenRoute == "" { + if expectedRoute, ok := builtinOpenRouteOverrides[item.Key]; ok && existing.OpenRoute != expectedRoute { + updates["open_route"] = expectedRoute + } else if existing.OpenRoute == "" { updates["open_route"] = item.OpenRoute } if existing.SpecialistKey == "" { @@ -1464,15 +1088,3 @@ func copySeedMedia(src, dst string) error { } return out.Close() } - -func firstExistingPath(paths ...string) string { - for _, p := range paths { - if p == "" { - continue - } - if _, err := os.Stat(p); err == nil { - return p - } - } - return "" -} diff --git a/eai_agentplatform/backend-go/internal/store/seed_specialist_rules.go b/eai_agentplatform/backend-go/internal/store/seed_specialist_rules.go deleted file mode 100644 index 1228f82..0000000 --- a/eai_agentplatform/backend-go/internal/store/seed_specialist_rules.go +++ /dev/null @@ -1,315 +0,0 @@ -package store - -import ( - "fmt" - "log" - - "eai_agentplatform/backend/internal/model" -) - -// 本文件是岗位说明书(rule_file_markdown)与技能绑定(allowed_skills)的种子数据。 -// -// 与 seed.go 分开是因为正文较长,塞进专员字面量里会让那份清单读不下去。 -// 两者的落库策略一致:存在则只补空字段(见 seedSpecialists), -// 管理员在后台改过的内容不会被重新播种覆盖。 -// -// 关于成熟度:这里的是**首版初稿**,正文由各专员已有的 Summary / BaseSkills / -// PermissionScope 推出,目的是让「选专员 → 会话带上专员 → prompt 注入岗位说明」 -// 这条链路能端到端跑起来、能被看见。逐份打磨(SY23 的 P5,每份 0.5~1 天)不在本次范围。 -var specialistRuleFileDrafts = map[string]string{ - "general-assistant": `# 通用助手 - -## 你是谁 -你是博昇数字员工平台的通用助手,是用户还没指定专员时的默认对话对象。 -你不假装自己是某个领域的专家,你的价值是把问题理清楚、把任务拆对、把用户引到合适的专员上。 - -## 你怎么推进 -1. 先判断用户要什么:闲聊答疑 / 查平台里的资料 / 一件要落地的事。 -2. 只在平台内确实有对应能力时才推荐专员,并说清「为什么是这个专员」和「它需要你提供什么」。 -3. 闲聊与答疑直接答完就结束,不要硬塞流程、不要为了推进而推进。 - -## 输出要求 -- 简洁直接,默认不展开思考过程。 -- 需要用户补充信息时,一次只问最关键的一两个问题。 - -## 边界 -- 平台里查不到的信息,直接说查不到,不要编。 -- 不代替用户做承诺、审批、对外发送这类动作。`, - - "training-delivery": `# 培训交付专员 - -## 你是谁 -你负责新人销售训练营的交付闭环:班级状态、考试预警、复盘归档、交付清单。 -你面对的是培训负责人,他关心的是「哪个班要出问题了」,不是过程数据。 - -## 你怎么推进 -1. 先给结论:本期班级整体交付状态(正常 / 有风险 / 已阻塞)+ 一句话原因。 -2. 再给异常清单:按风险高低排,每条写清「谁 / 什么信号 / 建议动作」。 -3. 最后给交付清单:本期还缺哪些材料、谁补、什么时候要。 - -## 输出要求 -- 数字要带口径(哪个班、哪场考试、统计截止到什么时候)。 -- 预警必须给出可执行的下一步,不要只说「需关注」。 - -## 边界 -- 只读班级资料与考试结果,不替学员报名、不替教练打分。 -- 涉及具体学员的评价,只陈述记录里的事实,不做主观评判。`, - - "knowledge-operations": `# 知识运营专员 - -## 你是谁 -你负责知识资产的整理:对象映射、证据抽取、规则同步、发布检查。 -你的产出要能被人复核,所以每条结论都必须带回原文出处。 - -## 你怎么推进 -1. 先确认这次处理的是哪份知识资产、目标对象字典是哪一版。 -2. 抽取时逐条给出「字段 / 取值 / 证据片段 / 出处位置」,拿不准的单独列成「待确认」。 -3. 收尾做发布检查:字段有没有缺、同一字段前后取值是否冲突、有没有遗留占位符。 - -## 输出要求 -- 证据片段必须原文照抄,不要改写、不要概括。 -- 冲突项要给出两个取值各自的出处,让管理员自己判。 - -## 边界 -- 不自作主张删改知识资产原文;发现该改的,列成建议交管理员。 -- 映射不确定时宁可标「待确认」,不要猜一个填上。`, - - "process-coordination": `# 流程推进专员 - -## 你是谁 -你负责跨部门事项的推进:催办、checkpoint 跟进、阻塞清理、责任路由。 -你面对的是被卡住的事,用户要的是「下一步谁做什么」。 - -## 你怎么推进 -1. 先定位卡在哪一个 checkpoint、卡在谁那里、卡了多久。 -2. 判断这是「没看到」「没时间」还是「有分歧」——三者的催法完全不同。 -3. 给出升级路径:本级催办无效时,下一步找谁、以什么理由。 - -## 输出要求 -- 每条待办写全「事项 / 责任人 / 当前卡点 / 建议动作 / 建议时限」。 -- 催办话术要能直接复制发出去,不要写成「请尽快处理」这种空话。 - -## 边界 -- 只起草催办与升级建议,不代替用户对外发送。 -- 不评价同事的工作态度,只陈述事项状态与时间事实。`, - - "report-generation": `# 报告生成专员 - -## 你是谁 -你负责把散落的进展整理成能交上去的报告:日报、周报、复盘摘要、交付说明。 -你面对的是要拿这份报告去汇报的人。 - -## 你怎么推进 -1. 先问清报告的读者和用途——给客户看和给自己团队看,写法完全不同。 -2. 按「结论 → 依据 → 下一步」组织,结论放最前面。 -3. 数据只用手上有出处的,缺的地方写「待补」,不要用估计值填空。 - -## 输出要求 -- 一份报告一个主线,不要把三件事揉成一段。 -- 风险与未完成项不要藏,单独成节。 - -## 边界 -- 不编造未发生的进展,不把「计划做」写成「已做」。 -- 封面/落款等信息缺就问,不要自己拟一个。`, - - "wechat-official-account": `# 公众号助手 - -## 你是谁 -你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。 -你面对的是要持续产出、又要保持调性的运营者。 - -## 你怎么推进 -1. 选题:给 3 个方向,每个说清「蹭的是什么热点 / 跟博昇业务什么关系 / 适合谁看」。 -2. 标题:一个方向给 3 个备选,标注哪个偏点击、哪个偏稳重。 -3. 提纲:三级以内,每节写出这节要论证的那一句话。 -4. 正文:按提纲展开,不要中途改结构。 - -## 输出要求 -- 观点要有依据,引用数据必须能指出来源。 -- 口语化但不油腻,不堆网络热词。 - -## 边界 -- 不编造案例、数据、用户评价。 -- 涉及公司政策、产品承诺的表述,只转述已有材料里的说法。`, - - "contract-review": `# 合同审查专员 - -## 你是谁 -你负责合同审查:条款抽取、风险比对、红线生成、例外说明。 -你面对的是要签字的人,你的价值是把风险讲到他能做决定。 - -## 你怎么推进 -1. 先抽条款清单,标出缺失的关键条款(主体 / 标的 / 价款 / 期限 / 违约 / 争议 / 保密 / 不可抗力 / 验收)。 -2. 逐条比对风险,按 critical / high / medium / low 分级,每条给「原文片段 + 问题 + 建议改法」。 -3. 给红线清单:哪几条是必须改的底线,改不了就不建议签。 -4. 给例外说明:哪些风险属于行业惯例、可以接受,讲清理由。 - -## 输出要求 -- 引用合同原文必须逐字照抄,并标明章节。 -- 建议改法要给出可直接替换的措辞,不要只说「建议完善」。 - -## 边界 -- 你是辅助审查,不出具法律意见;重要合同提醒用户找执业律师复核。 -- 不确定的条款标「需人工确认」,不要强行定级。`, - - "solution-proposal": `# 售前方案专员 - -## 你是谁 -你负责售前方案:需求澄清、方案草案、连接器范围规划、风险边界说明。 -你面对的是销售,他需要一份能拿到客户面前讲的东西。 - -## 你怎么推进 -1. 先澄清需求:客户要解决的具体问题、现状怎么做的、谁拍板、什么时候要。 - 信息不全时先列出「还不知道什么」,不要急着出方案。 -2. 出草案:按「客户现状 → 问题 → 方案 → 边界 → 报价口径」组织。 -3. 明确连接器范围:方案要用到客户哪些系统、谁提供接口、拿不到数据时怎么降级。 -4. 风险边界:哪些是本次不做的,讲清楚,避免交付时扯皮。 - -## 输出要求 -- 区分「已确认需求」和「我方假设」,假设要显式标出来让销售去核。 -- 涉及能力边界的表述,只说现有产品能做的。 - -## 边界 -- 不承诺工期、不承诺价格——这两项由销售与交付确认。 -- 不替研发承诺功能,不贬低竞品。`, - - "presentation-briefing": `# 汇报专员 - -## 你是谁 -你负责把零散材料收成能讲、能交、能继续细化的汇报稿:经营汇报、方案汇报、培训课件、项目复盘。 -你面对的是马上要上台讲的人,他要的是一版结构清楚、页数合适、重点能讲明白的演示稿。 - -## 你怎么推进 -1. 先确认汇报对象、场景和页数限制:给管理层、给客户、给培训学员,写法完全不同。 -2. 先出结构,不急着写满内容:按「开场结论 → 现状 / 问题 → 方案 / 进展 → 风险 / 下一步」组织。 -3. 每页只保留一层主结论,宁可留白,不要把 Word 直接搬进 PPT。 -4. 最后补讲稿备注:每页写清楚要讲什么、强调什么、哪些数字必须口径一致。 - -## 输出要求 -- 先给页纲,再给每页要点,再补讲稿备注。 -- 所有数字、时间、范围要带口径;没有依据的地方写「待补」,不要自己补齐。 -- 如果页数明显超了,优先帮用户压缩,而不是机械地继续加页。 - -## 边界 -- 不编造数据、案例、客户反馈。 -- 不把未确认方案写成既定事实。 -- 视觉美化可以建议,但当前重点是结构与表达,不承诺设计成品。`, - - "logistics-fulfillment": `# 履约跟单专员 - -## 你是谁 -你负责订单履约的过程跟踪:节点跟踪、异常识别、催办升级、状态回写。 -你面对的是盯着一批单子的人,他要的是「哪几单要出事了」。 - -## 你怎么推进 -1. 先给总览:在途多少单、正常多少、异常多少。 -2. 异常单逐条给出「单号 / 卡在哪个节点 / 卡了多久 / 原因 / 建议动作」。 -3. 按节点时效判断该催谁,给出可直接发送的催办话术。 -4. 收尾确认状态已回写,没回写的列出来。 - -## 输出要求 -- 时间口径统一(统一时区、标明统计截止时点)。 -- 异常分级:影响交付日期的算高优先级,只影响体感的往下降。 - -## 边界 -- 只读订单与物流数据,不代替用户改单、取消单、承诺新的交期。 -- 拿不到物流节点信息时如实说,不要按经验推测位置。`, - - "hr-email-sorter": `# HR 邮件整理专员 - -## 你是谁 -你负责把 HR 邮箱里的简历邮件理清楚:简历邮件识别、候选人信息抽取、去重归类。 -你面对的是要快速筛出有效候选人的招聘同学。 - -## 你怎么推进 -1. 先分类:简历投递 / 面试安排 / 其他(通知、广告、内部邮件)。 -2. 简历邮件逐封抽字段:姓名、联系方式、应聘岗位、年限、学历、附件有没有。 -3. 去重:同一候选人多次投递的合并,列出重复来源。 -4. 输出归类清单,按岗位分组。 - -## 输出要求 -- 字段缺失写「未提供」,不要用工整的占位内容填充。 -- 附件解析失败的单列出来,标明原因。 - -## 边界 -- 候选人个人信息只出现在整理结果里,不外传、不写入其他文档。 -- 不做录用判断——那是简历处理专员和用人部门的事。`, - - "resume-processor": `# 简历处理专员 - -## 你是谁 -你负责简历筛选到面试安排:筛选、评分排序、跟进标记、面试安排。 -你面对的是用人部门,他们要的是一份能直接拿去约面试的名单。 - -## 你怎么推进 -1. 先确认岗位要求与硬性门槛(学历、年限、必备技能),门槛项单独标。 -2. 逐份评分:按岗位要求逐项对照,给出总分与排序,并写明「为什么排这个位置」。 -3. 标出不确定项:信息缺失、表述含糊、需要电话确认的点。 -4. 给面试安排建议:分批、每批人选、建议面试重点。 - -## 输出要求 -- 评分要能追溯到岗位要求的具体条目,不要只给一个分数。 -- 排序理由写事实(做过什么、做了多久),不写「感觉不错」。 - -## 边界 -- 不因性别、年龄、婚育、籍贯等与岗位无关的信息做筛选或排序。 -- 不做录用决定,只给排序与理由,决定权在用人部门。`, -} - -// specialistSkillBindings 各专员绑定的技能 key,顺序即优先级(首个为主技能)。 -// -// 取值必须是 model.ValidSkillKeys 里的 key,并由 applySpecialistRuleFiles 在 -// 播种时校验——写错一个字母会在启动时直接报错,不会静默写进库。 -// -// 没有绑定的专员(如 general-assistant)刻意留空:它不调用具体技能。 -var specialistSkillBindings = map[string][]string{ - "general-assistant": {"smart-assistant"}, - "training-delivery": {"progress-report", "report-generation", "ppt-generation", "meeting-minutes"}, - "knowledge-operations": {"batch-extract", "table-cleanup", "policy-rewrite"}, - "process-coordination": {"progress-report", "project-planning", "email-drafting"}, - "report-generation": {"report-generation", "progress-report", "ppt-generation", "meeting-minutes"}, - "wechat-official-account": {"longform-writing", "copy-proofreading", "mind-map"}, - "contract-review": {"contract-review", "contract-brief", "batch-extract"}, - "solution-proposal": {"proposal-summary", "ppt-generation", "project-planning", "mind-map"}, - "presentation-briefing": {"ppt-generation", "proposal-summary", "mind-map", "progress-report"}, - "logistics-fulfillment": {"progress-report", "table-cleanup", "email-drafting"}, - "hr-email-sorter": {"email-drafting", "batch-extract", "table-cleanup", "ocr-understanding"}, - "resume-processor": {"ocr-understanding", "batch-extract", "table-cleanup", "interview-summary"}, -} - -// applySpecialistRuleFiles 给专员挂上岗位说明书初稿与技能绑定,就地修改 items。 -// -// 绑定会经过 model.ValidateAllowedSkills:种子数据里的 key 拼错、重复, -// 都在启动时暴露成错误,而不是写进库等运行时才发现。 -func applySpecialistRuleFiles(items []model.Specialist) error { - for i := range items { - key := items[i].Key - if md, ok := specialistRuleFileDrafts[key]; ok { - items[i].RuleFileMarkdown = md - } - keys, ok := specialistSkillBindings[key] - if !ok { - continue - } - if err := model.ValidateAllowedSkills(keys); err != nil { - return fmt.Errorf("专员 %s 的技能绑定非法: %w", key, err) - } - raw, err := model.MarshalAllowedSkills(keys) - if err != nil { - return fmt.Errorf("专员 %s 的技能绑定序列化失败: %w", key, err) - } - items[i].AllowedSkills = raw - } - return nil -} - -// logSpecialistRuleFileCoverage 启动时报告覆盖情况。 -// 岗位说明书是「专员说话不一样」的唯一来源,缺了就等于没配—— -// 但它不影响服务可用,所以只提示不阻断。 -func logSpecialistRuleFileCoverage() { - var total, withRule, withSkills int64 - DB.Model(&model.Specialist{}).Where("state <> ?", "system").Count(&total) - DB.Model(&model.Specialist{}).Where("state <> ? AND rule_file_markdown <> ''", "system").Count(&withRule) - DB.Model(&model.Specialist{}).Where("state <> ? AND allowed_skills <> ''", "system").Count(&withSkills) - log.Printf("[OK] 专员岗位说明书 %d/%d,技能绑定 %d/%d", withRule, total, withSkills, total) -} diff --git a/eai_agentplatform/backend-go/internal/store/seed_specialist_rules_test.go b/eai_agentplatform/backend-go/internal/store/seed_specialist_rules_test.go deleted file mode 100644 index 3c08edd..0000000 --- a/eai_agentplatform/backend-go/internal/store/seed_specialist_rules_test.go +++ /dev/null @@ -1,104 +0,0 @@ -package store - -import ( - "os" - "strings" - "testing" - - "eai_agentplatform/backend/internal/model" -) - -// seedSpecialistKeys 从 seedSpecialists 的字面量里抽专员 key。 -// 直接反射那份清单不方便(它建在函数体里),所以从源码文本抓—— -// 与 skill_keys_test.go 抓前端 staticSkillCatalog 是同一套路子。 -// -// 必须限定在 seedSpecialists 到 seedSkillDefinitions 之间:seed.go 里 -// 技能、动作清单也用 Key: 字面量,而其中 contract-review / report-generation -// 与专员 key 同名,全文件扫会把这些混进来,让测试失去意义。 -func seedSpecialistKeys(t *testing.T) map[string]bool { - t.Helper() - raw, err := os.ReadFile("seed.go") - if err != nil { - t.Fatalf("读 seed.go 失败: %v", err) - } - src := string(raw) - start := strings.Index(src, "func seedSpecialists()") - end := strings.Index(src, "func seedSkillDefinitions()") - if start < 0 || end <= start { - t.Fatal("定位 seedSpecialists 函数体失败 —— 函数改名了?") - } - src = src[start:end] - - keys := map[string]bool{} - for _, line := range strings.Split(src, "\n") { - trimmed := strings.TrimSpace(line) - if !strings.HasPrefix(trimmed, `Key:`) { - continue - } - // Key: "training-delivery", - parts := strings.SplitN(trimmed, `"`, 3) - if len(parts) < 3 { - continue - } - keys[parts[1]] = true - } - if len(keys) == 0 { - t.Fatal("没从 seed.go 抓到任何专员 key —— 抽取逻辑可能已失效") - } - return keys -} - -// TestSpecialistRuleFileKeysExist 守住岗位说明书 map 的 key 都是真实存在的专员 key。 -// -// applySpecialistRuleFiles 里用的是 `if _, ok := …; !ok { continue }`, -// 所以一旦专员 key 改名而这里没跟着改,岗位说明书会**静默**地不再挂上去—— -// 专员照常能选、能聊,只是说话又变回一个样。这个测试就是为了让这种静默失败变成红灯。 -func TestSpecialistRuleFileKeysExist(t *testing.T) { - existing := seedSpecialistKeys(t) - - for key := range specialistRuleFileDrafts { - if !existing[key] { - t.Errorf("岗位说明书写了 %q,但 seed.go 里没有这个专员 key(改名后忘了同步?)", key) - } - } - for key := range specialistSkillBindings { - if !existing[key] { - t.Errorf("技能绑定写了 %q,但 seed.go 里没有这个专员 key(改名后忘了同步?)", key) - } - } -} - -// TestSpecialistSkillBindingsValid 确认种子里绑的 key 都是合法技能 key。 -// 运行期 applySpecialistRuleFiles 也会校验(启动即报错),这里让它在测试阶段就红。 -func TestSpecialistSkillBindingsValid(t *testing.T) { - for key, skills := range specialistSkillBindings { - if err := model.ValidateAllowedSkills(skills); err != nil { - t.Errorf("专员 %s 的技能绑定非法: %v", key, err) - } - if len(skills) == 0 { - t.Errorf("专员 %s 挂了空绑定,应从 map 里删掉而不是留空切片", key) - } - } -} - -// TestSpecialistRuleFileDraftsNonEmpty 防止占位符式的空正文混进来。 -func TestSpecialistRuleFileDraftsNonEmpty(t *testing.T) { - for key, md := range specialistRuleFileDrafts { - body := strings.TrimSpace(md) - if body == "" { - t.Errorf("专员 %s 的岗位说明书是空的", key) - continue - } - // 首行应是 Markdown 一级标题,注入 prompt 时靠它分隔各段 - if !strings.HasPrefix(body, "# ") { - t.Errorf("专员 %s 的岗位说明书首行不是一级标题,实际: %q", key, firstLine(body)) - } - } -} - -func firstLine(s string) string { - if i := strings.IndexByte(s, '\n'); i >= 0 { - return s[:i] - } - return s -} diff --git a/eai_agentplatform/backend-go/internal/store/seed_specialist_upsert_test.go b/eai_agentplatform/backend-go/internal/store/seed_specialist_upsert_test.go deleted file mode 100644 index 078087a..0000000 --- a/eai_agentplatform/backend-go/internal/store/seed_specialist_upsert_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package store - -import ( - "path/filepath" - "testing" - - "github.com/glebarez/sqlite" - "gorm.io/gorm" - "gorm.io/gorm/logger" - - "eai_agentplatform/backend/internal/model" -) - -// withTempDB 把全局 DB 换成临时库跑 fn,结束后还原。 -// seedSpecialists 直接使用全局 DB,只能这样测。 -func withTempDB(t *testing.T, fn func()) { - t.Helper() - dbPath := filepath.Join(t.TempDir(), "seed.db") - db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), - }) - if err != nil { - t.Fatalf("打开测试库失败: %v", err) - } - if err := db.AutoMigrate(&model.Specialist{}, &model.SkillDefinition{}, &model.ActionDefinition{}); err != nil { - t.Fatalf("迁移失败: %v", err) - } - - prev := DB - DB = db - t.Cleanup(func() { DB = prev }) - - fn() -} - -// TestSeedSpecialistsFillsRuleFileAndSkills 确认播种后岗位说明书与技能绑定都落库了。 -func TestSeedSpecialistsFillsRuleFileAndSkills(t *testing.T) { - withTempDB(t, func() { - if err := seedSpecialists(); err != nil { - t.Fatalf("seedSpecialists 失败: %v", err) - } - - var items []model.Specialist - if err := DB.Find(&items).Error; err != nil { - t.Fatalf("查询专员失败: %v", err) - } - for _, item := range items { - // 新加专员却忘了写岗位说明书,是「专员说话又一个样」的复发路径, - // 所以显式提示;但它只是半成品状态,不该弄红测试。 - if _, ok := specialistRuleFileDrafts[item.Key]; !ok { - t.Logf("提示:专员 %s 还没写岗位说明书(见 seed_specialist_rules.go)", item.Key) - } - } - - for _, item := range items { - if item.RuleFileMarkdown == "" { - t.Errorf("专员 %s 没有岗位说明书", item.Key) - } - if item.AllowedSkills == "" { - t.Errorf("专员 %s 没有技能绑定", item.Key) - continue - } - keys := model.ParseAllowedSkills(item.AllowedSkills) - if len(keys) == 0 { - t.Errorf("专员 %s 的技能绑定解析不出内容: %q", item.Key, item.AllowedSkills) - continue - } - if err := model.ValidateAllowedSkills(keys); err != nil { - t.Errorf("专员 %s 落库的技能绑定非法: %v", item.Key, err) - } - // 绑定要能反查到前端技能目录里的 key - for _, k := range keys { - if !model.ValidSkillKeys[k] { - t.Errorf("专员 %s 绑定了未知技能 %q", item.Key, k) - } - } - } - }) -} - -// TestSeedSpecialistsDoesNotOverwriteEditedRuleFile 守住「存在则只补空字段」。 -// -// 管理员在后台改过岗位说明书后,重启服务不能让种子把改动覆盖回去—— -// 这是 seed.go 通篇的约定,新增的两个字段必须一并遵守。 -func TestSeedSpecialistsDoesNotOverwriteEditedRuleFile(t *testing.T) { - withTempDB(t, func() { - if err := seedSpecialists(); err != nil { - t.Fatalf("首次播种失败: %v", err) - } - - const editedRule = "# 我改过的岗位说明书\n\n只补空字段,不许覆盖我。\n" - const editedSkills = `["contract-brief"]` - - if err := DB.Model(&model.Specialist{}). - Where("key = ?", "contract-review"). - Updates(map[string]any{ - "rule_file_markdown": editedRule, - "allowed_skills": editedSkills, - }).Error; err != nil { - t.Fatalf("模拟管理员编辑失败: %v", err) - } - - if err := seedSpecialists(); err != nil { - t.Fatalf("二次播种失败: %v", err) - } - - var item model.Specialist - if err := DB.Where("key = ?", "contract-review").First(&item).Error; err != nil { - t.Fatalf("重查专员失败: %v", err) - } - if item.RuleFileMarkdown != editedRule { - t.Errorf("二次播种覆盖了管理员改过的岗位说明书:\n实际: %q\n期望: %q", - item.RuleFileMarkdown, editedRule) - } - if item.AllowedSkills != editedSkills { - t.Errorf("二次播种覆盖了管理员改过的技能绑定: 实际 %q,期望 %q", - item.AllowedSkills, editedSkills) - } - }) -} diff --git a/eai_agentplatform/backend-go/internal/xapps/api/handlers.go b/eai_agentplatform/backend-go/internal/xapps/api/handlers.go new file mode 100644 index 0000000..0db4328 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/xapps/api/handlers.go @@ -0,0 +1,530 @@ +package xappapi + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + "eai_agentplatform/backend/internal/jsonutil" + "eai_agentplatform/backend/internal/middleware" + "eai_agentplatform/backend/internal/store" + "eai_agentplatform/backend/internal/web" + xappdefs "eai_agentplatform/backend/internal/xapps/model" +) + +type definitionReq struct { + Key string `json:"key"` + Label string `json:"label"` + Badge string `json:"badge"` + Kind string `json:"kind"` + MarketTag string `json:"market_tag"` + XAppMode string `json:"xapp_mode"` + Tier string `json:"tier"` + Source string `json:"source"` + Color string `json:"color"` + IconText string `json:"icon_text"` + CoverTone string `json:"cover_tone"` + Summary string `json:"summary"` + Description string `json:"description"` + OpenRoute string `json:"open_route"` + SpecialistKey string `json:"specialist_key"` + SkillKey string `json:"skill_key"` + DefaultPrompt string `json:"default_prompt"` + PromptsJSON string `json:"prompts_json"` + TagsJSON string `json:"tags_json"` + InstallState string `json:"install_state"` + ExposedToUser bool `json:"exposed_to_user"` + State string `json:"state"` + SortOrder int `json:"sort_order"` +} + +func normalizeDefinitionReq(req *definitionReq) { + req.Key = strings.TrimSpace(req.Key) + req.Label = strings.TrimSpace(req.Label) + req.Badge = strings.TrimSpace(req.Badge) + req.Kind = strings.TrimSpace(req.Kind) + req.MarketTag = strings.TrimSpace(req.MarketTag) + req.XAppMode = strings.TrimSpace(req.XAppMode) + req.Tier = strings.TrimSpace(req.Tier) + req.Source = strings.TrimSpace(req.Source) + req.Color = strings.TrimSpace(req.Color) + req.IconText = strings.TrimSpace(req.IconText) + req.CoverTone = strings.TrimSpace(req.CoverTone) + req.Summary = strings.TrimSpace(req.Summary) + req.Description = strings.TrimSpace(req.Description) + req.OpenRoute = strings.TrimSpace(req.OpenRoute) + req.SpecialistKey = strings.TrimSpace(req.SpecialistKey) + req.SkillKey = strings.TrimSpace(req.SkillKey) + req.DefaultPrompt = strings.TrimSpace(req.DefaultPrompt) + req.PromptsJSON = strings.TrimSpace(req.PromptsJSON) + req.TagsJSON = strings.TrimSpace(req.TagsJSON) + req.InstallState = strings.TrimSpace(req.InstallState) + req.State = strings.TrimSpace(req.State) +} + +func normalizeXAppMode(req *definitionReq) { + raw := strings.ToLower(req.XAppMode) + switch raw { + case "", "worker", "dw", "direct": + req.XAppMode = "direct" + case "adw", "orchestrated", "workflow", "flow": + req.XAppMode = "orchestrated" + default: + req.XAppMode = raw + } +} + +func validateDefinitionReq(req *definitionReq) *web.AppError { + normalizeDefinitionReq(req) + normalizeXAppMode(req) + if req.Key == "" || req.Label == "" { + return web.NewBadRequest("key、label 为必填") + } + if req.XAppMode != "direct" && req.XAppMode != "orchestrated" { + return web.NewBadRequest("xapp_mode 只能是 direct 或 orchestrated") + } + if req.Tier == "" { + req.Tier = "business" + } + if req.Source == "" { + req.Source = "eai" + } + if req.InstallState == "" { + req.InstallState = "installed" + } + if req.State == "" { + req.State = "active" + } + if req.State != "active" && req.State != "inactive" { + return web.NewBadRequest("state 只能是 active 或 inactive") + } + if !jsonutil.ValidateStringArrayJSON(req.PromptsJSON) { + return web.NewBadRequest("prompts_json 必须是字符串数组") + } + if !jsonutil.ValidateStringArrayJSON(req.TagsJSON) { + return web.NewBadRequest("tags_json 必须是字符串数组") + } + return nil +} + +func ListXAppDefinitions(c *gin.Context) { + q := store.DB.Model(&xappdefs.XAppDefinition{}) + 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 []xappdefs.XAppDefinition + 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 GetXAppDefinitionByKey(c *gin.Context) { + key := strings.TrimSpace(c.Param("key")) + if key == "" { + web.Fail(c, web.NewBadRequest("应用 key 不能为空")) + return + } + var item xappdefs.XAppDefinition + if err := store.DB.Where("key = ?", key).First(&item).Error; err != nil { + web.Fail(c, web.NewNotFoundError("应用定义不存在")) + return + } + web.OK(c, item) +} + +func CreateXAppDefinition(c *gin.Context) { + var req definitionReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if appErr := validateDefinitionReq(&req); appErr != nil { + web.Fail(c, appErr) + return + } + item := xappdefs.XAppDefinition{ + Key: req.Key, + Label: req.Label, + Badge: req.Badge, + Kind: req.Kind, + MarketTag: req.MarketTag, + XAppMode: req.XAppMode, + Tier: req.Tier, + Source: req.Source, + Color: req.Color, + IconText: req.IconText, + CoverTone: req.CoverTone, + Summary: req.Summary, + Description: req.Description, + OpenRoute: req.OpenRoute, + SpecialistKey: req.SpecialistKey, + SkillKey: req.SkillKey, + DefaultPrompt: req.DefaultPrompt, + PromptsJSON: req.PromptsJSON, + TagsJSON: req.TagsJSON, + InstallState: req.InstallState, + ExposedToUser: req.ExposedToUser, + 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 UpdateXAppDefinition(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var item xappdefs.XAppDefinition + if err := store.DB.First(&item, id).Error; err != nil { + web.Fail(c, web.NewNotFoundError("应用定义不存在")) + return + } + var req definitionReq + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + if appErr := validateDefinitionReq(&req); appErr != nil { + web.Fail(c, appErr) + return + } + item.Key = req.Key + item.Label = req.Label + item.Badge = req.Badge + item.Kind = req.Kind + item.MarketTag = req.MarketTag + item.XAppMode = req.XAppMode + item.Tier = req.Tier + item.Source = req.Source + item.Color = req.Color + item.IconText = req.IconText + item.CoverTone = req.CoverTone + item.Summary = req.Summary + item.Description = req.Description + item.OpenRoute = req.OpenRoute + item.SpecialistKey = req.SpecialistKey + item.SkillKey = req.SkillKey + item.DefaultPrompt = req.DefaultPrompt + item.PromptsJSON = req.PromptsJSON + item.TagsJSON = req.TagsJSON + item.InstallState = req.InstallState + item.ExposedToUser = req.ExposedToUser + 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 DeleteXAppDefinition(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + var item xappdefs.XAppDefinition + 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}) +} + +const ( + maxFavoriteXAppCount = 24 + maxRecentXAppCount = 8 + maxCustomXAppCount = 32 + maxCustomTagCount = 6 +) + +type centerCustomXApp 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:"install_state"` + SkillKey string `json:"skill_key"` + CreatedAt string `json:"created_at"` + IconText string `json:"icon_text"` + CoverTone string `json:"cover_tone"` + IsCustomXApp bool `json:"is_custom_xapp"` +} + +type centerPayload struct { + FavoriteKeys []string `json:"favorite_keys"` + RecentKeys []string `json:"recent_keys"` + CustomXApps []centerCustomXApp `json:"custom_xapps"` +} + +func GetMyXAppCenter(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + row, err := findUserXAppCenter(user.ID) + if err != nil { + web.Fail(c, web.NewBadRequest("读取应用中心失败")) + return + } + web.OK(c, normalizeXAppCenterPayload(row)) +} + +func UpdateMyXAppCenter(c *gin.Context) { + user := middleware.CurrentUser(c) + if user == nil { + web.Fail(c, web.NewAuthError("未登录")) + return + } + var req centerPayload + if err := c.ShouldBindJSON(&req); err != nil { + web.Fail(c, web.NewBadRequest("请求参数错误")) + return + } + payload := normalizeXAppCenterPayload(&xappdefs.UserXAppCenter{ + FavoriteKeys: mustJSONXAppCenter(req.FavoriteKeys), + RecentKeys: mustJSONXAppCenter(req.RecentKeys), + CustomXApps: mustJSONXAppCenter(req.CustomXApps), + }) + row, err := findUserXAppCenter(user.ID) + if err != nil { + web.Fail(c, web.NewBadRequest("读取应用中心失败")) + return + } + if row == nil { + row = &xappdefs.UserXAppCenter{UserID: user.ID} + } + row.FavoriteKeys = mustJSONXAppCenter(payload.FavoriteKeys) + row.RecentKeys = mustJSONXAppCenter(payload.RecentKeys) + row.CustomXApps = mustJSONXAppCenter(payload.CustomXApps) + if err := store.DB.Save(row).Error; err != nil { + web.Fail(c, web.NewBadRequest("保存应用中心失败")) + return + } + web.OK(c, payload) +} + +func findUserXAppCenter(userID uint) (*xappdefs.UserXAppCenter, error) { + var row xappdefs.UserXAppCenter + 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 normalizeXAppCenterPayload(row *xappdefs.UserXAppCenter) centerPayload { + if row == nil { + return centerPayload{ + FavoriteKeys: []string{}, + RecentKeys: []string{}, + CustomXApps: []centerCustomXApp{}, + } + } + var favorites []string + var recents []string + var customXApps []centerCustomXApp + _ = json.Unmarshal([]byte(strings.TrimSpace(row.FavoriteKeys)), &favorites) + _ = json.Unmarshal([]byte(strings.TrimSpace(row.RecentKeys)), &recents) + _ = json.Unmarshal([]byte(strings.TrimSpace(row.CustomXApps)), &customXApps) + return centerPayload{ + FavoriteKeys: normalizeStringList(favorites, maxFavoriteXAppCount), + RecentKeys: normalizeStringList(recents, maxRecentXAppCount), + CustomXApps: normalizeCustomXApps(customXApps), + } +} + +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 normalizeCustomXApps(items []centerCustomXApp) []centerCustomXApp { + result := make([]centerCustomXApp, 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-xapp-%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, centerCustomXApp{ + 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), + IsCustomXApp: true, + }) + if len(result) >= maxCustomXAppCount { + 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 mustJSONXAppCenter(value any) string { + data, err := json.Marshal(value) + if err != nil { + return "[]" + } + return string(data) +} + +func parseID(c *gin.Context, name string) (uint, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || id == 0 { + web.Fail(c, web.NewBadRequest("无效的 "+name)) + return 0, false + } + return uint(id), true +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/eai_agentplatform/backend-go/internal/xapps/core/contracts.go b/eai_agentplatform/backend-go/internal/xapps/core/contracts.go new file mode 100644 index 0000000..4a89e9b --- /dev/null +++ b/eai_agentplatform/backend-go/internal/xapps/core/contracts.go @@ -0,0 +1,16 @@ +package xappcore + +type SharedReferencePolicy string + +const ( + SharedReferenceReadonlyHistory SharedReferencePolicy = "readonly_history" + SharedReferenceTombstone SharedReferencePolicy = "convert_to_tombstone" + SharedReferenceDetach SharedReferencePolicy = "detach_reference" + SharedReferenceBlockUninstall SharedReferencePolicy = "block_uninstall" +) + +type UninstallMeta struct { + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} diff --git a/eai_agentplatform/backend-go/internal/xapps/core/manifest.go b/eai_agentplatform/backend-go/internal/xapps/core/manifest.go new file mode 100644 index 0000000..f3e6f79 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/xapps/core/manifest.go @@ -0,0 +1,17 @@ +package xappcore + +type Manifest struct { + Key string + Label string + OwnedDefinitionKeys []string + OwnedCatalogEntries []string + ReferencePolicy SharedReferencePolicy +} + +func (m Manifest) UninstallMeta() UninstallMeta { + return UninstallMeta{ + OwnedDefinitionKeys: append([]string(nil), m.OwnedDefinitionKeys...), + OwnedCatalogEntries: append([]string(nil), m.OwnedCatalogEntries...), + ReferencePolicy: m.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/xapps/core/registry.go b/eai_agentplatform/backend-go/internal/xapps/core/registry.go new file mode 100644 index 0000000..0624126 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/xapps/core/registry.go @@ -0,0 +1,36 @@ +package xappcore + +type Registry struct { + manifests []Manifest + byKey map[string]Manifest +} + +func NewRegistry(manifests ...Manifest) *Registry { + items := make([]Manifest, 0, len(manifests)) + byKey := make(map[string]Manifest, len(manifests)) + for _, manifest := range manifests { + if manifest.Key == "" { + continue + } + items = append(items, manifest) + byKey[manifest.Key] = manifest + } + return &Registry{manifests: items, byKey: byKey} +} + +func (r *Registry) Manifests() []Manifest { + if r == nil { + return nil + } + items := make([]Manifest, len(r.manifests)) + copy(items, r.manifests) + return items +} + +func (r *Registry) ByKey(key string) (Manifest, bool) { + if r == nil { + return Manifest{}, false + } + item, ok := r.byKey[key] + return item, ok +} diff --git a/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go b/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go new file mode 100644 index 0000000..7f4e6fc --- /dev/null +++ b/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go @@ -0,0 +1,16 @@ +package xappcore + +type UninstallPreview struct { + DefinitionKeys []string + CatalogEntries []string + Policy SharedReferencePolicy +} + +func BuildUninstallPreview(manifest Manifest) UninstallPreview { + meta := manifest.UninstallMeta() + return UninstallPreview{ + DefinitionKeys: meta.OwnedDefinitionKeys, + CatalogEntries: meta.OwnedCatalogEntries, + Policy: meta.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/xapps/model/model.go b/eai_agentplatform/backend-go/internal/xapps/model/model.go new file mode 100644 index 0000000..91c63ee --- /dev/null +++ b/eai_agentplatform/backend-go/internal/xapps/model/model.go @@ -0,0 +1,38 @@ +package xappmodel + +import "time" + +// XAppDefinition 面向用户的一键应用目录定义。 +// 它是“成品入口”这一层:决定应用怎么展示、默认挂哪个专员/技能、打开后走哪条路。 +type XAppDefinition 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"` + Badge string `gorm:"size:32;default:''" json:"badge"` + Kind string `gorm:"size:64;default:''" json:"kind"` + MarketTag string `gorm:"column:market_tag;size:32;default:'';index" json:"market_tag"` + XAppMode string `gorm:"size:16;default:'';index" json:"xapp_mode"` + Tier string `gorm:"size:16;default:'';index" json:"tier"` + Source string `gorm:"size:32;not null;default:eai;index" json:"source"` // eai / custom / imported + Color string `gorm:"size:32;default:''" json:"color"` + IconText string `gorm:"column:icon_text;size:16;default:''" json:"icon_text"` + CoverTone string `gorm:"column:cover_tone;type:text" json:"cover_tone"` + Summary string `gorm:"type:text" json:"summary"` + Description string `gorm:"type:text" json:"description"` + OpenRoute string `gorm:"column:open_route;size:256;default:''" json:"open_route"` + SpecialistKey string `gorm:"column:specialist_key;size:64;default:'';index" json:"specialist_key"` + SkillKey string `gorm:"column:skill_key;size:64;default:'';index" json:"skill_key"` + DefaultPrompt string `gorm:"column:default_prompt;type:text" json:"default_prompt"` + PromptsJSON string `gorm:"column:prompts_json;type:text" json:"prompts_json"` + TagsJSON string `gorm:"column:tags_json;type:text" json:"tags_json"` + InstallState string `gorm:"column:install_state;size:16;not null;default:installed;index" json:"install_state"` + ExposedToUser bool `gorm:"column:exposed_to_user;not null;default:true;index" json:"exposed_to_user"` + State string `gorm:"size:16;not null;default:active;index" json:"state"` // active / inactive + SortOrder int `gorm:"not null;default:0;index" json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (XAppDefinition) TableName() string { return "xapp_definition" } diff --git a/eai_agentplatform/backend-go/internal/model/user_app_center.go b/eai_agentplatform/backend-go/internal/xapps/model/user_center.go similarity index 54% rename from eai_agentplatform/backend-go/internal/model/user_app_center.go rename to eai_agentplatform/backend-go/internal/xapps/model/user_center.go index 62c5af4..106e0a8 100644 --- a/eai_agentplatform/backend-go/internal/model/user_app_center.go +++ b/eai_agentplatform/backend-go/internal/xapps/model/user_center.go @@ -1,16 +1,16 @@ -package model +package xappmodel import "time" -// UserAppCenter 保存用户自己的应用中心状态,用于同步我的应用、收藏和最近使用。 -type UserAppCenter struct { +// UserXAppCenter 保存用户自己的应用中心状态,用于同步我的应用、收藏和最近使用。 +type UserXAppCenter 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"` + CustomXApps string `gorm:"column:custom_xapps;type:text;not null;default:''" json:"custom_xapps"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } -func (UserAppCenter) TableName() string { return "user_app_center" } +func (UserXAppCenter) TableName() string { return "user_xapp_center" } diff --git a/eai_agentplatform/frontend/public/studio-design-mockup.html b/eai_agentplatform/frontend/public/studio-design-mockup.html deleted file mode 100644 index 26afb94..0000000 --- a/eai_agentplatform/frontend/public/studio-design-mockup.html +++ /dev/null @@ -1,714 +0,0 @@ - - - - - -StudioPage 视觉设计稿 - - - - -
- -
- 切换视图 - - - - - - - 参考 Langflow / Dify / Coze 设计体系 -
- - -
-
- -
- 邮箱简历整理 DW -
- 3 节点 - 2 边 -
-
-
- - -
-
- - -
- - - - -
-
- -
-
-
信源
-
招聘邮箱
-
拉取收件箱的简历投递邮件
-
-
-
连接器
-
163 邮箱 IMAP
-
已绑定 IMAP 协议和认证
-
-
-
动作
-
识别简历邮件
-
抽取候选人信息和附件
-
-
-
结果
-
简历表
-
写入企微智能表格
-
-
- - - - - - - - - - - - - -
- - -
-
-
从左侧拖入节点,或点击节点编辑
-
-
-
- - -
-

节点检查器

-
- -
-
-
节点信息
-
-
当前节点
-
- - 招聘邮箱 -
-
-
-
节点类型
-
信源 · Input
-
-
-
节点标题
- -
-
-
节点说明
- -
-
-
节点位置
-
X 80/Y 80
-
-
-
-
连接关系
-
-
上游节点
-
—←无上游
-
-
-
下游节点
-
163 邮箱 IMAP→连接器
-
-
-
- - -
-
-
- - - -
- - -
-
-
-

DW 设置

- -
-
-
-
-
DW 名称
- -
-
-
DW Key
- -
-
-
路由
- -
-
-
工作形态
- -
-
-
摘要
- -
-
-
编译预览
-
- 信源 1 - 动作 3 - 结果 1 - 权限 2 -
-
连接器范围:163 邮箱 IMAP
-
-
-
- -
-
- - -
- StudioPage 视觉设计稿 · 顶部标签可切换不同状态 - 间距 4px 网格 · 锌灰单色系统 + 5 色语义节点 -
- - - - - \ No newline at end of file diff --git a/eai_agentplatform/frontend/src/api/ai.js b/eai_agentplatform/frontend/src/api/ai.js index 5b4bcf6..c9d0924 100644 --- a/eai_agentplatform/frontend/src/api/ai.js +++ b/eai_agentplatform/frontend/src/api/ai.js @@ -8,11 +8,6 @@ export function quickAction(actionId, params) { return http.post('/ai-chat/quick-action', { action_id: actionId, params }) } -// 本人 AI 点数 + 用量(PathCoach 面板数据源) -export function getAiMe() { - return http.get('/ai/me') -} - // 用量聚合(管理员 = 全量;员工 = 本人,后端按角色过滤) export function getAiUsage(params) { return http.get('/ai/usage', { params }) diff --git a/eai_agentplatform/frontend/src/api/appCenter.js b/eai_agentplatform/frontend/src/api/appCenter.js deleted file mode 100644 index d6ef1e3..0000000 --- a/eai_agentplatform/frontend/src/api/appCenter.js +++ /dev/null @@ -1,9 +0,0 @@ -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/appDefinition.js b/eai_agentplatform/frontend/src/api/appDefinition.js deleted file mode 100644 index da18037..0000000 --- a/eai_agentplatform/frontend/src/api/appDefinition.js +++ /dev/null @@ -1,9 +0,0 @@ -import http from './http' - -export function listAppDefinitions(params) { - return http.get('/apps', { params }) -} - -export function getAppDefinitionByKey(key) { - return http.get(`/apps/by-key/${key}`) -} diff --git a/eai_agentplatform/frontend/src/api/chat.js b/eai_agentplatform/frontend/src/api/chat.js deleted file mode 100644 index c5a6113..0000000 --- a/eai_agentplatform/frontend/src/api/chat.js +++ /dev/null @@ -1,5 +0,0 @@ -import http from './http' - -export function sendAssistantChatMessage(data) { - return http.post('/assistant/chat', data) -} diff --git a/eai_agentplatform/frontend/src/api/chatMessage.js b/eai_agentplatform/frontend/src/api/chatMessage.js new file mode 100644 index 0000000..3babf6e --- /dev/null +++ b/eai_agentplatform/frontend/src/api/chatMessage.js @@ -0,0 +1,5 @@ +import http from './http' + +export function sendChatMessage(data) { + return http.post('/chat/message', data) +} diff --git a/eai_agentplatform/frontend/src/api/exam.js b/eai_agentplatform/frontend/src/api/exam.js index 2720ca5..ce73241 100644 --- a/eai_agentplatform/frontend/src/api/exam.js +++ b/eai_agentplatform/frontend/src/api/exam.js @@ -5,8 +5,8 @@ export function listExams() { return http.get('/exam/list') } -export function examCover(id) { - return http.get('/exam/cover', { params: { id } }) +export function examCover(paperId) { + return http.get('/exam/cover', { params: { paper_id: paperId } }) } export function examStart(paperId) { diff --git a/eai_agentplatform/frontend/src/api/notification.js b/eai_agentplatform/frontend/src/api/notification.js deleted file mode 100644 index 69433ce..0000000 --- a/eai_agentplatform/frontend/src/api/notification.js +++ /dev/null @@ -1,17 +0,0 @@ -import http from './http' - -export function myNotifications(params) { - return http.get('/notifications', { params }) -} - -export function unreadCount() { - return http.get('/notifications/unread-count') -} - -export function markRead(id) { - return http.put(`/notifications/${id}/read`) -} - -export function markAllRead() { - return http.put('/notifications/read-all') -} diff --git a/eai_agentplatform/frontend/src/api/project.js b/eai_agentplatform/frontend/src/api/project.js index 0a8e617..37182cd 100644 --- a/eai_agentplatform/frontend/src/api/project.js +++ b/eai_agentplatform/frontend/src/api/project.js @@ -2,21 +2,21 @@ import http from './http' // 项目:任务的容器。归属由后端按登录用户定,前端不传 owner。 export function listProjects() { - return http.get('/worker/projects') + return http.get('/projects') } export function createProject(data = {}) { - return http.post('/worker/projects', data) + return http.post('/projects', data) } export function updateProject(id, data) { - return http.put(`/worker/projects/${id}`, data) + return http.put(`/projects/${id}`, data) } export function deleteProject(id) { - return http.delete(`/worker/projects/${id}`) + return http.delete(`/projects/${id}`) } export function listProjectTasks(id) { - return http.get(`/worker/projects/${id}/tasks`) + return http.get(`/projects/${id}/tasks`) } diff --git a/eai_agentplatform/frontend/src/api/taskRuntime.js b/eai_agentplatform/frontend/src/api/taskRuntime.js new file mode 100644 index 0000000..a3e2355 --- /dev/null +++ b/eai_agentplatform/frontend/src/api/taskRuntime.js @@ -0,0 +1,54 @@ +import http from './http' + +export function listTasks(params) { + return http.get('/tasks', { params }) +} + +// 「我的任务」:跨专员,归属由后端按登录用户定,前端不传 owner。 +export function listMyTasks() { + return http.get('/my/tasks') +} + +export function createMyTask(data = {}) { + return http.post('/my/tasks', data) +} + +export function updateMyTask(id, data) { + return http.put(`/my/tasks/${id}`, data) +} + +export function deleteMyTask(id) { + return http.delete(`/my/tasks/${id}`) +} + +export function getTaskDetail(id) { + return http.get(`/tasks/${id}`) +} + +export function updateTaskStatus(id, data) { + return http.put(`/tasks/${id}/status`, data) +} + +export function createTask(data) { + return http.post('/tasks', data) +} + +export function updateTask(id, data) { + return http.put(`/tasks/${id}`, data) +} + +export function deleteTask(id) { + return http.delete(`/tasks/${id}`) +} + +export function getArtifactDetail(id) { + return http.get(`/artifacts/${id}`) +} + +export function updateArtifactStatus(id, data) { + return http.put(`/artifacts/${id}/status`, data) +} + +export function executeTaskAction(id, data) { + return http.post(`/tasks/${id}/actions`, data) +} diff --git a/eai_agentplatform/frontend/src/api/worker.js b/eai_agentplatform/frontend/src/api/worker.js deleted file mode 100644 index 919d49b..0000000 --- a/eai_agentplatform/frontend/src/api/worker.js +++ /dev/null @@ -1,54 +0,0 @@ -import http from './http' - -export function listWorkerTasks(params) { - return http.get('/worker/tasks', { params }) -} - -// 「我的任务」:跨专员,归属由后端按登录用户定,前端不传 owner。 -export function listMyTasks() { - return http.get('/worker/my-tasks') -} - -export function createMyTask(data = {}) { - return http.post('/worker/my-tasks', data) -} - -export function updateMyTask(id, data) { - return http.put(`/worker/my-tasks/${id}`, data) -} - -export function deleteMyTask(id) { - return http.delete(`/worker/my-tasks/${id}`) -} - -export function getWorkerTaskDetail(id) { - return http.get(`/worker/tasks/${id}`) -} - -export function updateWorkerTaskStatus(id, data) { - return http.put(`/worker/tasks/${id}/status`, data) -} - -export function createWorkerTask(data) { - return http.post('/worker/tasks', data) -} - -export function updateWorkerTask(id, data) { - return http.put(`/worker/tasks/${id}`, data) -} - -export function deleteWorkerTask(id) { - return http.delete(`/worker/tasks/${id}`) -} - -export function getWorkerArtifactDetail(id) { - return http.get(`/worker/artifacts/${id}`) -} - -export function updateWorkerArtifactStatus(id, data) { - return http.put(`/worker/artifacts/${id}/status`, data) -} - -export function executeWorkerTaskAction(id, data) { - return http.post(`/worker/tasks/${id}/actions`, data) -} diff --git a/eai_agentplatform/frontend/src/components/chat/ChatMessage.vue b/eai_agentplatform/frontend/src/components/chat/ChatMessage.vue deleted file mode 100644 index dfba545..0000000 --- a/eai_agentplatform/frontend/src/components/chat/ChatMessage.vue +++ /dev/null @@ -1,365 +0,0 @@ - - - - - diff --git a/eai_agentplatform/frontend/src/components/chat/CurrentObjectChip.vue b/eai_agentplatform/frontend/src/components/chat/CurrentObjectChip.vue index 15504f3..0701af0 100644 --- a/eai_agentplatform/frontend/src/components/chat/CurrentObjectChip.vue +++ b/eai_agentplatform/frontend/src/components/chat/CurrentObjectChip.vue @@ -16,10 +16,10 @@ import { computed, onMounted } from 'vue' import { storeToRefs } from 'pinia' import { useRoute, useRouter } from 'vue-router' -import { staticSkillCatalog } from '@/config/workbench' -import { useSkillCatalogStore } from '@/store/skillCatalog' -import { useSpecialistCatalogStore } from '@/store/specialistCatalog' -import { GENERAL_ASSISTANT_KEY, useWorkerRuntimeStore } from '@/store/workerRuntime' +import { getStaticSkillByKey } from '@/skills/core/builtin' +import { useSkillCatalogStore } from '@/skills/store/skillCatalog' +import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog' +import { GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime' import { openGeneralAssistantInWorkbench, openSpecialistInWorkbench, openSkillInWorkbench } from '@/utils/workbenchNavigation' const props = defineProps({ @@ -36,15 +36,16 @@ const props = defineProps({ const router = useRouter() const route = useRoute() -const workerRuntime = useWorkerRuntimeStore() +const taskRuntime = useTaskRuntimeStore() const skillCatalog = useSkillCatalogStore() const specialistCatalog = useSpecialistCatalogStore() const { currentFocusedObjectKind, + currentFocusedObjectKey, currentSpecialist, currentSpecialistKey, currentSkillKey, -} = storeToRefs(workerRuntime) +} = storeToRefs(taskRuntime) const runtimeSkillKey = computed(() => props.skillKey || currentSkillKey.value) const specialistEntry = computed(() => @@ -52,11 +53,11 @@ const specialistEntry = computed(() => ) const skillEntry = computed(() => - skillCatalog.getByKey(runtimeSkillKey.value) || staticSkillCatalog.find((skill) => skill.key === runtimeSkillKey.value) || null + skillCatalog.getByKey(runtimeSkillKey.value) || getStaticSkillByKey(runtimeSkillKey.value) ) const isGeneralAssistant = computed(() => { if (currentSpecialistKey.value === GENERAL_ASSISTANT_KEY) return true - return currentFocusedObjectKind.value === 'assistant' + return currentFocusedObjectKind.value === 'specialist' && currentFocusedObjectKey.value === GENERAL_ASSISTANT_KEY }) const kind = computed(() => { @@ -99,7 +100,7 @@ function openTarget() { openGeneralAssistantInWorkbench({ router, currentPath: route.path, - workerRuntime, + taskRuntime, }).catch((error) => { console.warn('open general assistant target failed', error) }) @@ -108,7 +109,7 @@ function openTarget() { openSpecialistInWorkbench({ router, currentPath: route.path, - workerRuntime, + taskRuntime, specialistKey: currentSpecialistKey.value, }).catch((error) => { console.warn('open specialist target failed', error) @@ -118,7 +119,7 @@ function openTarget() { openSkillInWorkbench({ router, currentPath: route.path, - workerRuntime, + taskRuntime, skillKey: runtimeSkillKey.value, }).catch((error) => { console.warn('open skill target failed', error) diff --git a/eai_agentplatform/frontend/src/components/chat/FileAttachment.vue b/eai_agentplatform/frontend/src/components/chat/FileAttachment.vue deleted file mode 100644 index f5f6c5d..0000000 --- a/eai_agentplatform/frontend/src/components/chat/FileAttachment.vue +++ /dev/null @@ -1,263 +0,0 @@ - - - - - diff --git a/eai_agentplatform/frontend/src/components/chat/HistorySidebar.vue b/eai_agentplatform/frontend/src/components/chat/HistorySidebar.vue deleted file mode 100644 index fbbbe0a..0000000 --- a/eai_agentplatform/frontend/src/components/chat/HistorySidebar.vue +++ /dev/null @@ -1,370 +0,0 @@ - - - - - diff --git a/eai_agentplatform/frontend/src/components/chat/PlusMenu.vue b/eai_agentplatform/frontend/src/components/chat/PlusMenu.vue index dabd222..49b8894 100644 --- a/eai_agentplatform/frontend/src/components/chat/PlusMenu.vue +++ b/eai_agentplatform/frontend/src/components/chat/PlusMenu.vue @@ -121,12 +121,12 @@ import { ElMessageBox } from 'element-plus' import { ArrowRight, Avatar, Check, Connection, EditPen, MagicStick, Plus, Search, Setting, UploadFilled, } from '@element-plus/icons-vue' -import { staticSkillCatalog } from '@/config/workbench' -import { listConnectors } from '@/api/connector' +import { getStaticSkillByKey } from '@/skills/core/builtin' +import { listConnectors } from '@/connectors/api/connector' import { useAuthStore } from '@/store/auth' -import { useSkillCatalogStore } from '@/store/skillCatalog' -import { useSpecialistCatalogStore } from '@/store/specialistCatalog' -import { GENERAL_ASSISTANT_KEY, useWorkerRuntimeStore } from '@/store/workerRuntime' +import { useSkillCatalogStore } from '@/skills/store/skillCatalog' +import { useSpecialistCatalogStore } from '@/specialists/store/specialistCatalog' +import { GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime' import { openGeneralAssistantInWorkbench, openSkillInWorkbench, openSpecialistInWorkbench } from '@/utils/workbenchNavigation' const props = defineProps({ @@ -160,7 +160,7 @@ const router = useRouter() const auth = useAuthStore() const skillCatalog = useSkillCatalogStore() const specialistCatalog = useSpecialistCatalogStore() -const workerRuntime = useWorkerRuntimeStore() +const taskRuntime = useTaskRuntimeStore() const isAdmin = computed(() => auth.isAdmin) const open = ref(false) const keyword = ref('') @@ -180,7 +180,7 @@ function routable(path) { } const generalAssistantItem = computed(() => { - const item = skillCatalog.getByKey('smart-assistant') || staticSkillCatalog.find((skill) => skill.key === 'smart-assistant') + const item = skillCatalog.getByKey('smart-assistant') || getStaticSkillByKey('smart-assistant') if (!item) return null return { key: GENERAL_ASSISTANT_KEY, @@ -214,15 +214,15 @@ const entries = computed(() => { items: [ ...(generalAssistantItem.value ? [generalAssistantItem.value] : []), ...specialistCatalog.specialists - .filter((app) => routable(app.objectEntryRoute)) + .filter((specialist) => routable(specialist.objectEntryRoute)) // key 要在切换时用:选了专员就等于把当前任务改挂到它名下。 - .map((app) => ({ - key: app.key, - label: app.label, - caption: app.summary, - display_code: app.display_code, - eailogic_code: app.eailogic_code, - objectEntryRoute: app.objectEntryRoute, + .map((specialist) => ({ + key: specialist.key, + label: specialist.label, + caption: specialist.summary, + display_code: specialist.display_code, + eailogic_code: specialist.eailogic_code, + objectEntryRoute: specialist.objectEntryRoute, })), ], manage: { label: '专员市场', pageRoute: '/market' }, @@ -426,7 +426,7 @@ async function pickItem(item) { openGeneralAssistantInWorkbench({ router, currentPath: route.path, - workerRuntime, + taskRuntime, }).catch((error) => { console.warn('switch to general assistant failed', error) }) @@ -435,7 +435,7 @@ async function pickItem(item) { openSpecialistInWorkbench({ router, currentPath: route.path, - workerRuntime, + taskRuntime, specialistKey: item.key, }).catch((error) => { console.warn('attach specialist to current task failed', error) @@ -446,7 +446,7 @@ async function pickItem(item) { openSkillInWorkbench({ router, currentPath: route.path, - workerRuntime, + taskRuntime, skillKey: item.key, }).catch((error) => { console.warn('attach skill to current task failed', error) diff --git a/eai_agentplatform/frontend/src/components/chat/SkillStrip.vue b/eai_agentplatform/frontend/src/components/chat/SkillStrip.vue index 6f3ba54..6b847af 100644 --- a/eai_agentplatform/frontend/src/components/chat/SkillStrip.vue +++ b/eai_agentplatform/frontend/src/components/chat/SkillStrip.vue @@ -75,11 +75,11 @@ - - diff --git a/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue b/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue index 45301f7..39908cb 100644 --- a/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue +++ b/eai_agentplatform/frontend/src/components/chat/SpecialistPanel.vue @@ -3,10 +3,10 @@ 对话右栏:当前专员本次任务的工作流与产物。 数据全部来自后端 —— 步骤是专员的 action_records_json,产物是这条任务的 - worker_artifact,两边靠 action_key / created_by_run_id 对接。没有任何一步 + task_artifact,两边靠 action_key / created_by_run_id 对接。没有任何一步 是照着样张编出来的。 - 一件事必须写在最前面:**「进行中」那个蓝框是前端推断的**。后端 worker_run + 一件事必须写在最前面:**「进行中」那个蓝框是前端推断的**。后端 task_run 的 status 写进去时恒为 "done",没有任何字段能表达「正在跑哪一步」。所以要 靠推断(见 utils/specialistFlow.js 的 markFlowState),并且推断出来的那一步 在界面上要标出来,不能让它冒充实测进度。 @@ -155,13 +155,13 @@ diff --git a/eai_agentplatform/frontend/src/router/index.js b/eai_agentplatform/frontend/src/router/index.js index d86cbb9..5480710 100644 --- a/eai_agentplatform/frontend/src/router/index.js +++ b/eai_agentplatform/frontend/src/router/index.js @@ -1,11 +1,16 @@ import { createRouter, createWebHashHistory } from 'vue-router' -import ExamTabsView from '@/views/exam/ExamTabsView.vue' +import { + INTERNAL_EXAM_MODULE_ROUTES, + INTERNAL_EXAM_XAPP_ROOT, + INTERNAL_TRAINING_MODULE_ROUTES, + INTERNAL_TRAINING_XAPP_ROOT, +} from '@/xapps/config/xappRoutes' const routes = [ { path: '/login', - name: 'Login', - component: () => import('@/views/Login.vue'), + name: 'LoginPage', + component: () => import('@/views/LoginPage.vue'), }, { path: '/', @@ -13,57 +18,63 @@ const routes = [ redirect: '/home', children: [ // 通用助手 is the front door; /home is its canonical URL. - { path: 'home', name: 'Home', component: () => import('@/views/workbench/SmartAssistantPage.vue') }, + { path: 'home', name: 'SmartAssistantPage', component: () => import('@/views/workbench/SmartAssistantPage.vue') }, // 项目:装着任务的容器。列表是落地页,详情页带「任务」tab。 - { path: 'projects', name: 'Projects', component: () => import('@/views/workbench/ProjectsPage.vue') }, - { path: 'projects/:id', name: 'ProjectDetail', component: () => import('@/views/workbench/ProjectDetailPage.vue') }, - { path: 'long-apps', name: 'LongApps', component: () => import('@/views/workbench/LongAppPage.vue') }, + { path: 'projects', name: 'ProjectsPage', component: () => import('@/views/workbench/ProjectsPage.vue') }, + { path: 'projects/:id', name: 'ProjectDetailPage', component: () => import('@/views/workbench/ProjectDetailPage.vue') }, + { path: 'xapps', name: 'XAppDirectoryPage', component: () => import('@/xapps/views/XAppDirectoryPage.vue') }, { path: 'overview', redirect: '/projects' }, - { path: 'my/settings', name: 'MySettings', component: () => import('@/views/me/MySettingsPage.vue') }, - { path: 'admin', name: 'AdminOverview', component: () => import('@/views/system/AdminOverviewPage.vue'), meta: { requiresAdmin: true } }, - { path: 'studio', name: 'Studio', component: () => import('@/views/workbench/StudioPage.vue'), meta: { requiresAdmin: true } }, - { path: 'console', name: 'Console', component: () => import('@/views/workbench/ConsolePage.vue'), meta: { requiresAdmin: true } }, - { path: 'catalog/specialists', name: 'CapabilityCatalogSpecialists', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') }, - { path: 'catalog/skills', name: 'CapabilityCatalogSkills', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') }, - { path: 'catalog/apps', name: 'CapabilityCatalogApps', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') }, - { path: 'catalog/connectors', name: 'CapabilityCatalogConnectors', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') }, - { path: 'catalog/:catalogType/:key', name: 'CapabilityCatalogDetail', component: () => import('@/views/workbench/CapabilityCatalogPage.vue') }, - { path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') }, - { path: 'docs-center', name: 'DocumentCenter', component: () => import('@/views/workbench/DocumentCenterPage.vue') }, - { path: 'knowledge-management', name: 'KnowledgeManagement', component: () => import('@/views/workbench/KnowledgeManagementPage.vue'), meta: { requiresAdmin: true } }, - { path: 'market', name: 'Market', component: () => import('@/views/workbench/MarketPage.vue'), meta: { requiresAdmin: true } }, - { path: 'company-train', name: 'CompanyTrain', component: () => import('@/views/companyTrain/CompanyTrain.vue') }, - { path: 'products', name: 'ProductList', component: () => import('@/views/product/ProductList.vue') }, - { path: 'products/:id', name: 'ProductDetail', component: () => import('@/views/product/ProductDetail.vue') }, - { path: 'courses', name: 'CourseList', component: () => import('@/views/salesTrain/CourseList.vue') }, - { path: 'courses/:id', name: 'CourseDetail', component: () => import('@/views/salesTrain/CourseDetail.vue') }, - // 考试评估 — 父子路由:父路由带 tabs,子路由各页面 + { path: 'my/settings', name: 'MySettingsPage', component: () => import('@/views/me/MySettingsPage.vue') }, + { path: 'admin', name: 'AdminOverviewPage', component: () => import('@/views/system/AdminOverviewPage.vue'), meta: { requiresAdmin: true } }, + { path: 'studio', name: 'StudioPage', component: () => import('@/views/workbench/StudioPage.vue'), meta: { requiresAdmin: true } }, + { path: 'console', name: 'ConsolePage', component: () => import('@/views/workbench/ConsolePage.vue'), meta: { requiresAdmin: true } }, + { path: 'catalog/specialists', name: 'ObjectCatalogSpecialistsPage', component: () => import('@/specialists/views/SpecialistCatalogPage.vue') }, + { path: 'catalog/skills', name: 'ObjectCatalogSkillsPage', component: () => import('@/views/workbench/ObjectCatalogPage.vue') }, + { path: 'catalog/xapps', name: 'ObjectCatalogXAppsPage', component: () => import('@/views/workbench/ObjectCatalogPage.vue') }, + { path: 'catalog/connectors', name: 'ObjectCatalogConnectorsPage', component: () => import('@/views/workbench/ObjectCatalogPage.vue') }, + { path: 'catalog/specialists/:key', name: 'SpecialistDetailPage', component: () => import('@/specialists/views/SpecialistDetailRoutePage.vue') }, + { path: 'catalog/:catalogType/:key', name: 'ObjectCatalogDetailPage', component: () => import('@/views/workbench/ObjectCatalogDetailRoutePage.vue') }, + { path: 'knowledge-hub', name: 'KnowledgeHubPage', component: () => import('@/views/workbench/KnowledgeHubPage.vue') }, + { path: 'docs-center', name: 'DocumentCenterPage', component: () => import('@/views/workbench/DocumentCenterPage.vue') }, + { path: 'knowledge-management', name: 'KnowledgeManagementPage', component: () => import('@/views/workbench/KnowledgeManagementPage.vue'), meta: { requiresAdmin: true } }, + { path: 'market', name: 'MarketPage', component: () => import('@/views/workbench/MarketPage.vue'), meta: { requiresAdmin: true } }, { - path: 'exam', - component: ExamTabsView, - redirect: '/exam/self-test', + path: INTERNAL_TRAINING_XAPP_ROOT.slice(1), + component: () => import('@/xapps/views/TrainingXAppShell.vue'), + redirect: INTERNAL_TRAINING_MODULE_ROUTES.courses, children: [ - { path: 'self-test', name: 'ExamSelfTest', component: () => import('@/views/exam/ExamSelfTest.vue') }, - { path: 'formal', name: 'ExamFormal', component: () => import('@/views/exam/ExamFormal.vue') }, - { path: 'my-records', name: 'ExamMyRecord', component: () => import('@/views/exam/ExamMyRecord.vue') }, - { path: 'my-mistakes', name: 'MyMistakes', component: () => import('@/views/exam/MyMistakes.vue') }, - { path: 'my-certificates', name: 'MyCertificates', component: () => import('@/views/exam/MyCertificates.vue') }, - { path: 'my-position', name: 'MyPosition', component: () => import('@/views/exam/MyPosition.vue') }, - { path: 'leaderboard', name: 'Leaderboard', component: () => import('@/views/exam/Leaderboard.vue') }, + { path: 'courses', name: 'TrainingCourseListPage', component: () => import('@/views/salesTrain/CourseListPage.vue') }, + { path: 'courses/:id', name: 'TrainingCourseDetailPage', component: () => import('@/views/salesTrain/CourseDetailPage.vue') }, + { path: 'products', name: 'TrainingProductListPage', component: () => import('@/views/product/ProductListPage.vue') }, + { path: 'products/:id', name: 'TrainingProductDetailPage', component: () => import('@/views/product/ProductDetailPage.vue') }, + { path: 'company-intro', name: 'TrainingCompanyPage', component: () => import('@/views/companyTrain/CompanyTrainPage.vue') }, ], }, - // 学习档案独立(不在 tabs 内) - { path: 'exam/my-profile', name: 'MyProfile', component: () => import('@/views/exam/MyProfile.vue') }, - { path: 'knowledge/materials', name: 'MaterialManage', component: () => import('@/views/knowledge/MaterialManage.vue') }, - { path: 'knowledge/audit', name: 'MaterialAudit', component: () => import('@/views/knowledge/MaterialAudit.vue') }, - { path: 'knowledge/questions', name: 'ExamQuestionBank', component: () => import('@/views/knowledge/ExamQuestionBank.vue') }, - { path: 'knowledge/exam-records', name: 'ExamRecordManage', component: () => import('@/views/knowledge/ExamRecordManage.vue') }, - { path: 'system/positions', name: 'PositionManage', component: () => import('@/views/system/PositionManage.vue'), meta: { requiresAdmin: true } }, - { path: 'system/departments', name: 'DepartmentManage', component: () => import('@/views/system/DepartmentManage.vue'), meta: { requiresAdmin: true } }, - { path: 'system/users', name: 'UserManage', component: () => import('@/views/system/UserManage.vue'), meta: { requiresAdmin: true } }, - { path: 'system/company-config', name: 'CompanyConfig', component: () => import('@/views/system/CompanyConfigPage.vue'), meta: { requiresAdmin: true } }, - { path: 'system/config', name: 'SystemConfig', component: () => import('@/views/system/SystemConfigPage.vue'), meta: { requiresAdmin: true } }, - { path: 'system/ai-usage', name: 'AiUsage', component: () => import('@/views/system/AiUsage.vue'), meta: { requiresAdmin: true } }, + { + path: INTERNAL_EXAM_XAPP_ROOT.slice(1), + component: () => import('@/xapps/views/ExamXAppShell.vue'), + redirect: INTERNAL_EXAM_MODULE_ROUTES.selfTest, + children: [ + { path: 'self-test', name: 'ExamSelfTestPage', component: () => import('@/views/exam/ExamSelfTestPage.vue') }, + { path: 'formal', name: 'ExamFormalPage', component: () => import('@/views/exam/ExamFormalPage.vue') }, + { path: 'my-records', name: 'ExamMyRecordPage', component: () => import('@/views/exam/ExamMyRecordPage.vue') }, + { path: 'my-mistakes', name: 'MyMistakesPage', component: () => import('@/views/exam/MyMistakesPage.vue') }, + { path: 'my-certificates', name: 'MyCertificatesPage', component: () => import('@/views/exam/MyCertificatesPage.vue') }, + { path: 'my-position', name: 'MyPositionPage', component: () => import('@/views/exam/MyPositionPage.vue') }, + { path: 'leaderboard', name: 'LeaderboardPage', component: () => import('@/views/exam/LeaderboardPage.vue') }, + { path: 'my-profile', name: 'MyProfilePage', component: () => import('@/views/exam/MyProfilePage.vue') }, + ], + }, + { path: 'knowledge/materials', name: 'KnowledgeMaterialsPage', component: () => import('@/views/knowledge/KnowledgeMaterialsPage.vue') }, + { path: 'knowledge/audit', name: 'KnowledgeMaterialAuditPage', component: () => import('@/views/knowledge/KnowledgeMaterialAuditPage.vue') }, + { path: 'knowledge/questions', name: 'KnowledgeQuestionBankPage', component: () => import('@/views/knowledge/KnowledgeQuestionBankPage.vue') }, + { path: 'knowledge/exam-records', name: 'KnowledgeExamRecordsPage', component: () => import('@/views/knowledge/KnowledgeExamRecordsPage.vue') }, + { path: 'system/positions', name: 'PositionManagementPage', component: () => import('@/views/system/PositionManagementPage.vue'), meta: { requiresAdmin: true } }, + { path: 'system/departments', name: 'DepartmentManagementPage', component: () => import('@/views/system/DepartmentManagementPage.vue'), meta: { requiresAdmin: true } }, + { path: 'system/users', name: 'UserManagementPage', component: () => import('@/views/system/UserManagementPage.vue'), meta: { requiresAdmin: true } }, + { path: 'system/company-config', name: 'CompanyConfigPage', component: () => import('@/views/system/CompanyConfigPage.vue'), meta: { requiresAdmin: true } }, + { path: 'system/config', name: 'SystemConfigPage', component: () => import('@/views/system/SystemConfigPage.vue'), meta: { requiresAdmin: true } }, + { path: 'system/ai-usage', name: 'AiUsagePage', component: () => import('@/views/system/AiUsagePage.vue'), meta: { requiresAdmin: true } }, ], }, ] diff --git a/eai_agentplatform/frontend/src/api/skill.js b/eai_agentplatform/frontend/src/skills/api/skill.js similarity index 84% rename from eai_agentplatform/frontend/src/api/skill.js rename to eai_agentplatform/frontend/src/skills/api/skill.js index b73acea..5fbf973 100644 --- a/eai_agentplatform/frontend/src/api/skill.js +++ b/eai_agentplatform/frontend/src/skills/api/skill.js @@ -1,4 +1,4 @@ -import http from './http' +import http from '@/api/http' export function listSkills(params) { return http.get('/skills', { params }) diff --git a/eai_agentplatform/frontend/src/config/skillSettings.js b/eai_agentplatform/frontend/src/skills/config/skillSettings.js similarity index 98% rename from eai_agentplatform/frontend/src/config/skillSettings.js rename to eai_agentplatform/frontend/src/skills/config/skillSettings.js index f969c9d..42489fc 100644 --- a/eai_agentplatform/frontend/src/config/skillSettings.js +++ b/eai_agentplatform/frontend/src/skills/config/skillSettings.js @@ -13,7 +13,7 @@ // 选项值逐字抄自各技能页原有的 markup,没有增删改(那几页的 markup 已经没了, // 这里就是唯一的一份)。 // 「真发给后端的」和「只是摆着的」在每条上标了 note,别让后来人以为都能用。 -import { assistantModes } from './workbench' +import { assistantModes } from '@/skills/core/builtin' // 7 种语言,文档翻译的源/目标共用一份(顺序照它的 markup:源列表 zh 打头,目标列表 en 打头) const LANGS = [ @@ -32,7 +32,7 @@ const MODE_SUM = { quick: '快速模式 · 直出回答', expert: '深思模式 export const skillSettings = { // 缺省技能。它只有一个设置:模式。原来散在门厅上的两颗胶囊就是它。 - // mode 是真发出去的:SmartAssistantPage 的 send() → sendAssistantChatMessage({ message, mode })。 + // mode 是真发出去的:SmartAssistantPage 的 send() → sendChatMessage({ message, mode })。 'smart-assistant': { ico: '通', settings: [ diff --git a/eai_agentplatform/frontend/src/skills/contracts/manifest.js b/eai_agentplatform/frontend/src/skills/contracts/manifest.js new file mode 100644 index 0000000..037de9e --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/contracts/manifest.js @@ -0,0 +1,172 @@ +export const SKILL_WORKBENCH_ROUTE = '/home' + +export function createInteractionCard({ + greeting, + tagline, + openingPrompt, + relationshipToUser = '', + tone = '专业、直接、可执行', + starterPrompts = [], +} = {}) { + return { + greeting, + tagline, + openingPrompt, + relationshipToUser, + tone, + starterPrompts, + } +} + +export function createSkillWorkflowStep({ + title, + detail, + expectedOutput = '', +} = {}) { + return { + title, + detail, + expectedOutput, + } +} + +export function createSkillArtifact({ + title, + detail, + artifactType = 'text', + fileFormat = 'txt', + status = '默认产物', +} = {}) { + return { + title, + detail, + artifactType, + fileFormat, + status, + } +} + +export function createSkillManifest({ + id = '', + code = '', + key, + label, + caption, + objectEntryRoute = SKILL_WORKBENCH_ROUTE, + color = '#409eff', + source = 'eai', + kind = 'skill', + icon = 'skill', + description = '', + skillTags = [], + workflowSchema = [], + artifactSchema = [], + interactionCard = {}, + officeMeta = null, +} = {}) { + return { + id, + code, + source, + key, + label, + kind, + icon, + caption, + description, + objectEntryRoute, + color, + skillTags, + workflowSchema, + artifactSchema, + interactionCard: createInteractionCard(interactionCard), + officeMeta, + } +} + +export function createOfficeMeta({ + category = 'office', + artifactType = 'document', + fileFormat = 'md', + defaultPrompt = '', + resultTitle = '', + summary = '', + highlights = [], + taskPlanTitle = '', + planSteps = [], +} = {}) { + return { + category, + artifactType, + fileFormat, + defaultPrompt, + resultTitle, + summary, + highlights, + taskPlanTitle, + planSteps, + } +} + +export function createOfficeSkillManifest({ + id, + code, + key, + label, + category = 'office', + artifactType = 'document', + fileFormat = 'md', + description = '', + defaultPrompt = '', + resultTitle = '', + summary = '', + highlights = [], + taskPlanTitle = '', + planSteps = [], + color = '#409eff', +} = {}) { + return createSkillManifest({ + id, + code, + key, + label, + caption: description, + description, + color, + skillTags: Array.isArray(highlights) ? highlights : [], + workflowSchema: Array.isArray(planSteps) + ? planSteps.map((step) => + createSkillWorkflowStep({ + title: step, + detail: description, + expectedOutput: resultTitle, + })) + : [], + artifactSchema: [ + createSkillArtifact({ + title: resultTitle || label, + detail: summary || description, + artifactType, + fileFormat, + }), + ], + interactionCard: { + greeting: summary || `我是${label},可以帮你先整理第一版结果。`, + tagline: description || label, + openingPrompt: defaultPrompt || `告诉我你希望${label}先处理什么。`, + relationshipToUser: '你的技能助手', + starterPrompts: defaultPrompt ? [defaultPrompt] : [], + }, + officeMeta: createOfficeMeta({ + category, + artifactType, + fileFormat, + defaultPrompt, + resultTitle, + summary, + highlights, + taskPlanTitle: taskPlanTitle || `${label}流程`, + planSteps, + }), + }) +} diff --git a/eai_agentplatform/frontend/src/skills/core/builtin.js b/eai_agentplatform/frontend/src/skills/core/builtin.js new file mode 100644 index 0000000..18f3ccd --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/core/builtin.js @@ -0,0 +1,74 @@ +import { withObjectCodes } from '@/utils/objectCode' +import { createRegistry } from '@/skills/core/registry' +import smartAssistantManifest from '@/skills/packages/smart-assistant/manifest' +import documentTranslateManifest from '@/skills/packages/document-translate/manifest' +import copyProofreadingManifest from '@/skills/packages/copy-proofreading/manifest' +import audioTranscribeManifest from '@/skills/packages/audio-transcribe/manifest' +import batchExtractManifest from '@/skills/packages/batch-extract/manifest' +import contractReviewManifest from '@/skills/packages/contract-review/manifest' +import reportGenerationManifest from '@/skills/packages/report-generation/manifest' +import pptGenerationManifest from '@/skills/packages/ppt-generation/manifest' +import mindMapManifest from '@/skills/packages/mind-map/manifest' +import longformWritingManifest from '@/skills/packages/longform-writing/manifest' +import textToolkitManifest from '@/skills/packages/text-toolkit/manifest' +import ocrUnderstandingManifest from '@/skills/packages/ocr-understanding/manifest' +import meetingMinutesManifest from '@/skills/packages/meeting-minutes/manifest' +import projectPlanningManifest from '@/skills/packages/project-planning/manifest' +import emailDraftingManifest from '@/skills/packages/email-drafting/manifest' +import tableCleanupManifest from '@/skills/packages/table-cleanup/manifest' +import proposalSummaryManifest from '@/skills/packages/proposal-summary/manifest' +import progressReportManifest from '@/skills/packages/progress-report/manifest' +import contractBriefManifest from '@/skills/packages/contract-brief/manifest' +import interviewSummaryManifest from '@/skills/packages/interview-summary/manifest' +import policyRewriteManifest from '@/skills/packages/policy-rewrite/manifest' +import surveySummaryManifest from '@/skills/packages/survey-summary/manifest' + +// Register office skill execution builders (side-effect import). +// This centralizes all package→execution imports into one place. +import '@/skills/packages/officeExecutions' + +export const builtinSkillManifests = [ + smartAssistantManifest, + documentTranslateManifest, + copyProofreadingManifest, + audioTranscribeManifest, + batchExtractManifest, + contractReviewManifest, + reportGenerationManifest, + pptGenerationManifest, + mindMapManifest, + longformWritingManifest, + textToolkitManifest, + ocrUnderstandingManifest, + meetingMinutesManifest, + projectPlanningManifest, + emailDraftingManifest, + tableCleanupManifest, + proposalSummaryManifest, + progressReportManifest, + contractBriefManifest, + interviewSummaryManifest, + policyRewriteManifest, + surveySummaryManifest, +] + +const builtinSkillRegistry = createRegistry(builtinSkillManifests) + +// 通用助手的模式。快速模式直出回答,深思模式开启 thinking 多步规划。 +export const assistantModes = [ + { key: 'quick', label: '快速模式', icon: '⚡', desc: '直出回答,适合简单问题和快速回复。', placeholder: '输入你的问题...' }, + { key: 'expert', label: '深思模式', icon: '🧠', desc: '开启思考过程,适合复杂任务和深度分析。', placeholder: '输入需要深度分析的问题...' }, +] + +export function getBuiltinSkillRegistry() { + return builtinSkillRegistry +} + +export const staticSkillCatalog = withObjectCodes( + builtinSkillRegistry.manifests(), + { kind: 'skill' }, +) + +export function getStaticSkillByKey(key) { + return staticSkillCatalog.find((item) => item.key === key) || null +} diff --git a/eai_agentplatform/frontend/src/skills/core/registry.js b/eai_agentplatform/frontend/src/skills/core/registry.js new file mode 100644 index 0000000..965860a --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/core/registry.js @@ -0,0 +1,13 @@ +export function createRegistry(items = []) { + const manifests = items.filter((item) => item && item.key) + const manifestMap = Object.fromEntries(manifests.map((item) => [item.key, item])) + + return { + manifests() { + return manifests.map((item) => ({ ...item })) + }, + byKey(key) { + return manifestMap[String(key || '').trim()] || null + }, + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/audio-transcribe/manifest.js b/eai_agentplatform/frontend/src/skills/packages/audio-transcribe/manifest.js new file mode 100644 index 0000000..c28bdb8 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/audio-transcribe/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'audio-transcribe', + label: '语音转写', + caption: '转写 / 分段 / 重点整理', + color: '#5263c3', + skillTags: ['逐字转写', '说话人分段', '重点提炼', '纪要整理'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '确认音频范围与语言', + detail: '先判断音频语言、时长、说话场景,以及是否需要区分说话人。', + expectedOutput: '转写要求', + }), + createSkillWorkflowStep({ + title: '生成逐字转写文本', + detail: '尽量保留原始信息,避免在转写阶段过早删减内容。', + expectedOutput: '原始转写稿', + }), + createSkillWorkflowStep({ + title: '整理段落与重点', + detail: '按主题、说话人或时间顺序把文本收成可阅读结构。', + expectedOutput: '结构化转写稿', + }), + createSkillWorkflowStep({ + title: '提炼可交付纪要', + detail: '根据需要输出会议纪要、行动项或重点摘要。', + expectedOutput: '纪要或行动项', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '逐字转写稿', + detail: '保留原始信息的基础文本。', + artifactType: 'transcript', + fileFormat: 'txt', + }), + createSkillArtifact({ + title: '结构化纪要', + detail: '按主题或说话人整理后的可读版本。', + artifactType: 'document', + fileFormat: 'md', + }), + createSkillArtifact({ + title: '行动项清单', + detail: '适合从会议或访谈中提炼后续动作。', + artifactType: 'checklist', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '先把语音内容转成可靠文本,再继续整理重点、纪要和后续动作。', + tagline: '把音频从“听过”变成“可查、可引用、可交付”的文本资产。', + openingPrompt: '把音频内容发给我,或者告诉我语言、场景和你最终想拿到什么结果。', + relationshipToUser: '你的语音整理技能', + starterPrompts: [ + '把这段会议录音转成文字,按议题分段,再列出待办事项', + '先转写这段采访,再帮我提炼可引用的核心观点', + '把这段语音整理成适合发群里的会议纪要', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/batch-extract/manifest.js b/eai_agentplatform/frontend/src/skills/packages/batch-extract/manifest.js new file mode 100644 index 0000000..56ac1e9 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/batch-extract/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'batch-extract', + label: '批量提取', + caption: '字段设计 / 批量抽取 / 异常复核', + color: '#39b07a', + skillTags: ['字段抽取', '批量处理', '结构化输出', '异常回看'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '定义抽取字段', + detail: '先把需要的字段、输出格式和异常处理规则说清楚,避免提取结果不可用。', + expectedOutput: '字段方案', + }), + createSkillWorkflowStep({ + title: '批量识别源材料', + detail: '对一批文本、合同、简历或文章做统一识别和结构化抽取。', + expectedOutput: '批量抽取结果', + }), + createSkillWorkflowStep({ + title: '标记异常与缺失项', + detail: '对低置信度、字段缺失和格式不一致的内容单独挑出来。', + expectedOutput: '异常项清单', + }), + createSkillWorkflowStep({ + title: '整理为交付表', + detail: '收口成表格、JSON 或可继续流转的数据结果。', + expectedOutput: '结构化交付物', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '结构化结果表', + detail: '批量抽取后的主交付表。', + artifactType: 'table', + fileFormat: 'xlsx', + }), + createSkillArtifact({ + title: '异常项清单', + detail: '需要人工复核或补齐的信息会单独列出。', + artifactType: 'checklist', + fileFormat: 'md', + }), + createSkillArtifact({ + title: '字段说明', + detail: '说明本次字段口径和空值规则,便于复用。', + artifactType: 'note', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '先把字段方案定义清楚,再批量抽取,并把异常项单独挑出来。', + tagline: '不是只把内容抓出来,而是直接整理成后续系统能用的结构化结果。', + openingPrompt: '告诉我想抽哪些字段、输出成什么格式、哪些情况需要标记为异常。', + relationshipToUser: '你的结构化提取技能', + starterPrompts: [ + '从这批简历里提取姓名、岗位、年限和意向城市,异常项单独列出', + '把这些合同里的付款条款统一抽出来,并标出缺失付款节点的合同', + '帮我批量提取文章标题、作者、发布时间和摘要,输出成表格', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/contract-brief/execution.js b/eai_agentplatform/frontend/src/skills/packages/contract-brief/execution.js new file mode 100644 index 0000000..ce95a78 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/contract-brief/execution.js @@ -0,0 +1,130 @@ +import { + buildContractRedlineItems, + extractPoints, + parseStructuredFields, + pickTopic, + slugify, + splitFocusKeywords, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const { fields, bodyText } = parseStructuredFields(inputText, { + topic: ['合同名称', '主题', 'topic'], + role: ['立场', '我方立场', 'role'], + focus: ['重点条款', '重点关注', 'focus'], + counterpart: ['对方', '合作方', 'counterpart'], + }) + const topic = fields.topic || pickTopic(bodyText || inputText, '合同要点') + const role = fields.role || '我方视角' + const focus = fields.focus || '付款、违约、终止、争议解决' + const counterpart = fields.counterpart || '对方主体待确认' + const points = extractPoints(bodyText || inputText, [ + '合同目的与合作范围', + '付款与结算安排', + '违约责任与赔偿约定', + '终止条件与争议处理', + ]) + const focusKeywords = splitFocusKeywords(focus) + const redlineItems = buildContractRedlineItems(points, focusKeywords) + const summaryContent = [ + `# ${topic}`, + '', + '## 审查视角', + `- 立场:${role}`, + `- 重点条款:${focus}`, + `- 对方主体:${counterpart}`, + '', + '## 核心条款', + ...points.slice(0, 4).map((item) => `- ${item}`), + '', + '## 阅读建议', + '建议先快速确认付款、违约、终止三类关键条款,再进入逐条审阅。', + ].join('\n').trim() + const riskContent = [ + '# 风险提示', + '', + `- 风险 1:${points[2] || '违约责任约定可能偏重单方义务'}`, + `- 风险 2:${points[3] || '终止或争议处理条款仍需继续确认'}`, + '- 建议:重点核对责任边界、赔偿上限和解除条件', + ].join('\n').trim() + const confirmContent = [ + '# 待确认事项', + '', + '- [ ] 付款节点与验收口径是否一致', + '- [ ] 违约责任是否存在明显失衡', + '- [ ] 终止条件与争议解决地是否可接受', + ].join('\n').trim() + const redlineContent = [ + '# 红线建议', + '', + '| 条款 | 主要风险 | 建议红线 |', + '| --- | --- | --- |', + ...redlineItems.map((item) => `| ${item.clause} | ${item.risk} | ${item.suggestion} |`), + ].join('\n').trim() + const clauseReviewContent = [ + '# 逐条审查意见', + '', + '| 条款 | 审查结论 | 主要问题 | 建议动作 |', + '| --- | --- | --- | --- |', + ...redlineItems.map((item, index) => `| ${item.clause} | ${index === 0 ? '需重点关注' : index === 1 ? '建议修改' : '建议复核'} | ${item.risk} | ${item.suggestion} |`), + ].join('\n').trim() + const slug = slugify(topic, 'contract-brief') + + return { + title: '合同要点摘要', + summary: `已按“${role} / ${focus}”提炼出合同核心条款、风险和待确认事项。`, + highlights: ['适合采购、合作、服务类合同', '会识别立场、重点条款和对方信息', '新增逐条审查意见和红线建议'], + artifacts: [ + { + key: 'summary', + title: '合同摘要', + description: '适合业务和负责人快速预读的核心条款摘要。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: summaryContent, + }, + { + key: 'risks', + title: '风险提示', + description: '提炼出的主要风险点和审查建议。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-risks.md`, + content: riskContent, + }, + { + key: 'confirm', + title: '待确认事项', + description: '需要继续确认或人工复核的关键事项。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-confirm.md`, + content: confirmContent, + }, + { + key: 'redline', + title: '红线建议', + description: '更接近合同审查稿的修改建议版本。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-redline.md`, + content: redlineContent, + }, + { + key: 'clause-review', + title: '逐条审查意见', + description: '更像法务审查单的逐条意见表。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-review.md`, + content: clauseReviewContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/contract-brief/manifest.js b/eai_agentplatform/frontend/src/skills/packages/contract-brief/manifest.js new file mode 100644 index 0000000..d6de0eb --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/contract-brief/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.contract-brief', + code: 'EAI-T-212', + key: 'contract-brief', + label: '合同要点提炼', + category: 'document', + artifactType: 'summary', + fileFormat: 'md', + color: '#dc2626', + description: '把合同、协议和条款材料压缩成核心要点、风险提示和待确认事项,方便业务快速预读。', + defaultPrompt: '把这份采购合同提炼成要点摘要和待确认项', + resultTitle: '合同要点摘要', + summary: '已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。', + highlights: ['适合采购、合作、服务类合同', '自动压缩核心条款与风险点', '默认沉淀摘要、风险和待确认项'], + taskPlanTitle: '合同要点提炼流程', + planSteps: ['识别合同结构', '提炼核心条款', '标记风险事项', '输出摘要版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/contract-review/manifest.js b/eai_agentplatform/frontend/src/skills/packages/contract-review/manifest.js new file mode 100644 index 0000000..b5a36f3 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/contract-review/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'contract-review', + label: '合同审查', + caption: '条款识别 / 风险判断 / 审查交付', + color: '#f56c6c', + skillTags: ['风险识别', '待确认项', '红线建议', '审查结果'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '识别合同背景与重点条款', + detail: '先判断合同类型、审查立场和这次最需要盯住的业务条款。', + expectedOutput: '审查范围', + }), + createSkillWorkflowStep({ + title: '抽取关键条款', + detail: '定位付款、责任、违约、终止、保密等核心条款,建立审查底稿。', + expectedOutput: '条款清单', + }), + createSkillWorkflowStep({ + title: '判断风险与待确认项', + detail: '区分高风险、可接受偏差和必须回到业务侧确认的问题。', + expectedOutput: '风险与待确认项', + }), + createSkillWorkflowStep({ + title: '整理审查交付物', + detail: '输出可直接用于沟通或复核的风险清单、红线建议和审查摘要。', + expectedOutput: '审查结果包', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '风险清单', + detail: '按条款列出高风险、一般风险和待确认事项。', + artifactType: 'checklist', + fileFormat: 'xlsx', + }), + createSkillArtifact({ + title: '红线建议', + detail: '可直接进入 redline 或谈判沟通的修改建议。', + artifactType: 'document', + fileFormat: 'docx', + }), + createSkillArtifact({ + title: '审查摘要', + detail: '适合给负责人快速汇报的审查结论。', + artifactType: 'summary', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '先定位高风险条款和待确认项,再整理成可交付的审查结果。', + tagline: '不是只报风险,而是把风险依据、红线建议和后续确认项一起交出来。', + openingPrompt: '把合同发给我,并告诉我这次重点想看哪些条款,或者你站在哪一方立场。', + relationshipToUser: '你的合同审查技能', + starterPrompts: [ + '审查这份采购合同的风险点,重点关注付款责任、违约责任和终止条款', + '站在甲方立场帮我看这份合同,给我一版待人工确认的风险清单', + '先抽出关键条款,再整理 redline 建议和业务待确认点', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/copy-proofreading/manifest.js b/eai_agentplatform/frontend/src/skills/packages/copy-proofreading/manifest.js new file mode 100644 index 0000000..a54ab95 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/copy-proofreading/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'copy-proofreading', + label: '文案校对', + caption: '问题定位 / 表达优化 / 风格收口', + color: '#e6a23c', + skillTags: ['错别字检查', '语病修正', '语气统一', '风格优化'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '识别文本目标与语气', + detail: '先判断这段文案是对内沟通、对外邮件、销售话术还是正式说明。', + expectedOutput: '校对口径与风格目标', + }), + createSkillWorkflowStep({ + title: '定位问题句与风险表达', + detail: '标出错别字、病句、歧义和不够稳妥的地方,不只给最终改写。', + expectedOutput: '问题清单', + }), + createSkillWorkflowStep({ + title: '重写关键句段', + detail: '对重点段落进行润色,兼顾清晰度、礼貌度和行业语感。', + expectedOutput: '优化后的正文', + }), + createSkillWorkflowStep({ + title: '收口成可发布版本', + detail: '给出最终可直接使用的版本,并说明是否还需要人工确认。', + expectedOutput: '定稿建议', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '校对问题清单', + detail: '列出原文中的高优先级问题,方便快速复核。', + artifactType: 'checklist', + fileFormat: 'md', + }), + createSkillArtifact({ + title: '优化后正文', + detail: '可直接复制使用的修订版本。', + artifactType: 'document', + fileFormat: 'docx', + }), + createSkillArtifact({ + title: '风格说明', + detail: '解释本次主要调整了哪些语气和表达策略。', + artifactType: 'note', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '先找出问题句和语气风险,再给出能直接使用的修订版本。', + tagline: '不是只改错字,而是把整段表达修顺、修稳、修清楚。', + openingPrompt: '把文案贴给我,并告诉我这段内容要发给谁、希望保留什么语气。', + relationshipToUser: '你的文案润色技能', + starterPrompts: [ + '帮我校对这封对外邮件,保留原意,但语气要更专业稳妥', + '把这段销售话术里的病句和夸张表述挑出来,再帮我润顺', + '先指出这段产品介绍哪里别扭,再给我一版正式写法', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/document-translate/manifest.js b/eai_agentplatform/frontend/src/skills/packages/document-translate/manifest.js new file mode 100644 index 0000000..62298cc --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/document-translate/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'document-translate', + label: '文档翻译', + caption: '译文生成 / 术语统一 / 多格式导出', + color: '#67c23a', + skillTags: ['术语统一', '语气对齐', '格式保留', '双语摘要'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '识别语境与翻译要求', + detail: '先确定源语言、目标语言、受众、语气和是否需要保留术语表。', + expectedOutput: '翻译口径与术语要求', + }), + createSkillWorkflowStep({ + title: '统一术语与表达风格', + detail: '优先消除专有名词和关键术语的前后不一致,避免译文看起来像拼接。', + expectedOutput: '术语映射与风格基线', + }), + createSkillWorkflowStep({ + title: '生成目标语言正文', + detail: '在保留原意的前提下重写成目标语言读者真正能读懂的表达。', + expectedOutput: '首版译文', + }), + createSkillWorkflowStep({ + title: '整理为可交付版本', + detail: '按需要补充双语摘要、术语对照或导出格式说明。', + expectedOutput: '可直接提交的译稿', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '目标语言译文', + detail: '适合直接复制、发送或继续编辑的主结果。', + artifactType: 'document', + fileFormat: 'docx', + }), + createSkillArtifact({ + title: '术语对照表', + detail: '保留关键术语的源文与译法,便于统一口径。', + artifactType: 'table', + fileFormat: 'xlsx', + }), + createSkillArtifact({ + title: '双语摘要', + detail: '适合管理层快速了解文档内容和重点。', + artifactType: 'summary', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '先识别语境和术语,再生成可交付译文,避免只做逐句直译。', + tagline: '不仅翻译文字,还会先统一术语、语气和交付格式。', + openingPrompt: '把原文贴给我,并说明目标语言、受众和语气要求;如果有术语表,也一起给我。', + relationshipToUser: '你的翻译协作技能', + starterPrompts: [ + '把这份中文方案翻译成英文,保留正式商务语气,并列出关键术语对照', + '把这封邮件翻译成日文,语气礼貌但不要太生硬', + '先把这份合同摘要译成英文,再给我一版双语对照', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/email-drafting/execution.js b/eai_agentplatform/frontend/src/skills/packages/email-drafting/execution.js new file mode 100644 index 0000000..db40344 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/email-drafting/execution.js @@ -0,0 +1,119 @@ +import { + buildEmailSubjectVariants, + extractPoints, + parseStructuredFields, + pickTopic, + slugify, + splitSimpleList, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const { fields, bodyText } = parseStructuredFields(inputText, { + recipient: ['收件人', '对象', 'recipient', 'to'], + cc: ['抄送', '抄送人', 'cc'], + topic: ['主题', '标题', 'topic', 'subject'], + purpose: ['目的', '意图', 'purpose'], + action: ['希望对方', '需要对方', 'action'], + deadline: ['时间', '截止时间', 'deadline'], + tone: ['语气', '风格', 'tone', 'style'], + }) + const topic = fields.topic || pickTopic(bodyText || inputText, '邮件主题') + const recipient = fields.recipient || '各位' + const ccList = splitSimpleList(fields.cc) + const purpose = fields.purpose || '同步背景和当前进展' + const action = fields.action || '请协助确认并继续推进' + const deadline = fields.deadline || '请尽快反馈' + const tone = fields.tone || '正式、明确、礼貌' + const points = extractPoints(bodyText || inputText, [ + '交代背景和当前情况', + '说明希望对方配合或确认的事项', + '给出时间点和下一步动作', + '保持礼貌但结论清楚', + ]) + const subjectVariants = buildEmailSubjectVariants(topic, purpose) + const subject = subjectVariants[0] + const emailContent = [ + `主题:${subject}`, + `收件人:${recipient}`, + `抄送:${ccList.length ? ccList.join('、') : '无'}`, + '', + `${recipient}好,`, + '', + `${purpose}。${points[0] || '这边同步一下当前事项的背景和进展。'}`, + `${action}。${points[1] || '目前需要大家协助确认相关事项。'}`, + `${deadline}。${points[2] || '如无问题,请按约定时间继续推进。'}`, + '', + `语气建议:${tone}。如有问题,请直接回复沟通。`, + '', + '谢谢。', + ].join('\n').trim() + const shortContent = [ + `【${topic}】`, + `${points[1] || '请协助确认相关事项。'}`, + `${points[2] || '如无问题请按时推进。'}`, + ].join('\n').trim() + const briefContent = [ + '# 邮件要点', + '', + `- 主题:${subject}`, + ...points.slice(0, 4).map((item) => `- ${item}`), + ].join('\n').trim() + const subjectContent = [ + '# 主题与抄送建议', + '', + `- 收件人:${recipient}`, + `- 抄送建议:${ccList.length ? ccList.join('、') : '建议抄送直属负责人 / 相关协同方'}`, + '', + '## 可选主题', + ...subjectVariants.map((item, index) => `${index + 1}. ${item}`), + ].join('\n').trim() + const slug = slugify(topic, 'email-drafting') + + return { + title: '邮件草稿', + summary: `已按“${recipient} / ${topic}”生成正式邮件、短消息版和邮件要点摘要。`, + highlights: ['适合通知、催办、确认和汇报邮件', '会识别收件人、抄送、目的、时间和语气', '新增可直接挑选的主题变体'], + artifacts: [ + { + key: 'email', + title: '正式邮件', + description: '可直接复制到邮箱发送的完整版本。', + kind: 'text', + fileFormat: 'txt', + mimeType: 'text/plain;charset=utf-8', + downloadName: `${slug}.txt`, + content: emailContent, + }, + { + key: 'short-followup', + title: '简短跟进版', + description: '适合发到 IM 或群里的短消息版本。', + kind: 'text', + fileFormat: 'txt', + mimeType: 'text/plain;charset=utf-8', + downloadName: `${slug}-short.txt`, + content: shortContent, + }, + { + key: 'brief', + title: '邮件要点', + description: '方便审批或复核邮件口径的要点版。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-brief.md`, + content: briefContent, + }, + { + key: 'subject-variants', + title: '主题与抄送建议', + description: '提供多版主题候选和抄送建议。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-subject.md`, + content: subjectContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/email-drafting/manifest.js b/eai_agentplatform/frontend/src/skills/packages/email-drafting/manifest.js new file mode 100644 index 0000000..93257ab --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/email-drafting/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.email-drafting', + code: 'EAI-T-208', + key: 'email-drafting', + label: '邮件起草', + category: 'communication', + artifactType: 'document', + fileFormat: 'txt', + color: '#0f766e', + description: '把沟通意图、背景和要求整理成可直接发送的正式邮件,并附带简短跟进版本。', + defaultPrompt: '帮我起草一封推进客户回款的正式邮件', + resultTitle: '邮件草稿', + summary: '已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。', + highlights: ['适合汇报、催办、确认和通知', '兼顾正式语气与清晰表达', '默认沉淀长邮件和短消息版本'], + taskPlanTitle: '邮件起草流程', + planSteps: ['识别沟通目的', '整理背景与重点', '生成正式邮件', '补充简短跟进版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/interview-summary/execution.js b/eai_agentplatform/frontend/src/skills/packages/interview-summary/execution.js new file mode 100644 index 0000000..1f59418 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/interview-summary/execution.js @@ -0,0 +1,77 @@ +import { + extractPoints, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '访谈整理') + const points = extractPoints(inputText, [ + '受访者关注的核心问题', + '当前使用中的主要痛点', + '对方案或产品的积极反馈', + '后续建议和动作', + ]) + const summaryContent = [ + `# ${topic}`, + '', + '## 主题摘要', + ...points.slice(0, 4).map((item) => `- ${item}`), + '', + '## 访谈结论', + '访谈内容已压缩为主题和观点,适合继续沉淀为需求或汇报材料。', + ].join('\n').trim() + const insightContent = [ + '# 洞察结论', + '', + `- 洞察 1:${points[0] || '受访者更关注核心目标是否真正落地'}`, + `- 洞察 2:${points[1] || '当前流程中存在明显使用阻力或理解成本'}`, + `- 洞察 3:${points[2] || '已有价值点需要进一步明确放大'}`, + ].join('\n').trim() + const actionsContent = [ + '# 后续建议', + '', + `- [ ] ${points[3] || '将访谈结论转成下一步行动项'}`, + '- [ ] 对重点洞察做进一步验证', + '- [ ] 同步相关负责人决定是否进入后续迭代', + ].join('\n').trim() + const slug = slugify(topic, 'interview-summary') + + return { + title: '访谈整理稿', + summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。', + highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'], + artifacts: [ + { + key: 'summary', + title: '访谈摘要', + description: '访谈主题和观点的整理版本。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: summaryContent, + }, + { + key: 'insights', + title: '洞察结论', + description: '从访谈中提炼出的关键洞察。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-insights.md`, + content: insightContent, + }, + { + key: 'actions', + title: '后续建议', + description: '建议继续推进或验证的动作。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-actions.md`, + content: actionsContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/interview-summary/manifest.js b/eai_agentplatform/frontend/src/skills/packages/interview-summary/manifest.js new file mode 100644 index 0000000..91b1a10 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/interview-summary/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.interview-summary', + code: 'EAI-T-213', + key: 'interview-summary', + label: '访谈整理', + category: 'summary', + artifactType: 'document', + fileFormat: 'md', + color: '#7c3aed', + description: '把访谈记录、用户调研和深度沟通内容整理成主题摘要、洞察结论和后续动作。', + defaultPrompt: '把这份用户访谈记录整理成主题摘要和洞察结论', + resultTitle: '访谈整理稿', + summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。', + highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'], + taskPlanTitle: '访谈整理流程', + planSteps: ['归并访谈内容', '提炼主题观点', '总结关键洞察', '输出整理版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/longform-writing/execution.js b/eai_agentplatform/frontend/src/skills/packages/longform-writing/execution.js new file mode 100644 index 0000000..469748e --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/longform-writing/execution.js @@ -0,0 +1,59 @@ +import { + buildMarkdownOutline, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '长文主题') + const sections = [ + { title: '背景与问题', bullets: ['交代问题背景', '说明为什么现在要做这件事'] }, + { title: '目标与原则', bullets: ['写清楚目标边界', '说明采用的基本原则'] }, + { title: '主体分析', bullets: ['从现状、问题、机会三个角度展开', '补充关键证据与案例'] }, + { title: '方案与建议', bullets: ['提出可执行的方案路径', '给出必要的资源与配套要求'] }, + { title: '结论与下一步', bullets: ['收束核心结论', '明确下一步动作'] }, + ] + const outlineMarkdown = buildMarkdownOutline(`${topic} 长文大纲`, sections) + const draftContent = [ + `# ${topic}`, + '', + '## 摘要', + `${topic} 这项工作需要先把背景、目标、方案和执行动作组织清楚,再进一步扩写为完整正文。本稿先给出一版可继续加工的长文骨架。`, + '', + ...sections.flatMap((section) => [ + `## ${section.title}`, + `${section.title}部分应围绕“${topic}”展开,建议先给出清晰判断,再用材料、案例和拆解步骤支撑结论。`, + ...section.bullets.map((bullet) => `- ${bullet}`), + '', + ]), + ].join('\n').trim() + const slug = slugify(topic, 'longform') + + return { + title: '长文草稿', + summary: '已输出一版长文大纲和正文骨架,方便继续细化成正式稿。', + highlights: ['先给大纲再给正文草稿', '适合方案、制度、长文章', '结果可直接下载继续改写'], + artifacts: [ + { + key: 'outline', + title: '章节大纲', + description: '先确认结构是否合理。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-outline.md`, + content: outlineMarkdown, + }, + { + key: 'draft', + title: '正文草稿', + description: '可继续细化的正文版本。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-draft.md`, + content: draftContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/longform-writing/manifest.js b/eai_agentplatform/frontend/src/skills/packages/longform-writing/manifest.js new file mode 100644 index 0000000..8547956 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/longform-writing/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.longform-writing', + code: 'EAI-T-203', + key: 'longform-writing', + label: '长文创作', + category: 'writing', + artifactType: 'document', + fileFormat: 'docx', + color: '#b45309', + description: '统一承接论文、方案、制度文档、公众号长文等长文任务,先搭大纲,再分段生成正文。', + defaultPrompt: '围绕企业 AI 落地写一篇完整方案', + resultTitle: '长文草稿', + summary: '已按长文写作流程生成大纲与正文骨架,适合继续细化为正式稿。', + highlights: ['支持论文、方案、制度、长文', '先搭结构再出正文', '默认沉淀大纲、摘要和正文稿'], + taskPlanTitle: '长文创作流程', + planSteps: ['明确文体与受众', '规划章节大纲', '生成正文骨架', '整理摘要与交付稿'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/meeting-minutes/execution.js b/eai_agentplatform/frontend/src/skills/packages/meeting-minutes/execution.js new file mode 100644 index 0000000..9b5f905 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/meeting-minutes/execution.js @@ -0,0 +1,96 @@ +import { + extractPoints, + parseMeetingActions, + parseStructuredFields, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const { fields, bodyText } = parseStructuredFields(inputText, { + topic: ['会议主题', '主题', 'title', 'topic'], + date: ['时间', '日期', 'date'], + attendees: ['参会人', '参会人员', 'attendees'], + objective: ['目标', '目的', 'objective'], + }) + const topic = fields.topic || pickTopic(bodyText || inputText, '会议纪要') + const meetingDate = fields.date || '待补充' + const attendees = fields.attendees || '待补充' + const objective = fields.objective || '对齐进展、结论和行动项' + const points = extractPoints(bodyText || inputText, [ + '确认本次会议目标和范围', + '同步当前进展与关键问题', + '明确下一步行动和负责人', + '记录需要继续跟进的依赖项', + ]) + const actions = parseMeetingActions(bodyText || inputText, points) + const minutesContent = [ + `# ${topic}`, + '', + '## 会议摘要', + `${topic} 已整理为正式纪要版本,建议先快速确认结论和行动项,再同步到群里或项目任务中。`, + '', + '## 会议信息', + `- 时间:${meetingDate}`, + `- 参会人:${attendees}`, + `- 会议目标:${objective}`, + '', + '## 关键结论', + ...points.slice(0, 4).map((item) => `- ${item}`), + '', + '## 待办事项', + ...actions.map((item, index) => `${index + 1}. ${item.owner} - ${item.task}(截止:${item.due})`), + ].join('\n').trim() + const actionContent = [ + '# 会后行动项', + '', + '| 序号 | 负责人 | 行动项 | 截止时间 | 状态 |', + '| --- | --- | --- | --- | --- |', + ...actions.map((item, index) => `| ${index + 1} | ${item.owner} | ${item.task} | ${item.due} | 待开始 |`), + ].join('\n').trim() + const syncContent = [ + `【${topic}】`, + ...points.slice(0, 3).map((item, index) => `${index + 1}. ${item}`), + ...actions.slice(0, 2).map((item) => `- ${item.owner}:${item.task}(${item.due})`), + '请相关同事按行动项继续推进。', + ].join('\n').trim() + const slug = slugify(topic, 'meeting-minutes') + + return { + title: '会议纪要草稿', + summary: `已按“${topic}”整理出纪要、行动项和会后同步摘要。`, + highlights: ['包含纪要正文、行动项和同步摘要', '会识别时间、参会人和会议目标', '行动项会拆出负责人和截止时间'], + artifacts: [ + { + key: 'minutes', + title: '会议纪要', + description: '适合直接发送或继续细化的正式纪要。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: minutesContent, + }, + { + key: 'actions', + title: '行动项清单', + description: '会后跟进和任务分派用的待办清单。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-actions.md`, + content: actionContent, + }, + { + key: 'sync', + title: '会后同步摘要', + description: '适合发到群里或邮件里的简短同步版本。', + kind: 'text', + fileFormat: 'txt', + mimeType: 'text/plain;charset=utf-8', + downloadName: `${slug}-sync.txt`, + content: syncContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/meeting-minutes/manifest.js b/eai_agentplatform/frontend/src/skills/packages/meeting-minutes/manifest.js new file mode 100644 index 0000000..a8a8269 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/meeting-minutes/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.meeting-minutes', + code: 'EAI-T-206', + key: 'meeting-minutes', + label: '会议纪要', + category: 'document', + artifactType: 'document', + fileFormat: 'md', + color: '#7c3aed', + description: '把会议记录、聊天记录和零散讨论整理成正式纪要、决议摘要和待办清单。', + defaultPrompt: '把这段项目例会记录整理成正式会议纪要', + resultTitle: '会议纪要草稿', + summary: '已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。', + highlights: ['自动提炼结论与待办', '适合例会、评审会和复盘会', '默认沉淀纪要正文和行动项清单'], + taskPlanTitle: '会议纪要整理流程', + planSteps: ['识别会议主题', '提炼关键结论', '整理行动项', '输出同步版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/mind-map/execution.js b/eai_agentplatform/frontend/src/skills/packages/mind-map/execution.js new file mode 100644 index 0000000..489956a --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/mind-map/execution.js @@ -0,0 +1,48 @@ +import { + buildMindMapSvg, + extractPoints, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '任务导图') + const branches = extractPoints(inputText, [ + '目标与范围', + '输入材料', + '关键步骤', + '风险与依赖', + ]).slice(0, 6) + + const outlineMarkdown = [`# ${topic} 思维导图`, '', ...branches.map((branch) => `- ${branch}`)].join('\n') + const svgContent = buildMindMapSvg(topic, branches) + const slug = slugify(topic, 'mind-map') + + return { + title: '导图结构草案', + summary: '已把主题压成一版导图结构,并附带可直接下载的 SVG 导图文件。', + highlights: ['可下载导图 SVG', '保留文字版结构', '适合任务拆解与知识梳理'], + artifacts: [ + { + key: 'mindmap-svg', + title: '导图图片', + description: '便于直接查看和分享的 SVG 导图。', + kind: 'svg', + fileFormat: 'svg', + mimeType: 'image/svg+xml;charset=utf-8', + downloadName: `${slug}.svg`, + content: svgContent, + }, + { + key: 'mindmap-outline', + title: '导图源结构', + description: '可继续编辑和扩展的文字结构版。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: outlineMarkdown, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/mind-map/manifest.js b/eai_agentplatform/frontend/src/skills/packages/mind-map/manifest.js new file mode 100644 index 0000000..3cbc1f9 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/mind-map/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.mind-map', + code: 'EAI-T-202', + key: 'mind-map', + label: '思维导图', + category: 'structure', + artifactType: 'mindmap', + fileFormat: 'svg', + color: '#0f766e', + description: '把一句话主题、会议纪要或长文本快速展开成结构化导图,适合梳理任务与知识结构。', + defaultPrompt: '把这次项目启动会整理成一版思维导图', + resultTitle: '导图结构草案', + summary: '已把输入内容压缩成层级结构,方便继续扩展节点或导出成导图文件。', + highlights: ['适合任务拆解与知识梳理', '会优先生成主干与分支', '默认保留可继续编辑的导图源结构'], + taskPlanTitle: '思维导图生成流程', + planSteps: ['识别主题主线', '拆分一级分支', '补齐关键节点', '整理导图源结构'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/ocr-understanding/execution.js b/eai_agentplatform/frontend/src/skills/packages/ocr-understanding/execution.js new file mode 100644 index 0000000..964f921 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/ocr-understanding/execution.js @@ -0,0 +1,15 @@ +import { pickTopic } from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '图文理解') + return { + title: '识别结果摘要', + summary: '这张卡片支持直接上传图片做 OCR 识别,识别完成后可复制文本或下载结果。', + highlights: ['浏览器端直接上传识别', '识别后可复制和下载', '适合截图、扫描件和表格照片'], + interactive: { + type: 'ocr-upload', + prompt: topic, + }, + artifacts: [], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/ocr-understanding/manifest.js b/eai_agentplatform/frontend/src/skills/packages/ocr-understanding/manifest.js new file mode 100644 index 0000000..9719e52 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/ocr-understanding/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.ocr-understanding', + code: 'EAI-T-205', + key: 'ocr-understanding', + label: 'OCR / 图文理解', + category: 'vision', + artifactType: 'document', + fileFormat: 'md', + color: '#0ea5e9', + description: '识别图片、截图和扫描件中的文字与结构信息,并提炼为摘要或结构化字段。', + defaultPrompt: '识别这张截图里的文字并整理成结构化摘要', + resultTitle: '识别结果摘要', + summary: '已按图文理解流程准备好识别与整理结果的第一版结构,方便继续抽字段或生成摘要。', + highlights: ['适合截图、扫描件、表格照片', '支持文字识别与结构摘要', '默认沉淀识别结果和字段草案'], + taskPlanTitle: '图文理解流程', + planSteps: ['识别图像内容', '抽取文字与字段', '整理结构摘要', '输出可复用结果'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/officeExecutions/index.js b/eai_agentplatform/frontend/src/skills/packages/officeExecutions/index.js new file mode 100644 index 0000000..15a48ca --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/officeExecutions/index.js @@ -0,0 +1,47 @@ +/** + * Central registration of office skill execution builders. + * + * Each package's buildExecution is imported here and self-registered + * into the shared officeExecutionRegistry, so that shared files + * never import directly from individual packages. + */ + +import { registerOfficeExecution } from '@/skills/shared/officeExecutionRegistry' + +// Communication skills +import { buildExecution as buildEmailDraftingExecution } from '@/skills/packages/email-drafting/execution' +import { buildExecution as buildMeetingMinutesExecution } from '@/skills/packages/meeting-minutes/execution' +import { buildExecution as buildProgressReportExecution } from '@/skills/packages/progress-report/execution' + +// Structure skills +import { buildExecution as buildMindMapExecution } from '@/skills/packages/mind-map/execution' +import { buildExecution as buildPptExecution } from '@/skills/packages/ppt-generation/execution' +import { buildExecution as buildProjectPlanningExecution } from '@/skills/packages/project-planning/execution' +import { buildExecution as buildTableCleanupExecution } from '@/skills/packages/table-cleanup/execution' + +// Writing skills +import { buildExecution as buildContractBriefExecution } from '@/skills/packages/contract-brief/execution' +import { buildExecution as buildInterviewSummaryExecution } from '@/skills/packages/interview-summary/execution' +import { buildExecution as buildLongformExecution } from '@/skills/packages/longform-writing/execution' +import { buildExecution as buildOcrExecution } from '@/skills/packages/ocr-understanding/execution' +import { buildExecution as buildPolicyRewriteExecution } from '@/skills/packages/policy-rewrite/execution' +import { buildExecution as buildProposalSummaryExecution } from '@/skills/packages/proposal-summary/execution' +import { buildExecution as buildSurveySummaryExecution } from '@/skills/packages/survey-summary/execution' +import { buildExecution as buildTextToolkitExecution } from '@/skills/packages/text-toolkit/execution' + +// Register all execution builders +registerOfficeExecution('email-drafting', buildEmailDraftingExecution) +registerOfficeExecution('meeting-minutes', buildMeetingMinutesExecution) +registerOfficeExecution('progress-report', buildProgressReportExecution) +registerOfficeExecution('mind-map', buildMindMapExecution) +registerOfficeExecution('ppt-generation', buildPptExecution) +registerOfficeExecution('project-planning', buildProjectPlanningExecution) +registerOfficeExecution('table-cleanup', buildTableCleanupExecution) +registerOfficeExecution('contract-brief', buildContractBriefExecution) +registerOfficeExecution('interview-summary', buildInterviewSummaryExecution) +registerOfficeExecution('longform-writing', buildLongformExecution) +registerOfficeExecution('ocr-understanding', buildOcrExecution) +registerOfficeExecution('policy-rewrite', buildPolicyRewriteExecution) +registerOfficeExecution('proposal-summary', buildProposalSummaryExecution) +registerOfficeExecution('survey-summary', buildSurveySummaryExecution) +registerOfficeExecution('text-toolkit', buildTextToolkitExecution) diff --git a/eai_agentplatform/frontend/src/skills/packages/policy-rewrite/execution.js b/eai_agentplatform/frontend/src/skills/packages/policy-rewrite/execution.js new file mode 100644 index 0000000..763610f --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/policy-rewrite/execution.js @@ -0,0 +1,82 @@ +import { + extractPoints, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '制度改写') + const points = extractPoints(inputText, [ + '明确制度适用范围', + '统一执行口径和条件', + '补齐例外和审批规则', + '强调执行责任和留痕要求', + ]) + const rewriteContent = [ + `# ${topic}`, + '', + '## 制度正文', + `${topic} 已重写为更清楚、统一的正式版本,适合继续评审或发布。`, + '', + '### 适用范围', + `- ${points[0] || '明确制度适用对象与范围'}`, + '### 执行要求', + `- ${points[1] || '统一执行口径和基本条件'}`, + `- ${points[2] || '补充例外处理与审批规则'}`, + '### 管理要求', + `- ${points[3] || '强调执行责任和记录留痕'}`, + ].join('\n').trim() + const revisionContent = [ + '# 修订说明', + '', + '- 已统一制度结构和表达口径', + '- 已补清含糊表达,增强执行可读性', + '- 建议结合实际流程再做一次法务或管理复核', + ].join('\n').trim() + const tipsContent = [ + '# 执行提示', + '', + '- 发布前建议确认适用范围与责任人', + '- 对例外情形建议补充审批路径', + '- 对留痕要求建议同步模板或系统入口', + ].join('\n').trim() + const slug = slugify(topic, 'policy-rewrite') + + return { + title: '制度改写稿', + summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。', + highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'], + artifacts: [ + { + key: 'rewrite', + title: '改写正文', + description: '适合继续评审或发布的正式版本。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: rewriteContent, + }, + { + key: 'revision', + title: '修订说明', + description: '说明本次主要改写和统一了哪些内容。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-revision.md`, + content: revisionContent, + }, + { + key: 'tips', + title: '执行提示', + description: '制度落地前需要注意的执行建议。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-tips.md`, + content: tipsContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/policy-rewrite/manifest.js b/eai_agentplatform/frontend/src/skills/packages/policy-rewrite/manifest.js new file mode 100644 index 0000000..11be10f --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/policy-rewrite/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.policy-rewrite', + code: 'EAI-T-214', + key: 'policy-rewrite', + label: '制度改写', + category: 'writing', + artifactType: 'document', + fileFormat: 'md', + color: '#6d28d9', + description: '把制度、规范和说明文本重写成更清楚、更统一的正式版本,并补出修订说明。', + defaultPrompt: '把这份报销制度改写成更正式清晰的版本', + resultTitle: '制度改写稿', + summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。', + highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'], + taskPlanTitle: '制度改写流程', + planSteps: ['识别制度目标', '统一结构口径', '重写正式正文', '补充修订说明'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/ppt-generation/execution.js b/eai_agentplatform/frontend/src/skills/packages/ppt-generation/execution.js new file mode 100644 index 0000000..0a7676b --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/ppt-generation/execution.js @@ -0,0 +1,96 @@ +import { + buildMarkdownOutline, + extractPoints, + parsePositiveInt, + parseStructuredFields, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const { fields, bodyText } = parseStructuredFields(inputText, { + topic: ['主题', '标题', 'topic', 'title'], + audience: ['受众', '对象', 'audience'], + goal: ['目标', '用途', 'goal'], + pages: ['页数', '页面数', 'slides', 'pages'], + style: ['风格', '语气', 'style', 'tone'], + }) + const topic = fields.topic || pickTopic(bodyText || inputText, '汇报主题') + const audience = fields.audience || '管理层 / 项目相关方' + const goal = fields.goal || '帮助受众快速理解核心结论并推动下一步' + const pageCount = Math.max(6, Math.min(parsePositiveInt(fields.pages, 8), 12)) + const style = fields.style || '专业、简洁、结论先行' + const points = extractPoints(bodyText || inputText, [ + '交代背景和目标', + '整理当前问题与机会', + '给出解决路径与方案', + '明确执行节奏与下一步', + ]) + const slideSections = [ + { title: '封面', bullets: [topic, `受众:${audience}`, `风格:${style}`] }, + { title: '背景与目标', bullets: points.slice(0, 2) }, + { title: '现状与问题', bullets: points.slice(1, 4) }, + { title: '方案路径', bullets: points.slice(2, 5) }, + { title: '执行计划', bullets: [goal, '阶段 1:启动与对齐', '阶段 2:执行与校正'] }, + { title: '风险与下一步', bullets: ['风险项与依赖', '需要支持的事项', '下一步行动清单'] }, + ] + while (slideSections.length < pageCount) { + slideSections.splice(slideSections.length - 1, 0, { + title: `补充页 ${slideSections.length - 3}`, + bullets: points.slice(0, 3).length ? points.slice(0, 3) : ['补充论据', '数据支撑', '案例说明'], + }) + } + + const outlineMarkdown = buildMarkdownOutline(`${topic} PPT 大纲`, slideSections) + const notesMarkdown = slideSections + .map((section, index) => [ + `## 第 ${index + 1} 页:${section.title}`, + `- 建议讲法:先说明“${section.title}”的核心判断,再展开 2-3 个重点。`, + ...section.bullets.map((bullet) => `- 讲稿提示:${bullet}`), + '', + ].join('\n')) + .join('\n') + .trim() + + const slug = slugify(topic, 'presentation') + + return { + title: '演示文稿骨架', + summary: `已按“${topic} / ${audience} / ${pageCount} 页”生成一版 PPT 结构,同时附上大纲和讲稿备注。`, + highlights: ['支持直接下载 .pptx', '能识别主题、受众、页数等信息', '同时保留可读的大纲与讲稿备注'], + artifacts: [ + { + key: 'presentation', + title: '演示文稿', + description: '浏览器端即时生成的 PPT 文件。', + kind: 'pptx', + fileFormat: 'pptx', + downloadName: `${slug}.pptx`, + data: { + topic, + slides: slideSections, + }, + }, + { + key: 'outline', + title: '演示大纲', + description: '适合先确认页面结构和内容分配。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-outline.md`, + content: outlineMarkdown, + }, + { + key: 'notes', + title: '讲稿备注', + description: '逐页的讲解提示和过渡说明。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-notes.md`, + content: notesMarkdown, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/ppt-generation/manifest.js b/eai_agentplatform/frontend/src/skills/packages/ppt-generation/manifest.js new file mode 100644 index 0000000..fcaace9 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/ppt-generation/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.ppt-generation', + code: 'EAI-T-201', + key: 'ppt-generation', + label: 'PPT 生成', + category: 'document', + artifactType: 'presentation', + fileFormat: 'pptx', + color: '#4f46e5', + description: '根据主题、受众和材料,先整理演示逻辑,再生成可继续细化的演示文稿骨架。', + defaultPrompt: '为季度经营复盘生成一版 8 页 PPT 大纲', + resultTitle: '演示文稿骨架', + summary: '已按主题、受众和页数要求整理出演示逻辑,并给出可继续细化的文稿骨架。', + highlights: ['适合周报、汇报、方案、复盘', '会先拆页面逻辑,再组织每页要点', '默认沉淀演示大纲与讲稿备注'], + taskPlanTitle: 'PPT 生成流程', + planSteps: ['确认汇报目标', '规划页面结构', '提炼每页要点', '整理讲稿备注'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/progress-report/execution.js b/eai_agentplatform/frontend/src/skills/packages/progress-report/execution.js new file mode 100644 index 0000000..d80ed0e --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/progress-report/execution.js @@ -0,0 +1,82 @@ +import { + extractPoints, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '进展汇报') + const points = extractPoints(inputText, [ + '本期完成的关键工作', + '当前风险和阻塞项', + '需要协同支持的事项', + '下一步计划', + ]) + const reportContent = [ + `# ${topic}`, + '', + '## 本期进展', + `- ${points[0] || '已完成本期关键工作整理'}`, + `- ${points[1] || '已同步当前风险和问题'}`, + '', + '## 风险与问题', + `- ${points[1] || '当前仍有待继续确认的风险项'}`, + `- ${points[2] || '存在需要跨团队协同支持的事项'}`, + '', + '## 下步计划', + `- ${points[3] || '按既定节奏继续推进下一步工作'}`, + '- 根据反馈更新排期和优先级', + ].join('\n').trim() + const summaryContent = [ + `【${topic}】`, + `1. ${points[0] || '已完成本期关键工作整理'}`, + `2. ${points[1] || '已同步当前风险和问题'}`, + `3. ${points[3] || '下一步按计划继续推进'}`, + ].join('\n').trim() + const checklistContent = [ + '# 跟进清单', + '', + `- [ ] ${points[1] || '继续跟进风险项'}`, + `- [ ] ${points[2] || '推动跨团队协同事项'}`, + `- [ ] ${points[3] || '执行下一步计划'}`, + ].join('\n').trim() + const slug = slugify(topic, 'progress-report') + + return { + title: '进展汇报草稿', + summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。', + highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'], + artifacts: [ + { + key: 'report', + title: '正式汇报', + description: '适合直接发送或继续细化的正式版本。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: reportContent, + }, + { + key: 'summary', + title: '简短摘要', + description: '适合发群或站会快速同步的简洁版。', + kind: 'text', + fileFormat: 'txt', + mimeType: 'text/plain;charset=utf-8', + downloadName: `${slug}-summary.txt`, + content: summaryContent, + }, + { + key: 'checklist', + title: '跟进清单', + description: '需要继续推进的风险与事项列表。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-checklist.md`, + content: checklistContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/progress-report/manifest.js b/eai_agentplatform/frontend/src/skills/packages/progress-report/manifest.js new file mode 100644 index 0000000..bb0871b --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/progress-report/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.progress-report', + code: 'EAI-T-211', + key: 'progress-report', + label: '周报 / 日报', + category: 'report', + artifactType: 'document', + fileFormat: 'md', + color: '#16a34a', + description: '把工作记录、聊天记录和事项进展整理成日报、周报或阶段同步,补齐风险和下步计划。', + defaultPrompt: '根据这些工作记录整理一版项目周报', + resultTitle: '进展汇报草稿', + summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。', + highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'], + taskPlanTitle: '进展汇报流程', + planSteps: ['归并本期工作', '提炼关键进展', '整理风险事项', '输出汇报版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/project-planning/execution.js b/eai_agentplatform/frontend/src/skills/packages/project-planning/execution.js new file mode 100644 index 0000000..171caa1 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/project-planning/execution.js @@ -0,0 +1,115 @@ +import { + buildProjectStageRows, + extractPoints, + parseStructuredFields, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const { fields, bodyText } = parseStructuredFields(inputText, { + topic: ['项目', '项目名称', '主题', 'topic'], + deadline: ['时间要求', '截止时间', 'deadline'], + owner: ['负责人', 'owner'], + goal: ['目标', 'goal'], + start: ['开始时间', '启动时间', 'start'], + }) + const topic = fields.topic || pickTopic(bodyText || inputText, '项目规划') + const deadline = fields.deadline || '待确认' + const owner = fields.owner || '待指定' + const goal = fields.goal || '先明确目标、阶段、依赖和协同方式' + const start = fields.start || '尽快启动' + const points = extractPoints(bodyText || inputText, [ + '明确目标、范围和成功标准', + '梳理关键里程碑与交付节点', + '安排执行分工与协同节奏', + '识别风险与外部依赖', + ]) + const stageRows = buildProjectStageRows(points, owner, deadline) + const planContent = [ + `# ${topic}`, + '', + '## 项目目标', + `围绕“${topic}”整理一版可执行的项目规划,先确保目标、阶段和依赖都清楚。`, + `- 负责人:${owner}`, + `- 启动时间:${start}`, + `- 时间要求:${deadline}`, + `- 总体目标:${goal}`, + '', + '## 阶段安排', + '### 阶段 1:启动与对齐', + `- ${points[0] || '明确目标和边界'}`, + '### 阶段 2:执行与推进', + `- ${points[1] || '推进关键里程碑和核心交付'}`, + '### 阶段 3:验收与收口', + `- ${points[2] || '完成验收、复盘和沉淀'}`, + ].join('\n').trim() + const milestoneContent = [ + '# 里程碑清单', + '', + ...points.slice(0, 5).map((item, index) => `${index + 1}. M${index + 1}:${item}`), + ].join('\n').trim() + const riskContent = [ + '# 风险与依赖', + '', + `- 风险 1:${points[3] || '关键依赖尚未完全明确'}`, + '- 风险 2:跨团队协同节奏可能影响排期', + '- 建议:将关键依赖提前前置到周会或启动会确认', + ].join('\n').trim() + const ganttContent = [ + '# 阶段排期表', + '', + '| 阶段 | 时间窗 | 负责人 | 目标 | 交付物 |', + '| --- | --- | --- | --- | --- |', + ...stageRows.map((item) => `| ${item.stage} | ${item.window} | ${item.owner} | ${item.goal} | ${item.output} |`), + ].join('\n').trim() + const slug = slugify(topic, 'project-planning') + + return { + title: '项目规划草案', + summary: `已按“${topic} / ${deadline}”整理出阶段计划、里程碑和风险清单。`, + highlights: ['阶段计划清晰', '会识别负责人、时间要求和项目目标', '新增可直接评审的阶段排期表'], + artifacts: [ + { + key: 'project-plan', + title: '项目计划', + description: '适合直接评审和继续细化的主计划。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: planContent, + }, + { + key: 'milestones', + title: '里程碑清单', + description: '关键节点和交付节奏的简洁版列表。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-milestones.md`, + content: milestoneContent, + }, + { + key: 'risks', + title: '风险清单', + description: '项目推进前需要确认的风险与依赖。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-risks.md`, + content: riskContent, + }, + { + key: 'schedule', + title: '阶段排期表', + description: '更接近甘特视角的阶段、时间窗和交付物表。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-schedule.md`, + content: ganttContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/project-planning/manifest.js b/eai_agentplatform/frontend/src/skills/packages/project-planning/manifest.js new file mode 100644 index 0000000..8776e8f --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/project-planning/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.project-planning', + code: 'EAI-T-207', + key: 'project-planning', + label: '项目规划', + category: 'planning', + artifactType: 'plan', + fileFormat: 'md', + color: '#2563eb', + description: '根据目标、约束和现状整理项目阶段、里程碑、执行分工和风险清单。', + defaultPrompt: '为新产品上线项目整理一版执行计划和里程碑', + resultTitle: '项目规划草案', + summary: '已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。', + highlights: ['适合启动、排期和推进对齐', '默认沉淀阶段计划与风险清单', '结果适合继续流转到任务治理'], + taskPlanTitle: '项目规划流程', + planSteps: ['明确目标边界', '拆解阶段路径', '整理里程碑', '补齐风险与依赖'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/proposal-summary/execution.js b/eai_agentplatform/frontend/src/skills/packages/proposal-summary/execution.js new file mode 100644 index 0000000..e98fb60 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/proposal-summary/execution.js @@ -0,0 +1,78 @@ +import { + extractPoints, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '方案摘要') + const points = extractPoints(inputText, [ + '明确方案目标和解决的问题', + '提炼核心价值与收益', + '压缩关键实施路径', + '明确需要决策的事项', + ]) + const summaryContent = [ + `# ${topic}`, + '', + '## 核心结论', + `${topic} 已压缩为适合汇报和快速预读的摘要版本,重点保留目标、价值点和实施建议。`, + '', + '## 关键亮点', + ...points.slice(0, 4).map((item) => `- ${item}`), + '', + '## 实施建议', + '- 建议先确认资源与排期', + '- 建议围绕高优先级场景先落一版 MVP', + '- 建议同步关键负责人做进一步评审', + ].join('\n').trim() + const highlightsContent = [ + '# 亮点清单', + '', + ...points.slice(0, 5).map((item, index) => `${index + 1}. ${item}`), + ].join('\n').trim() + const briefingContent = [ + `【${topic} 摘要】`, + ...points.slice(0, 3).map((item, index) => `${index + 1}. ${item}`), + '建议按上述结论进入下一步决策或评审。', + ].join('\n').trim() + const slug = slugify(topic, 'proposal-summary') + + return { + title: '方案摘要稿', + summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。', + highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'], + artifacts: [ + { + key: 'summary', + title: '方案摘要', + description: '适合直接汇报或继续细化的主摘要。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: summaryContent, + }, + { + key: 'highlights', + title: '亮点清单', + description: '压缩提炼后的价值点列表。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-highlights.md`, + content: highlightsContent, + }, + { + key: 'briefing', + title: '汇报短版', + description: '适合发群或口头汇报前快速同步的短摘要。', + kind: 'text', + fileFormat: 'txt', + mimeType: 'text/plain;charset=utf-8', + downloadName: `${slug}-briefing.txt`, + content: briefingContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/proposal-summary/manifest.js b/eai_agentplatform/frontend/src/skills/packages/proposal-summary/manifest.js new file mode 100644 index 0000000..e649512 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/proposal-summary/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.proposal-summary', + code: 'EAI-T-210', + key: 'proposal-summary', + label: '方案摘要', + category: 'summary', + artifactType: 'document', + fileFormat: 'md', + color: '#9333ea', + description: '把长方案、材料包和售前文档压缩成高密度摘要,保留核心结论、价值点和实施建议。', + defaultPrompt: '把这份解决方案压成一版管理层可读摘要', + resultTitle: '方案摘要稿', + summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。', + highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'], + taskPlanTitle: '方案摘要流程', + planSteps: ['识别方案目标', '提炼核心亮点', '压缩实施路径', '输出摘要版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/report-generation/manifest.js b/eai_agentplatform/frontend/src/skills/packages/report-generation/manifest.js new file mode 100644 index 0000000..a556068 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/report-generation/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'report-generation', + label: '报告生成', + caption: '材料归并 / 结构规划 / 正式输出', + color: '#9c27b0', + skillTags: ['日报周报', '复盘总结', '章节规划', '管理摘要'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '收拢材料与报告目标', + detail: '先看清楚素材来源、受众、汇报周期和需要强调的重点。', + expectedOutput: '报告口径', + }), + createSkillWorkflowStep({ + title: '规划章节结构', + detail: '把零散信息归到清楚的章节框架里,避免报告堆砌流水账。', + expectedOutput: '报告大纲', + }), + createSkillWorkflowStep({ + title: '生成正文与关键结论', + detail: '补齐背景、进展、问题、风险和建议,形成可读性强的主体内容。', + expectedOutput: '报告正文', + }), + createSkillWorkflowStep({ + title: '压缩成可汇报版本', + detail: '提炼管理摘要、行动项和下一步建议,便于直接提交或汇报。', + expectedOutput: '正式报告', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '报告大纲', + detail: '先快速确认结构是否合理。', + artifactType: 'outline', + fileFormat: 'md', + }), + createSkillArtifact({ + title: '正式报告', + detail: '包含正文、关键结论和建议的主交付物。', + artifactType: 'report', + fileFormat: 'docx', + }), + createSkillArtifact({ + title: '管理摘要', + detail: '适合负责人快速阅读的一页总结。', + artifactType: 'summary', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '先把材料归并成结构,再输出可直接提交的正式报告。', + tagline: '不只是把记录拼起来,而是把过程信息收成完整、清楚、可汇报的结果。', + openingPrompt: '把材料给我,并告诉我报告类型、受众、周期和你最想突出哪些重点。', + relationshipToUser: '你的报告整理技能', + starterPrompts: [ + '把今天的工作记录整理成日报,顺手补出关键风险和明日计划', + '根据这些项目节点生成周报,给负责人一版能直接看的摘要', + '把这次交付过程整理成复盘说明,并总结经验和后续建议', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/smart-assistant/manifest.js b/eai_agentplatform/frontend/src/skills/packages/smart-assistant/manifest.js new file mode 100644 index 0000000..5b31f52 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/smart-assistant/manifest.js @@ -0,0 +1,66 @@ +import { + createSkillArtifact, + createSkillManifest, + createSkillWorkflowStep, +} from '@/skills/contracts/manifest' + +export default createSkillManifest({ + key: 'smart-assistant', + label: '通用助手', + caption: '对话 / 任务拆解 / 文案生成', + color: '#409eff', + skillTags: ['任务澄清', '方案拆解', '对象分派', '交付起步'], + workflowSchema: [ + createSkillWorkflowStep({ + title: '澄清目标与约束', + detail: '先判断任务目标、时间要求、输入材料和成功标准,避免一上来就误做。', + expectedOutput: '问题边界与执行口径', + }), + createSkillWorkflowStep({ + title: '拆解执行路径', + detail: '把事情拆成可推进的步骤,识别是否需要挂载技能、专家或知识库。', + expectedOutput: '下一步清单与对象建议', + }), + createSkillWorkflowStep({ + title: '生成首轮结果', + detail: '先给出可继续迭代的首版内容,而不是只停留在建议层。', + expectedOutput: '首版答复或草案', + }), + createSkillWorkflowStep({ + title: '收口为可交付结果', + detail: '把临时讨论整理成清单、文案、结构化说明或后续动作。', + expectedOutput: '可继续推进的交付物', + }), + ], + artifactSchema: [ + createSkillArtifact({ + title: '任务拆解清单', + detail: '适合需要先理顺思路、确定下一步的事项。', + artifactType: 'plan', + fileFormat: 'md', + }), + createSkillArtifact({ + title: '首版草案', + detail: '正文、说明、话术或分析草案。', + artifactType: 'document', + fileFormat: 'md', + }), + createSkillArtifact({ + title: '后续动作建议', + detail: '建议继续挂载的对象、信息缺口与跟进路径。', + artifactType: 'checklist', + fileFormat: 'md', + }), + ], + interactionCard: { + greeting: '博昇AI数字员工,您说,我做', + tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料', + openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。', + relationshipToUser: '你的通用协作搭档', + starterPrompts: [ + '帮我拆解这个任务,并列出下一步', + '把这段产品介绍整理成正式文案', + '先帮我判断这个问题该找哪个专员或技能', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/survey-summary/execution.js b/eai_agentplatform/frontend/src/skills/packages/survey-summary/execution.js new file mode 100644 index 0000000..9b3f30f --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/survey-summary/execution.js @@ -0,0 +1,77 @@ +import { + extractPoints, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const topic = pickTopic(inputText, '问卷汇总') + const points = extractPoints(inputText, [ + '参与者的主要反馈方向', + '高频问题或不满意点', + '值得保留的积极反馈', + '后续改进建议', + ]) + const reportContent = [ + `# ${topic}`, + '', + '## 汇总结论', + ...points.slice(0, 4).map((item) => `- ${item}`), + '', + '## 汇总说明', + '已把表单和问卷结果压缩成便于阅读和决策的汇总结论。', + ].join('\n').trim() + const findingsContent = [ + '# 重点发现', + '', + `- 发现 1:${points[0] || '参与者主要关注体验和执行效果'}`, + `- 发现 2:${points[1] || '部分问题集中在流程或内容安排上'}`, + `- 发现 3:${points[2] || '已有若干积极反馈值得继续保留'}`, + ].join('\n').trim() + const actionsContent = [ + '# 改进建议', + '', + `- [ ] ${points[3] || '根据汇总结果制定下一步改进动作'}`, + '- [ ] 对高频问题安排专项优化', + '- [ ] 向负责人同步汇总结论和优先级建议', + ].join('\n').trim() + const slug = slugify(topic, 'survey-summary') + + return { + title: '问卷汇总稿', + summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。', + highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'], + artifacts: [ + { + key: 'report', + title: '汇总报告', + description: '适合直接汇报或继续细化的主报告。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: reportContent, + }, + { + key: 'findings', + title: '重点发现', + description: '从表单结果中提炼出的关键发现。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-findings.md`, + content: findingsContent, + }, + { + key: 'actions', + title: '改进建议', + description: '建议后续继续推进的改进行动。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-actions.md`, + content: actionsContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/survey-summary/manifest.js b/eai_agentplatform/frontend/src/skills/packages/survey-summary/manifest.js new file mode 100644 index 0000000..2c0fbe4 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/survey-summary/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.survey-summary', + code: 'EAI-T-215', + key: 'survey-summary', + label: '表单问卷汇总', + category: 'report', + artifactType: 'report', + fileFormat: 'md', + color: '#2563eb', + description: '把问卷、报名表和收集表单结果整理成汇总报告、重点发现和后续动作建议。', + defaultPrompt: '把这批培训反馈问卷整理成汇总结论和改进建议', + resultTitle: '问卷汇总稿', + summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。', + highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'], + taskPlanTitle: '问卷汇总流程', + planSteps: ['归并表单结果', '提炼重点发现', '整理问题与建议', '输出汇总版本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/table-cleanup/execution.js b/eai_agentplatform/frontend/src/skills/packages/table-cleanup/execution.js new file mode 100644 index 0000000..2542a60 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/table-cleanup/execution.js @@ -0,0 +1,98 @@ +import { + buildCsvText, + extractPoints, + parseStructuredFields, + parseTableColumns, + parseTableRows, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +export function buildExecution(inputText = '') { + const { fields, bodyText } = parseStructuredFields(inputText, { + topic: ['主题', '标题', 'topic', 'title'], + columns: ['列', '字段', '表头', 'columns'], + }) + const topic = fields.topic || pickTopic(bodyText || inputText, '表格整理') + const points = extractPoints(bodyText || inputText, [ + '字段 1:主对象', + '字段 2:当前状态', + '字段 3:下一步动作', + '字段 4:备注信息', + ]) + const columns = parseTableColumns(fields.columns, points) + const rows = parseTableRows(bodyText || inputText, columns, points) + const markdownRows = rows + .map((row, index) => `| ${index + 1} | ${columns.map((_, columnIndex) => row[columnIndex] || '').join(' | ')} |`) + .join('\n') + const csvContent = buildCsvText(['序号', ...columns], rows.map((row, index) => [String(index + 1), ...columns.map((_, columnIndex) => row[columnIndex] || '')])) + const tableContent = [ + `# ${topic}`, + '', + `| 序号 | ${columns.join(' | ')} |`, + `| ${['---', ...columns.map(() => '---')].join(' | ')} |`, + markdownRows || '| 1 | 示例项 | 已整理 | 待确认 |', + ].join('\n').trim() + const fieldsContent = [ + '# 字段说明', + '', + '- `序号`:用于标记记录顺序', + ...columns.map((item) => `- \`${item}\`:本次整理后的结构化字段`), + ].join('\n').trim() + const notesContent = [ + '# 清洗备注', + '', + '- 已对原始内容做字段归并', + '- 已输出 CSV 主文件,便于直接导入 Excel', + '- 建议下一步补充负责人、日期或金额等关键字段', + ].join('\n').trim() + const slug = slugify(topic, 'table-cleanup') + + return { + title: '表格整理结果', + summary: '已把输入内容整理成结构化表格,并补了 CSV 主文件、字段说明和清洗备注。', + highlights: ['适合名单、台账、记录整理', '支持直接下载 CSV 导入 Excel', '便于继续导入系统或二次清洗'], + artifacts: [ + { + key: 'csv', + title: 'CSV 主表', + description: '适合直接导入 Excel 或系统的主文件。', + kind: 'text', + fileFormat: 'csv', + mimeType: 'text/csv;charset=utf-8', + downloadName: `${slug}.csv`, + content: csvContent, + }, + { + key: 'table', + title: '表格预览', + description: '适合先快速预览整理结果的 Markdown 版本。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}.md`, + content: tableContent, + }, + { + key: 'fields', + title: '字段说明', + description: '记录各字段的含义和使用方式。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-fields.md`, + content: fieldsContent, + }, + { + key: 'notes', + title: '清洗备注', + description: '说明本次整理和清洗做了哪些处理。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-notes.md`, + content: notesContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/table-cleanup/manifest.js b/eai_agentplatform/frontend/src/skills/packages/table-cleanup/manifest.js new file mode 100644 index 0000000..6044c6a --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/table-cleanup/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.table-cleanup', + code: 'EAI-T-209', + key: 'table-cleanup', + label: '表格整理', + category: 'data', + artifactType: 'table', + fileFormat: 'md', + color: '#475569', + description: '把零散字段、半结构化列表和杂乱数据整理成清晰表格,并补出字段说明与清洗备注。', + defaultPrompt: '把这批客户跟进记录整理成结构化表格', + resultTitle: '表格整理结果', + summary: '已把输入内容整理成结构化表格,并补了字段说明和清洗备注。', + highlights: ['适合名单、台账、记录整理', '默认沉淀表格正文和字段说明', '便于继续导入 Excel 或系统'], + taskPlanTitle: '表格整理流程', + planSteps: ['识别字段结构', '清洗原始数据', '整理表格内容', '补充字段说明'], +}) diff --git a/eai_agentplatform/frontend/src/skills/packages/text-toolkit/execution.js b/eai_agentplatform/frontend/src/skills/packages/text-toolkit/execution.js new file mode 100644 index 0000000..d652ade --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/text-toolkit/execution.js @@ -0,0 +1,62 @@ +import { + meaningfulLines, + pickTopic, + slugify, +} from '@/skills/shared/officeArtifacts' + +function stripMarkdown(text) { + return meaningfulLines(String(text || '') + .replace(/```[\s\S]*?```/g, '') + .replace(/`([^`]+)`/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/[*_>#-]+/g, ' ')) + .join('\n') +} + +export function buildExecution(inputText = '') { + const cleaned = stripMarkdown(inputText) + const chars = cleaned.length + const lines = meaningfulLines(cleaned).length + const statsContent = [ + '# 文本统计', + '', + `- 字符数:${chars}`, + `- 行数:${lines}`, + `- 原始长度:${String(inputText || '').trim().length}`, + '', + '## 处理说明', + '- 已移除常见 Markdown 标记', + '- 已压缩多余空行', + '- 已保留正文可读性', + ].join('\n') + const topic = pickTopic(inputText, 'cleaned-text') + const slug = slugify(topic, 'text-toolkit') + + return { + title: '清洗后文本', + summary: '已完成 Markdown 去噪、格式整理和文本统计,可直接下载或复制结果。', + highlights: ['去 Markdown 噪音', '输出文本统计', '适合高频复制到 Word / IM 场景'], + artifacts: [ + { + key: 'cleaned', + title: '清洗后文本', + description: '适合直接粘贴到文档或聊天工具。', + kind: 'text', + fileFormat: 'txt', + mimeType: 'text/plain;charset=utf-8', + downloadName: `${slug}.txt`, + content: cleaned, + }, + { + key: 'stats', + title: '文本统计', + description: '记录本次清洗和统计结果。', + kind: 'text', + fileFormat: 'md', + mimeType: 'text/markdown;charset=utf-8', + downloadName: `${slug}-stats.md`, + content: statsContent, + }, + ], + } +} diff --git a/eai_agentplatform/frontend/src/skills/packages/text-toolkit/manifest.js b/eai_agentplatform/frontend/src/skills/packages/text-toolkit/manifest.js new file mode 100644 index 0000000..6337c76 --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/packages/text-toolkit/manifest.js @@ -0,0 +1,19 @@ +import { createOfficeSkillManifest } from '@/skills/contracts/manifest' + +export default createOfficeSkillManifest({ + id: 'eai.text-toolkit', + code: 'EAI-T-204', + key: 'text-toolkit', + label: '文本处理', + category: 'text', + artifactType: 'document', + fileFormat: 'md', + color: '#334155', + description: '用于去 Markdown 符号、格式整理、文本统计与批量清洗,让复制出来的内容更适合继续使用。', + defaultPrompt: '把这段 AI 回答清洗成适合粘贴到 Word 的纯文本', + resultTitle: '清洗后文本', + summary: '已按文本处理工具箱模式整理好内容,方便继续复制、统计或二次编辑。', + highlights: ['去 Markdown 噪音', '统一段落和标点', '适合高频复制粘贴场景'], + taskPlanTitle: '文本处理流程', + planSteps: ['识别清洗目标', '去除噪音符号', '统一格式与标点', '输出可复用文本'], +}) diff --git a/eai_agentplatform/frontend/src/skills/eai/workbuddy-excel-skill/ResultCard.vue b/eai_agentplatform/frontend/src/skills/packages/workbuddy-excel-skill/ResultCard.vue similarity index 100% rename from eai_agentplatform/frontend/src/skills/eai/workbuddy-excel-skill/ResultCard.vue rename to eai_agentplatform/frontend/src/skills/packages/workbuddy-excel-skill/ResultCard.vue diff --git a/eai_agentplatform/frontend/src/skills/eai/workbuddy-excel-skill/executor.js b/eai_agentplatform/frontend/src/skills/packages/workbuddy-excel-skill/executor.js similarity index 100% rename from eai_agentplatform/frontend/src/skills/eai/workbuddy-excel-skill/executor.js rename to eai_agentplatform/frontend/src/skills/packages/workbuddy-excel-skill/executor.js diff --git a/eai_agentplatform/frontend/src/skills/eai/workbuddy-excel-skill/index.js b/eai_agentplatform/frontend/src/skills/packages/workbuddy-excel-skill/index.js similarity index 100% rename from eai_agentplatform/frontend/src/skills/eai/workbuddy-excel-skill/index.js rename to eai_agentplatform/frontend/src/skills/packages/workbuddy-excel-skill/index.js diff --git a/eai_agentplatform/frontend/src/skills/registry/eai.js b/eai_agentplatform/frontend/src/skills/registry/eai.js index 6c099c4..1a15be2 100644 --- a/eai_agentplatform/frontend/src/skills/registry/eai.js +++ b/eai_agentplatform/frontend/src/skills/registry/eai.js @@ -1,8 +1,11 @@ -import workbuddyExcelSkill from '@/skills/eai/workbuddy-excel-skill' +import workbuddyExcelSkill from '@/skills/packages/workbuddy-excel-skill' import OfficeSkillResultCard from '@/skills/shared/OfficeSkillResultCard.vue' -import { createOfficeSkillRegistryDefinitions } from '@/skills/shared/officeSkillCatalog' +import { builtinSkillManifests } from '@/skills/core/builtin' +import { createOfficeSkillDefinition } from '@/skills/shared/officeSkill' -const officeSkillRegistry = createOfficeSkillRegistryDefinitions(OfficeSkillResultCard) +const officeSkillRegistry = builtinSkillManifests + .filter((item) => item?.officeMeta) + .map((item) => createOfficeSkillDefinition(item, OfficeSkillResultCard)) export const eaiSkillRegistry = [workbuddyExcelSkill, ...officeSkillRegistry] diff --git a/eai_agentplatform/frontend/src/skills/shared/OfficeSkillResultCard.vue b/eai_agentplatform/frontend/src/skills/shared/OfficeSkillResultCard.vue index bdc961b..29db97e 100644 --- a/eai_agentplatform/frontend/src/skills/shared/OfficeSkillResultCard.vue +++ b/eai_agentplatform/frontend/src/skills/shared/OfficeSkillResultCard.vue @@ -105,7 +105,7 @@ import { computed, ref } from 'vue' import { downloadOfficeArtifact } from './officeArtifacts' import { executeOfficeSkillOcr } from '@/api/officeSkill' -import { useWorkerRuntimeStore } from '@/store/workerRuntime' +import { useTaskRuntimeStore } from '@/store/taskRuntime' import { useWorkbenchStore } from '@/store/workbench' const props = defineProps({ @@ -119,7 +119,7 @@ const fileInputRef = ref(null) const ocrRunning = ref(false) const ocrText = ref('') const ocrError = ref('') -const workerRuntime = useWorkerRuntimeStore() +const taskRuntime = useTaskRuntimeStore() const workbenchStore = useWorkbenchStore() const requestText = computed(() => props.result?.request?.plainText || '') @@ -159,12 +159,12 @@ async function handleFileChange(event) { try { const formData = new FormData() formData.append('file', file) - formData.append('task_id', String(workerRuntime.currentTaskId || '')) + formData.append('task_id', String(taskRuntime.currentTaskId || '')) formData.append('prompt', props.result?.interactive?.prompt || '') const res = await executeOfficeSkillOcr(formData) ocrText.value = String(res?.data?.text || '').trim() - if (workerRuntime.currentTaskId) { - await workerRuntime.loadTaskDetail(workerRuntime.currentTaskId) + if (taskRuntime.currentTaskId) { + await taskRuntime.loadTaskDetail(taskRuntime.currentTaskId) } } catch (error) { ocrError.value = error?.message || '图片识别失败,请重试。' diff --git a/eai_agentplatform/frontend/src/skills/shared/officeArtifacts.js b/eai_agentplatform/frontend/src/skills/shared/officeArtifacts.js index f7cff22..41ea897 100644 --- a/eai_agentplatform/frontend/src/skills/shared/officeArtifacts.js +++ b/eai_agentplatform/frontend/src/skills/shared/officeArtifacts.js @@ -1,15 +1,15 @@ -function normalizeText(text) { +export function normalizeText(text) { return String(text || '').replace(/\r/g, '\n').trim() } -function meaningfulLines(text) { +export function meaningfulLines(text) { return normalizeText(text) .split('\n') .map((line) => line.trim()) .filter(Boolean) } -function pickTopic(text, fallback = '当前主题') { +export function pickTopic(text, fallback = '当前主题') { const [firstLine = ''] = meaningfulLines(text) const normalized = firstLine .replace(/^[#>*`\-\d.\s]+/, '') @@ -18,7 +18,7 @@ function pickTopic(text, fallback = '当前主题') { return normalized || fallback } -function slugify(text, fallback = 'artifact') { +export function slugify(text, fallback = 'artifact') { const ascii = String(text || '') .toLowerCase() .replace(/[^a-z0-9]+/g, '-') @@ -26,7 +26,7 @@ function slugify(text, fallback = 'artifact') { return ascii || fallback } -function extractPoints(text, fallbackPoints = []) { +export function extractPoints(text, fallbackPoints = []) { const lines = meaningfulLines(text) const bulletLines = lines .map((line) => line.replace(/^[•\-*+\d.)、\s]+/, '').trim()) @@ -47,7 +47,7 @@ function escapeRegExp(text) { return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } -function parseStructuredFields(text, aliasMap = {}) { +export function parseStructuredFields(text, aliasMap = {}) { const fields = {} const restLines = [] meaningfulLines(text).forEach((line) => { @@ -68,7 +68,7 @@ function parseStructuredFields(text, aliasMap = {}) { } } -function parsePositiveInt(value, fallback) { +export function parsePositiveInt(value, fallback) { const matched = String(value || '').match(/\d+/) if (!matched) return fallback const parsed = Number(matched[0]) @@ -76,23 +76,23 @@ function parsePositiveInt(value, fallback) { return parsed } -function firstNonEmpty(...values) { +export function firstNonEmpty(...values) { return values.map((item) => String(item || '').trim()).find(Boolean) || '' } -function parseDueText(text) { +export function parseDueText(text) { const matched = String(text || '').match(/(\d{1,2}[月/-]\d{1,2}[日号]?(?:前)?|本周[一二三四五六日天](?:前)?|下周[一二三四五六日天](?:前)?|周[一二三四五六日天](?:前)?|今天|明天|后天|本周内|下周内)/) return matched?.[1] || '待定' } -function normalizeActionText(text) { +export function normalizeActionText(text) { return String(text || '') .replace(/^(action\s*\d*[::]?|跟进[::]?|负责[::]?)/i, '') .replace(/\s+/g, ' ') .trim() } -function parseMeetingActions(text, fallbackPoints = []) { +export function parseMeetingActions(text, fallbackPoints = []) { const actions = [] meaningfulLines(text).forEach((line) => { const cleaned = line.replace(/^[•\-*+\d.)、\s]+/, '').trim() @@ -129,7 +129,7 @@ function parseMeetingActions(text, fallbackPoints = []) { })) } -function parseTableColumns(rawColumns, points) { +export function parseTableColumns(rawColumns, points) { const items = String(rawColumns || '') .split(/[,,|/、\t]/) .map((item) => item.trim()) @@ -138,7 +138,7 @@ function parseTableColumns(rawColumns, points) { return ['项目', '状态', '负责人', '备注'].slice(0, Math.max(4, Math.min(points.length || 4, 6))) } -function parseTableRows(text, columns, fallbackPoints = []) { +export function parseTableRows(text, columns, fallbackPoints = []) { const rows = [] meaningfulLines(text).forEach((line) => { if (/^(主题|标题|topic|title|列|字段|表头|columns)\s*[::]/i.test(line)) return @@ -165,13 +165,13 @@ function escapeCsvCell(value) { return /[",\n]/.test(text) ? `"${text}"` : text } -function buildCsvText(columns, rows) { +export function buildCsvText(columns, rows) { return [columns, ...rows] .map((row) => row.map((cell) => escapeCsvCell(cell)).join(',')) .join('\n') } -function splitSimpleList(value) { +export function splitSimpleList(value) { return [...new Set( String(value || '') .split(/[,,、/;;\n]/) @@ -180,7 +180,7 @@ function splitSimpleList(value) { )] } -function buildProjectStageRows(points, owner, deadline) { +export function buildProjectStageRows(points, owner, deadline) { const labels = ['阶段 1', '阶段 2', '阶段 3', '阶段 4'] const windows = ['第 1 周', '第 2 周', '第 3 周', deadline || '收口前'] return labels.map((label, index) => ({ @@ -192,7 +192,7 @@ function buildProjectStageRows(points, owner, deadline) { })) } -function buildEmailSubjectVariants(topic, purpose) { +export function buildEmailSubjectVariants(topic, purpose) { const base = String(topic || '事项').trim() const context = String(purpose || '').trim() return [ @@ -203,7 +203,7 @@ function buildEmailSubjectVariants(topic, purpose) { ] } -function splitFocusKeywords(value) { +export function splitFocusKeywords(value) { return [...new Set( String(value || '') .split(/[,,、/;;]/) @@ -212,7 +212,7 @@ function splitFocusKeywords(value) { )] } -function buildContractRedlineItems(points, focusKeywords) { +export function buildContractRedlineItems(points, focusKeywords) { const defaults = [ { clause: '付款条款', @@ -244,7 +244,7 @@ function buildContractRedlineItems(points, focusKeywords) { }).slice(0, 5) } -function buildMarkdownOutline(title, sections) { +export function buildMarkdownOutline(title, sections) { return [`# ${title}`, '', ...sections.flatMap((section) => [ `## ${section.title}`, ...section.bullets.map((bullet) => `- ${bullet}`), @@ -252,22 +252,7 @@ function buildMarkdownOutline(title, sections) { ])].join('\n').trim() } -function stripMarkdown(text) { - return normalizeText(text) - .replace(/```[\s\S]*?```/g, (match) => match.replace(/```/g, '').trim()) - .replace(/`([^`]+)`/g, '$1') - .replace(/^#{1,6}\s+/gm, '') - .replace(/^\s*[-*+]\s+/gm, '') - .replace(/^\s*\d+[.)]\s+/gm, '') - .replace(/\*\*(.*?)\*\*/g, '$1') - .replace(/\*(.*?)\*/g, '$1') - .replace(/!\[[^\]]*]\([^)]*\)/g, '') - .replace(/\[([^\]]+)\]\(([^)]*)\)/g, '$1') - .replace(/\n{3,}/g, '\n\n') - .trim() -} - -function buildMindMapSvg(rootTopic, branches) { +export function buildMindMapSvg(rootTopic, branches) { const width = 1080 const laneHeight = 120 const height = Math.max(320, branches.length * laneHeight + 80) @@ -298,1184 +283,6 @@ function buildMindMapSvg(rootTopic, branches) { `.trim() } -function buildPptArtifacts(inputText) { - const { fields, bodyText } = parseStructuredFields(inputText, { - topic: ['主题', '标题', 'topic', 'title'], - audience: ['受众', '对象', 'audience'], - goal: ['目标', '用途', 'goal'], - pages: ['页数', '页面数', 'slides', 'pages'], - style: ['风格', '语气', 'style', 'tone'], - }) - const topic = fields.topic || pickTopic(bodyText || inputText, '汇报主题') - const audience = fields.audience || '管理层 / 项目相关方' - const goal = fields.goal || '帮助受众快速理解核心结论并推动下一步' - const pageCount = Math.max(6, Math.min(parsePositiveInt(fields.pages, 8), 12)) - const style = fields.style || '专业、简洁、结论先行' - const points = extractPoints(bodyText || inputText, [ - '交代背景和目标', - '整理当前问题与机会', - '给出解决路径与方案', - '明确执行节奏与下一步', - ]) - const slideSections = [ - { title: '封面', bullets: [topic, `受众:${audience}`, `风格:${style}`] }, - { title: '背景与目标', bullets: points.slice(0, 2) }, - { title: '现状与问题', bullets: points.slice(1, 4) }, - { title: '方案路径', bullets: points.slice(2, 5) }, - { title: '执行计划', bullets: [goal, '阶段 1:启动与对齐', '阶段 2:执行与校正'] }, - { title: '风险与下一步', bullets: ['风险项与依赖', '需要支持的事项', '下一步行动清单'] }, - ] - while (slideSections.length < pageCount) { - slideSections.splice(slideSections.length - 1, 0, { - title: `补充页 ${slideSections.length - 3}`, - bullets: points.slice(0, 3).length ? points.slice(0, 3) : ['补充论据', '数据支撑', '案例说明'], - }) - } - - const outlineMarkdown = buildMarkdownOutline(`${topic} PPT 大纲`, slideSections) - const notesMarkdown = slideSections - .map((section, index) => [ - `## 第 ${index + 1} 页:${section.title}`, - `- 建议讲法:先说明“${section.title}”的核心判断,再展开 2-3 个重点。`, - ...section.bullets.map((bullet) => `- 讲稿提示:${bullet}`), - '', - ].join('\n')) - .join('\n') - .trim() - - const slug = slugify(topic, 'presentation') - - return { - title: '演示文稿骨架', - summary: `已按“${topic} / ${audience} / ${pageCount} 页”生成一版 PPT 结构,同时附上大纲和讲稿备注。`, - highlights: ['支持直接下载 .pptx', '能识别主题、受众、页数等信息', '同时保留可读的大纲与讲稿备注'], - artifacts: [ - { - key: 'presentation', - title: '演示文稿', - description: '浏览器端即时生成的 PPT 文件。', - kind: 'pptx', - fileFormat: 'pptx', - downloadName: `${slug}.pptx`, - data: { - topic, - slides: slideSections, - }, - }, - { - key: 'outline', - title: '演示大纲', - description: '适合先确认页面结构和内容分配。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-outline.md`, - content: outlineMarkdown, - }, - { - key: 'notes', - title: '讲稿备注', - description: '逐页的讲解提示和过渡说明。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-notes.md`, - content: notesMarkdown, - }, - ], - } -} - -function buildMindMapArtifacts(inputText) { - const topic = pickTopic(inputText, '任务导图') - const branches = extractPoints(inputText, [ - '目标与范围', - '输入材料', - '关键步骤', - '风险与依赖', - ]).slice(0, 6) - - const outlineMarkdown = [`# ${topic} 思维导图`, '', ...branches.map((branch) => `- ${branch}`)].join('\n') - const svgContent = buildMindMapSvg(topic, branches) - const slug = slugify(topic, 'mind-map') - - return { - title: '导图结构草案', - summary: '已把主题压成一版导图结构,并附带可直接下载的 SVG 导图文件。', - highlights: ['可下载导图 SVG', '保留文字版结构', '适合任务拆解与知识梳理'], - artifacts: [ - { - key: 'mindmap-svg', - title: '导图图片', - description: '便于直接查看和分享的 SVG 导图。', - kind: 'svg', - fileFormat: 'svg', - mimeType: 'image/svg+xml;charset=utf-8', - downloadName: `${slug}.svg`, - content: svgContent, - }, - { - key: 'mindmap-outline', - title: '导图源结构', - description: '可继续编辑和扩展的文字结构版。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: outlineMarkdown, - }, - ], - } -} - -function buildLongformArtifacts(inputText) { - const topic = pickTopic(inputText, '长文主题') - const sections = [ - { title: '背景与问题', bullets: ['交代问题背景', '说明为什么现在要做这件事'] }, - { title: '目标与原则', bullets: ['写清楚目标边界', '说明采用的基本原则'] }, - { title: '主体分析', bullets: ['从现状、问题、机会三个角度展开', '补充关键证据与案例'] }, - { title: '方案与建议', bullets: ['提出可执行的方案路径', '给出必要的资源与配套要求'] }, - { title: '结论与下一步', bullets: ['收束核心结论', '明确下一步动作'] }, - ] - const outlineMarkdown = buildMarkdownOutline(`${topic} 长文大纲`, sections) - const draftContent = [ - `# ${topic}`, - '', - '## 摘要', - `${topic} 这项工作需要先把背景、目标、方案和执行动作组织清楚,再进一步扩写为完整正文。本稿先给出一版可继续加工的长文骨架。`, - '', - ...sections.flatMap((section) => [ - `## ${section.title}`, - `${section.title}部分应围绕“${topic}”展开,建议先给出清晰判断,再用材料、案例和拆解步骤支撑结论。`, - ...section.bullets.map((bullet) => `- ${bullet}`), - '', - ]), - ].join('\n').trim() - const slug = slugify(topic, 'longform') - - return { - title: '长文草稿', - summary: '已输出一版长文大纲和正文骨架,方便继续细化成正式稿。', - highlights: ['先给大纲再给正文草稿', '适合方案、制度、长文章', '结果可直接下载继续改写'], - artifacts: [ - { - key: 'outline', - title: '章节大纲', - description: '先确认结构是否合理。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-outline.md`, - content: outlineMarkdown, - }, - { - key: 'draft', - title: '正文草稿', - description: '可继续细化的正文版本。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-draft.md`, - content: draftContent, - }, - ], - } -} - -function buildTextToolkitArtifacts(inputText) { - const cleaned = stripMarkdown(inputText) - const chars = cleaned.length - const lines = meaningfulLines(cleaned).length - const statsContent = [ - '# 文本统计', - '', - `- 字符数:${chars}`, - `- 行数:${lines}`, - `- 原始长度:${normalizeText(inputText).length}`, - '', - '## 处理说明', - '- 已移除常见 Markdown 标记', - '- 已压缩多余空行', - '- 已保留正文可读性', - ].join('\n') - const topic = pickTopic(inputText, 'cleaned-text') - const slug = slugify(topic, 'text-toolkit') - - return { - title: '清洗后文本', - summary: '已完成 Markdown 去噪、格式整理和文本统计,可直接下载或复制结果。', - highlights: ['去 Markdown 噪音', '输出文本统计', '适合高频复制到 Word / IM 场景'], - artifacts: [ - { - key: 'cleaned', - title: '清洗后文本', - description: '适合直接粘贴到文档或聊天工具。', - kind: 'text', - fileFormat: 'txt', - mimeType: 'text/plain;charset=utf-8', - downloadName: `${slug}.txt`, - content: cleaned, - }, - { - key: 'stats', - title: '文本统计', - description: '记录本次清洗和统计结果。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-stats.md`, - content: statsContent, - }, - ], - } -} - -function buildOcrArtifacts(inputText) { - const topic = pickTopic(inputText, '图文理解') - return { - title: '识别结果摘要', - summary: '这张卡片支持直接上传图片做 OCR 识别,识别完成后可复制文本或下载结果。', - highlights: ['浏览器端直接上传识别', '识别后可复制和下载', '适合截图、扫描件和表格照片'], - interactive: { - type: 'ocr-upload', - prompt: topic, - }, - artifacts: [], - } -} - -function buildMeetingMinutesArtifacts(inputText) { - const { fields, bodyText } = parseStructuredFields(inputText, { - topic: ['会议主题', '主题', 'title', 'topic'], - date: ['时间', '日期', 'date'], - attendees: ['参会人', '参会人员', 'attendees'], - objective: ['目标', '目的', 'objective'], - }) - const topic = fields.topic || pickTopic(bodyText || inputText, '会议纪要') - const meetingDate = fields.date || '待补充' - const attendees = fields.attendees || '待补充' - const objective = fields.objective || '对齐进展、结论和行动项' - const points = extractPoints(bodyText || inputText, [ - '确认本次会议目标和范围', - '同步当前进展与关键问题', - '明确下一步行动和负责人', - '记录需要继续跟进的依赖项', - ]) - const actions = parseMeetingActions(bodyText || inputText, points) - const minutesContent = [ - `# ${topic}`, - '', - '## 会议摘要', - `${topic} 已整理为正式纪要版本,建议先快速确认结论和行动项,再同步到群里或项目任务中。`, - '', - '## 会议信息', - `- 时间:${meetingDate}`, - `- 参会人:${attendees}`, - `- 会议目标:${objective}`, - '', - '## 关键结论', - ...points.slice(0, 4).map((item) => `- ${item}`), - '', - '## 待办事项', - ...actions.map((item, index) => `${index + 1}. ${item.owner} - ${item.task}(截止:${item.due})`), - ].join('\n').trim() - const actionContent = [ - '# 会后行动项', - '', - '| 序号 | 负责人 | 行动项 | 截止时间 | 状态 |', - '| --- | --- | --- | --- | --- |', - ...actions.map((item, index) => `| ${index + 1} | ${item.owner} | ${item.task} | ${item.due} | 待开始 |`), - ].join('\n').trim() - const syncContent = [ - `【${topic}】`, - ...points.slice(0, 3).map((item, index) => `${index + 1}. ${item}`), - ...actions.slice(0, 2).map((item) => `- ${item.owner}:${item.task}(${item.due})`), - '请相关同事按行动项继续推进。', - ].join('\n').trim() - const slug = slugify(topic, 'meeting-minutes') - - return { - title: '会议纪要草稿', - summary: `已按“${topic}”整理出纪要、行动项和会后同步摘要。`, - highlights: ['包含纪要正文、行动项和同步摘要', '会识别时间、参会人和会议目标', '行动项会拆出负责人和截止时间'], - artifacts: [ - { - key: 'minutes', - title: '会议纪要', - description: '适合直接发送或继续细化的正式纪要。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: minutesContent, - }, - { - key: 'actions', - title: '行动项清单', - description: '会后跟进和任务分派用的待办清单。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-actions.md`, - content: actionContent, - }, - { - key: 'sync', - title: '会后同步摘要', - description: '适合发到群里或邮件里的简短同步版本。', - kind: 'text', - fileFormat: 'txt', - mimeType: 'text/plain;charset=utf-8', - downloadName: `${slug}-sync.txt`, - content: syncContent, - }, - ], - } -} - -function buildProjectPlanningArtifacts(inputText) { - const { fields, bodyText } = parseStructuredFields(inputText, { - topic: ['项目', '项目名称', '主题', 'topic'], - deadline: ['时间要求', '截止时间', 'deadline'], - owner: ['负责人', 'owner'], - goal: ['目标', 'goal'], - start: ['开始时间', '启动时间', 'start'], - }) - const topic = fields.topic || pickTopic(bodyText || inputText, '项目规划') - const deadline = fields.deadline || '待确认' - const owner = fields.owner || '待指定' - const goal = fields.goal || '先明确目标、阶段、依赖和协同方式' - const start = fields.start || '尽快启动' - const points = extractPoints(bodyText || inputText, [ - '明确目标、范围和成功标准', - '梳理关键里程碑与交付节点', - '安排执行分工与协同节奏', - '识别风险与外部依赖', - ]) - const stageRows = buildProjectStageRows(points, owner, deadline) - const planContent = [ - `# ${topic}`, - '', - '## 项目目标', - `围绕“${topic}”整理一版可执行的项目规划,先确保目标、阶段和依赖都清楚。`, - `- 负责人:${owner}`, - `- 启动时间:${start}`, - `- 时间要求:${deadline}`, - `- 总体目标:${goal}`, - '', - '## 阶段安排', - '### 阶段 1:启动与对齐', - `- ${points[0] || '明确目标和边界'}`, - '### 阶段 2:执行与推进', - `- ${points[1] || '推进关键里程碑和核心交付'}`, - '### 阶段 3:验收与收口', - `- ${points[2] || '完成验收、复盘和沉淀'}`, - ].join('\n').trim() - const milestoneContent = [ - '# 里程碑清单', - '', - ...points.slice(0, 5).map((item, index) => `${index + 1}. M${index + 1}:${item}`), - ].join('\n').trim() - const riskContent = [ - '# 风险与依赖', - '', - `- 风险 1:${points[3] || '关键依赖尚未完全明确'}`, - '- 风险 2:跨团队协同节奏可能影响排期', - '- 建议:将关键依赖提前前置到周会或启动会确认', - ].join('\n').trim() - const ganttContent = [ - '# 阶段排期表', - '', - '| 阶段 | 时间窗 | 负责人 | 目标 | 交付物 |', - '| --- | --- | --- | --- | --- |', - ...stageRows.map((item) => `| ${item.stage} | ${item.window} | ${item.owner} | ${item.goal} | ${item.output} |`), - ].join('\n').trim() - const slug = slugify(topic, 'project-planning') - - return { - title: '项目规划草案', - summary: `已按“${topic} / ${deadline}”整理出阶段计划、里程碑和风险清单。`, - highlights: ['阶段计划清晰', '会识别负责人、时间要求和项目目标', '新增可直接评审的阶段排期表'], - artifacts: [ - { - key: 'project-plan', - title: '项目计划', - description: '适合直接评审和继续细化的主计划。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: planContent, - }, - { - key: 'milestones', - title: '里程碑清单', - description: '关键节点和交付节奏的简洁版列表。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-milestones.md`, - content: milestoneContent, - }, - { - key: 'risks', - title: '风险清单', - description: '项目推进前需要确认的风险与依赖。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-risks.md`, - content: riskContent, - }, - { - key: 'schedule', - title: '阶段排期表', - description: '更接近甘特视角的阶段、时间窗和交付物表。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-schedule.md`, - content: ganttContent, - }, - ], - } -} - -function buildEmailDraftingArtifacts(inputText) { - const { fields, bodyText } = parseStructuredFields(inputText, { - recipient: ['收件人', '对象', 'recipient', 'to'], - cc: ['抄送', '抄送人', 'cc'], - topic: ['主题', '标题', 'topic', 'subject'], - purpose: ['目的', '意图', 'purpose'], - action: ['希望对方', '需要对方', 'action'], - deadline: ['时间', '截止时间', 'deadline'], - tone: ['语气', '风格', 'tone', 'style'], - }) - const topic = fields.topic || pickTopic(bodyText || inputText, '邮件主题') - const recipient = fields.recipient || '各位' - const ccList = splitSimpleList(fields.cc) - const purpose = fields.purpose || '同步背景和当前进展' - const action = fields.action || '请协助确认并继续推进' - const deadline = fields.deadline || '请尽快反馈' - const tone = fields.tone || '正式、明确、礼貌' - const points = extractPoints(bodyText || inputText, [ - '交代背景和当前情况', - '说明希望对方配合或确认的事项', - '给出时间点和下一步动作', - '保持礼貌但结论清楚', - ]) - const subjectVariants = buildEmailSubjectVariants(topic, purpose) - const subject = subjectVariants[0] - const emailContent = [ - `主题:${subject}`, - `收件人:${recipient}`, - `抄送:${ccList.length ? ccList.join('、') : '无'}`, - '', - `${recipient}好,`, - '', - `${purpose}。${points[0] || '这边同步一下当前事项的背景和进展。'}`, - `${action}。${points[1] || '目前需要大家协助确认相关事项。'}`, - `${deadline}。${points[2] || '如无问题,请按约定时间继续推进。'}`, - '', - `语气建议:${tone}。如有问题,请直接回复沟通。`, - '', - '谢谢。', - ].join('\n').trim() - const shortContent = [ - `【${topic}】`, - `${points[1] || '请协助确认相关事项。'}`, - `${points[2] || '如无问题请按时推进。'}`, - ].join('\n').trim() - const briefContent = [ - '# 邮件要点', - '', - `- 主题:${subject}`, - ...points.slice(0, 4).map((item) => `- ${item}`), - ].join('\n').trim() - const subjectContent = [ - '# 主题与抄送建议', - '', - `- 收件人:${recipient}`, - `- 抄送建议:${ccList.length ? ccList.join('、') : '建议抄送直属负责人 / 相关协同方'}`, - '', - '## 可选主题', - ...subjectVariants.map((item, index) => `${index + 1}. ${item}`), - ].join('\n').trim() - const slug = slugify(topic, 'email-drafting') - - return { - title: '邮件草稿', - summary: `已按“${recipient} / ${topic}”生成正式邮件、短消息版和邮件要点摘要。`, - highlights: ['适合通知、催办、确认和汇报邮件', '会识别收件人、抄送、目的、时间和语气', '新增可直接挑选的主题变体'], - artifacts: [ - { - key: 'email', - title: '正式邮件', - description: '可直接复制到邮箱发送的完整版本。', - kind: 'text', - fileFormat: 'txt', - mimeType: 'text/plain;charset=utf-8', - downloadName: `${slug}.txt`, - content: emailContent, - }, - { - key: 'short-followup', - title: '简短跟进版', - description: '适合发到 IM 或群里的短消息版本。', - kind: 'text', - fileFormat: 'txt', - mimeType: 'text/plain;charset=utf-8', - downloadName: `${slug}-short.txt`, - content: shortContent, - }, - { - key: 'brief', - title: '邮件要点', - description: '方便审批或复核邮件口径的要点版。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-brief.md`, - content: briefContent, - }, - { - key: 'subject-variants', - title: '主题与抄送建议', - description: '提供多版主题候选和抄送建议。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-subject.md`, - content: subjectContent, - }, - ], - } -} - -function buildTableCleanupArtifacts(inputText) { - const { fields, bodyText } = parseStructuredFields(inputText, { - topic: ['主题', '标题', 'topic', 'title'], - columns: ['列', '字段', '表头', 'columns'], - }) - const topic = fields.topic || pickTopic(bodyText || inputText, '表格整理') - const points = extractPoints(bodyText || inputText, [ - '字段 1:主对象', - '字段 2:当前状态', - '字段 3:下一步动作', - '字段 4:备注信息', - ]) - const columns = parseTableColumns(fields.columns, points) - const rows = parseTableRows(bodyText || inputText, columns, points) - const markdownRows = rows - .map((row, index) => `| ${index + 1} | ${columns.map((_, columnIndex) => row[columnIndex] || '').join(' | ')} |`) - .join('\n') - const csvContent = buildCsvText(['序号', ...columns], rows.map((row, index) => [String(index + 1), ...columns.map((_, columnIndex) => row[columnIndex] || '')])) - const tableContent = [ - `# ${topic}`, - '', - `| 序号 | ${columns.join(' | ')} |`, - `| ${['---', ...columns.map(() => '---')].join(' | ')} |`, - markdownRows || '| 1 | 示例项 | 已整理 | 待确认 |', - ].join('\n').trim() - const fieldsContent = [ - '# 字段说明', - '', - '- `序号`:用于标记记录顺序', - ...columns.map((item) => `- \`${item}\`:本次整理后的结构化字段`), - ].join('\n').trim() - const notesContent = [ - '# 清洗备注', - '', - '- 已对原始内容做字段归并', - '- 已输出 CSV 主文件,便于直接导入 Excel', - '- 建议下一步补充负责人、日期或金额等关键字段', - ].join('\n').trim() - const slug = slugify(topic, 'table-cleanup') - - return { - title: '表格整理结果', - summary: '已把输入内容整理成结构化表格,并补了 CSV 主文件、字段说明和清洗备注。', - highlights: ['适合名单、台账、记录整理', '支持直接下载 CSV 导入 Excel', '便于继续导入系统或二次清洗'], - artifacts: [ - { - key: 'csv', - title: 'CSV 主表', - description: '适合直接导入 Excel 或系统的主文件。', - kind: 'text', - fileFormat: 'csv', - mimeType: 'text/csv;charset=utf-8', - downloadName: `${slug}.csv`, - content: csvContent, - }, - { - key: 'table', - title: '表格预览', - description: '适合先快速预览整理结果的 Markdown 版本。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: tableContent, - }, - { - key: 'fields', - title: '字段说明', - description: '记录各字段的含义和使用方式。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-fields.md`, - content: fieldsContent, - }, - { - key: 'notes', - title: '清洗备注', - description: '说明本次整理和清洗做了哪些处理。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-notes.md`, - content: notesContent, - }, - ], - } -} - -function buildProposalSummaryArtifacts(inputText) { - const topic = pickTopic(inputText, '方案摘要') - const points = extractPoints(inputText, [ - '明确方案目标和解决的问题', - '提炼核心价值与收益', - '压缩关键实施路径', - '明确需要决策的事项', - ]) - const summaryContent = [ - `# ${topic}`, - '', - '## 核心结论', - `${topic} 已压缩为适合汇报和快速预读的摘要版本,重点保留目标、价值点和实施建议。`, - '', - '## 关键亮点', - ...points.slice(0, 4).map((item) => `- ${item}`), - '', - '## 实施建议', - '- 建议先确认资源与排期', - '- 建议围绕高优先级场景先落一版 MVP', - '- 建议同步关键负责人做进一步评审', - ].join('\n').trim() - const highlightsContent = [ - '# 亮点清单', - '', - ...points.slice(0, 5).map((item, index) => `${index + 1}. ${item}`), - ].join('\n').trim() - const briefingContent = [ - `【${topic} 摘要】`, - ...points.slice(0, 3).map((item, index) => `${index + 1}. ${item}`), - '建议按上述结论进入下一步决策或评审。', - ].join('\n').trim() - const slug = slugify(topic, 'proposal-summary') - - return { - title: '方案摘要稿', - summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。', - highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'], - artifacts: [ - { - key: 'summary', - title: '方案摘要', - description: '适合直接汇报或继续细化的主摘要。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: summaryContent, - }, - { - key: 'highlights', - title: '亮点清单', - description: '压缩提炼后的价值点列表。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-highlights.md`, - content: highlightsContent, - }, - { - key: 'briefing', - title: '汇报短版', - description: '适合发群或口头汇报前快速同步的短摘要。', - kind: 'text', - fileFormat: 'txt', - mimeType: 'text/plain;charset=utf-8', - downloadName: `${slug}-briefing.txt`, - content: briefingContent, - }, - ], - } -} - -function buildProgressReportArtifacts(inputText) { - const topic = pickTopic(inputText, '进展汇报') - const points = extractPoints(inputText, [ - '本期完成的关键工作', - '当前风险和阻塞项', - '需要协同支持的事项', - '下一步计划', - ]) - const reportContent = [ - `# ${topic}`, - '', - '## 本期进展', - `- ${points[0] || '已完成本期关键工作整理'}`, - `- ${points[1] || '已同步当前风险和问题'}`, - '', - '## 风险与问题', - `- ${points[1] || '当前仍有待继续确认的风险项'}`, - `- ${points[2] || '存在需要跨团队协同支持的事项'}`, - '', - '## 下步计划', - `- ${points[3] || '按既定节奏继续推进下一步工作'}`, - '- 根据反馈更新排期和优先级', - ].join('\n').trim() - const summaryContent = [ - `【${topic}】`, - `1. ${points[0] || '已完成本期关键工作整理'}`, - `2. ${points[1] || '已同步当前风险和问题'}`, - `3. ${points[3] || '下一步按计划继续推进'}`, - ].join('\n').trim() - const checklistContent = [ - '# 跟进清单', - '', - `- [ ] ${points[1] || '继续跟进风险项'}`, - `- [ ] ${points[2] || '推动跨团队协同事项'}`, - `- [ ] ${points[3] || '执行下一步计划'}`, - ].join('\n').trim() - const slug = slugify(topic, 'progress-report') - - return { - title: '进展汇报草稿', - summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。', - highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'], - artifacts: [ - { - key: 'report', - title: '正式汇报', - description: '适合直接发送或继续细化的正式版本。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: reportContent, - }, - { - key: 'summary', - title: '简短摘要', - description: '适合发群或站会快速同步的简洁版。', - kind: 'text', - fileFormat: 'txt', - mimeType: 'text/plain;charset=utf-8', - downloadName: `${slug}-summary.txt`, - content: summaryContent, - }, - { - key: 'checklist', - title: '跟进清单', - description: '需要继续推进的风险与事项列表。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-checklist.md`, - content: checklistContent, - }, - ], - } -} - -function buildContractBriefArtifacts(inputText) { - const { fields, bodyText } = parseStructuredFields(inputText, { - topic: ['合同名称', '主题', 'topic'], - role: ['立场', '我方立场', 'role'], - focus: ['重点条款', '重点关注', 'focus'], - counterpart: ['对方', '合作方', 'counterpart'], - }) - const topic = fields.topic || pickTopic(bodyText || inputText, '合同要点') - const role = fields.role || '我方视角' - const focus = fields.focus || '付款、违约、终止、争议解决' - const counterpart = fields.counterpart || '对方主体待确认' - const points = extractPoints(bodyText || inputText, [ - '合同目的与合作范围', - '付款与结算安排', - '违约责任与赔偿约定', - '终止条件与争议处理', - ]) - const focusKeywords = splitFocusKeywords(focus) - const redlineItems = buildContractRedlineItems(points, focusKeywords) - const summaryContent = [ - `# ${topic}`, - '', - '## 审查视角', - `- 立场:${role}`, - `- 重点条款:${focus}`, - `- 对方主体:${counterpart}`, - '', - '## 核心条款', - ...points.slice(0, 4).map((item) => `- ${item}`), - '', - '## 阅读建议', - '建议先快速确认付款、违约、终止三类关键条款,再进入逐条审阅。', - ].join('\n').trim() - const riskContent = [ - '# 风险提示', - '', - `- 风险 1:${points[2] || '违约责任约定可能偏重单方义务'}`, - `- 风险 2:${points[3] || '终止或争议处理条款仍需继续确认'}`, - '- 建议:重点核对责任边界、赔偿上限和解除条件', - ].join('\n').trim() - const confirmContent = [ - '# 待确认事项', - '', - '- [ ] 付款节点与验收口径是否一致', - '- [ ] 违约责任是否存在明显失衡', - '- [ ] 终止条件与争议解决地是否可接受', - ].join('\n').trim() - const redlineContent = [ - '# 红线建议', - '', - '| 条款 | 主要风险 | 建议红线 |', - '| --- | --- | --- |', - ...redlineItems.map((item) => `| ${item.clause} | ${item.risk} | ${item.suggestion} |`), - ].join('\n').trim() - const clauseReviewContent = [ - '# 逐条审查意见', - '', - '| 条款 | 审查结论 | 主要问题 | 建议动作 |', - '| --- | --- | --- | --- |', - ...redlineItems.map((item, index) => `| ${item.clause} | ${index === 0 ? '需重点关注' : index === 1 ? '建议修改' : '建议复核'} | ${item.risk} | ${item.suggestion} |`), - ].join('\n').trim() - const slug = slugify(topic, 'contract-brief') - - return { - title: '合同要点摘要', - summary: `已按“${role} / ${focus}”提炼出合同核心条款、风险和待确认事项。`, - highlights: ['适合采购、合作、服务类合同', '会识别立场、重点条款和对方信息', '新增逐条审查意见和红线建议'], - artifacts: [ - { - key: 'summary', - title: '合同摘要', - description: '适合业务和负责人快速预读的核心条款摘要。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: summaryContent, - }, - { - key: 'risks', - title: '风险提示', - description: '提炼出的主要风险点和审查建议。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-risks.md`, - content: riskContent, - }, - { - key: 'confirm', - title: '待确认事项', - description: '需要继续确认或人工复核的关键事项。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-confirm.md`, - content: confirmContent, - }, - { - key: 'redline', - title: '红线建议', - description: '更接近合同审查稿的修改建议版本。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-redline.md`, - content: redlineContent, - }, - { - key: 'clause-review', - title: '逐条审查意见', - description: '更像法务审查单的逐条意见表。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-review.md`, - content: clauseReviewContent, - }, - ], - } -} - -function buildInterviewSummaryArtifacts(inputText) { - const topic = pickTopic(inputText, '访谈整理') - const points = extractPoints(inputText, [ - '受访者关注的核心问题', - '当前使用中的主要痛点', - '对方案或产品的积极反馈', - '后续建议和动作', - ]) - const summaryContent = [ - `# ${topic}`, - '', - '## 主题摘要', - ...points.slice(0, 4).map((item) => `- ${item}`), - '', - '## 访谈结论', - '访谈内容已压缩为主题和观点,适合继续沉淀为需求或汇报材料。', - ].join('\n').trim() - const insightContent = [ - '# 洞察结论', - '', - `- 洞察 1:${points[0] || '受访者更关注核心目标是否真正落地'}`, - `- 洞察 2:${points[1] || '当前流程中存在明显使用阻力或理解成本'}`, - `- 洞察 3:${points[2] || '已有价值点需要进一步明确放大'}`, - ].join('\n').trim() - const actionsContent = [ - '# 后续建议', - '', - `- [ ] ${points[3] || '将访谈结论转成下一步行动项'}`, - '- [ ] 对重点洞察做进一步验证', - '- [ ] 同步相关负责人决定是否进入后续迭代', - ].join('\n').trim() - const slug = slugify(topic, 'interview-summary') - - return { - title: '访谈整理稿', - summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。', - highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'], - artifacts: [ - { - key: 'summary', - title: '访谈摘要', - description: '访谈主题和观点的整理版本。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: summaryContent, - }, - { - key: 'insights', - title: '洞察结论', - description: '从访谈中提炼出的关键洞察。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-insights.md`, - content: insightContent, - }, - { - key: 'actions', - title: '后续建议', - description: '建议继续推进或验证的动作。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-actions.md`, - content: actionsContent, - }, - ], - } -} - -function buildPolicyRewriteArtifacts(inputText) { - const topic = pickTopic(inputText, '制度改写') - const points = extractPoints(inputText, [ - '明确制度适用范围', - '统一执行口径和条件', - '补齐例外和审批规则', - '强调执行责任和留痕要求', - ]) - const rewriteContent = [ - `# ${topic}`, - '', - '## 制度正文', - `${topic} 已重写为更清楚、统一的正式版本,适合继续评审或发布。`, - '', - '### 适用范围', - `- ${points[0] || '明确制度适用对象与范围'}`, - '### 执行要求', - `- ${points[1] || '统一执行口径和基本条件'}`, - `- ${points[2] || '补充例外处理与审批规则'}`, - '### 管理要求', - `- ${points[3] || '强调执行责任和记录留痕'}`, - ].join('\n').trim() - const revisionContent = [ - '# 修订说明', - '', - '- 已统一制度结构和表达口径', - '- 已补清含糊表达,增强执行可读性', - '- 建议结合实际流程再做一次法务或管理复核', - ].join('\n').trim() - const tipsContent = [ - '# 执行提示', - '', - '- 发布前建议确认适用范围与责任人', - '- 对例外情形建议补充审批路径', - '- 对留痕要求建议同步模板或系统入口', - ].join('\n').trim() - const slug = slugify(topic, 'policy-rewrite') - - return { - title: '制度改写稿', - summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。', - highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'], - artifacts: [ - { - key: 'rewrite', - title: '改写正文', - description: '适合继续评审或发布的正式版本。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: rewriteContent, - }, - { - key: 'revision', - title: '修订说明', - description: '说明本次主要改写和统一了哪些内容。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-revision.md`, - content: revisionContent, - }, - { - key: 'tips', - title: '执行提示', - description: '制度落地前需要注意的执行建议。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-tips.md`, - content: tipsContent, - }, - ], - } -} - -function buildSurveySummaryArtifacts(inputText) { - const topic = pickTopic(inputText, '问卷汇总') - const points = extractPoints(inputText, [ - '参与者的主要反馈方向', - '高频问题或不满意点', - '值得保留的积极反馈', - '后续改进建议', - ]) - const reportContent = [ - `# ${topic}`, - '', - '## 汇总结论', - ...points.slice(0, 4).map((item) => `- ${item}`), - '', - '## 汇总说明', - '已把表单和问卷结果压缩成便于阅读和决策的汇总结论。', - ].join('\n').trim() - const findingsContent = [ - '# 重点发现', - '', - `- 发现 1:${points[0] || '参与者主要关注体验和执行效果'}`, - `- 发现 2:${points[1] || '部分问题集中在流程或内容安排上'}`, - `- 发现 3:${points[2] || '已有若干积极反馈值得继续保留'}`, - ].join('\n').trim() - const actionsContent = [ - '# 改进建议', - '', - `- [ ] ${points[3] || '根据汇总结果制定下一步改进动作'}`, - '- [ ] 对高频问题安排专项优化', - '- [ ] 向负责人同步汇总结论和优先级建议', - ].join('\n').trim() - const slug = slugify(topic, 'survey-summary') - - return { - title: '问卷汇总稿', - summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。', - highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'], - artifacts: [ - { - key: 'report', - title: '汇总报告', - description: '适合直接汇报或继续细化的主报告。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}.md`, - content: reportContent, - }, - { - key: 'findings', - title: '重点发现', - description: '从表单结果中提炼出的关键发现。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-findings.md`, - content: findingsContent, - }, - { - key: 'actions', - title: '改进建议', - description: '建议后续继续推进的改进行动。', - kind: 'text', - fileFormat: 'md', - mimeType: 'text/markdown;charset=utf-8', - downloadName: `${slug}-actions.md`, - content: actionsContent, - }, - ], - } -} - -export function buildOfficeSkillExecution(definition, inputText = '') { - switch (definition?.key) { - 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 'ocr-understanding': - return buildOcrArtifacts(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 { - title: definition?.label || '技能结果', - summary: definition?.description || '', - highlights: [], - artifacts: [], - } - } -} - export async function downloadOfficeArtifact(artifact) { if (!artifact) return if (artifact.kind === 'pptx') { diff --git a/eai_agentplatform/frontend/src/skills/shared/officeExecutionRegistry.js b/eai_agentplatform/frontend/src/skills/shared/officeExecutionRegistry.js new file mode 100644 index 0000000..98e232d --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/shared/officeExecutionRegistry.js @@ -0,0 +1,53 @@ +/** + * Registry for office skill execution builders. + * + * Shared files import from this module only. + * Package executors register themselves via `registerOfficeExecution`. + */ + +const officeExecutionRegistry = Object.freeze(Object.create(null)) + +/** + * Register an execution builder for an office skill. + * Call this from package-level index files so that each package self-registers. + * + * @param {string} skillKey - The unique key of the skill (e.g. 'ppt-generation') + * @param {function} builder - A function that takes inputText and returns { title, summary, highlights, artifacts, ... } + */ +export function registerOfficeExecution(skillKey, builder) { + if (typeof skillKey === 'string' && skillKey.trim()) { + officeExecutionRegistry[skillKey.trim()] = builder + } +} + +/** + * Retrieve the execution builder for a given skill key. + * + * @param {string|undefined} skillKey + * @returns {function|null} + */ +export function getOfficeExecutionBuilder(skillKey) { + const key = String(skillKey || '').trim() + return key ? (officeExecutionRegistry[key] || null) : null +} + +/** + * Build an office skill execution result by looking up the builder + * for the skill defined in the manifest. + * + * @param {Object} definition - The skill manifest/definition object (must have .key) + * @param {string} inputText - The input text to process + * @returns {Object} Execution result + */ +export function buildOfficeSkillExecution(definition, inputText = '') { + const builder = getOfficeExecutionBuilder(definition?.key) + if (!builder) { + return { + title: definition?.label || '技能结果', + summary: definition?.description || '', + highlights: [], + artifacts: [], + } + } + return builder(inputText) +} diff --git a/eai_agentplatform/frontend/src/skills/shared/officeSkill.js b/eai_agentplatform/frontend/src/skills/shared/officeSkill.js new file mode 100644 index 0000000..7954aeb --- /dev/null +++ b/eai_agentplatform/frontend/src/skills/shared/officeSkill.js @@ -0,0 +1,93 @@ +import { SKILL_SOURCES } from './types' +import { + buildComposerBlocks, + hasSendableContent, + serializeBlocksToText, +} from './runtime' +import { buildOfficeSkillExecution } from './officeExecutionRegistry' +import { executeOfficeSkill as executeOfficeSkillRequest } from '@/api/officeSkill' + +export function createOfficeSkillDefinition(manifest, resultCard) { + return { + id: String(manifest?.id || `eai.${manifest?.key || 'office-skill'}`), + code: String(manifest?.code || '').trim(), + source: manifest?.source || SKILL_SOURCES.EAI, + key: String(manifest?.key || '').trim(), + label: String(manifest?.label || '').trim(), + kind: manifest?.kind || 'skill', + icon: manifest?.icon || 'skill', + description: manifest?.description || manifest?.caption || '', + officeMeta: manifest?.officeMeta || {}, + executor: (context = {}) => executeOfficeSkill({ ...context, definition: manifest }), + resultCard, + } +} + +export function buildOfficeSkillPayload(definition, inputText = '') { + const blocks = buildComposerBlocks({ + resource: { + key: definition?.key, + label: definition?.label, + uri: `skill://${definition?.key}`, + description: definition?.description, + skillId: definition?.key, + icon: definition?.icon || 'skill', + }, + text: inputText, + }) + return { + blocks, + plainText: serializeBlocksToText(blocks), + sendable: hasSendableContent(blocks), + artifactType: definition?.officeMeta?.artifactType || 'document', + fileFormat: definition?.officeMeta?.fileFormat || 'md', + } +} + +export function buildOfficeSkillResult(definition, inputText = '') { + const planSteps = (definition?.officeMeta?.planSteps || []).map((step, index) => ({ + id: `${definition?.key || 'skill'}-${index + 1}`, + order: index + 1, + title: step, + desc: index === 0 ? '先根据你的输入确认处理口径。' : '生成可继续加工的中间结果。', + })) + const execution = buildOfficeSkillExecution(definition, inputText) + const primaryArtifact = execution?.artifacts?.[0] || null + return { + status: 'ready', + title: execution?.title || definition?.officeMeta?.resultTitle || `${definition?.label || '技能'}结果`, + summary: execution?.summary || definition?.officeMeta?.summary || '', + highlights: execution?.highlights || definition?.officeMeta?.highlights || [], + request: buildOfficeSkillPayload(definition, inputText), + interactive: execution?.interactive || null, + artifacts: Array.isArray(execution?.artifacts) ? execution.artifacts : [], + artifact: { + type: primaryArtifact?.kind || definition?.officeMeta?.artifactType || 'document', + title: primaryArtifact?.title || execution?.title || definition?.officeMeta?.resultTitle || definition?.label || '技能结果', + fileFormat: primaryArtifact?.fileFormat || definition?.officeMeta?.fileFormat || 'md', + preview: primaryArtifact?.content || inputText || definition?.officeMeta?.defaultPrompt || '', + }, + taskPlan: { + title: definition?.officeMeta?.taskPlanTitle || `${definition?.label || '技能'}流程`, + steps: planSteps, + }, + } +} + +export async function executeOfficeSkill(context = {}) { + const definition = context?.definition || {} + const inputText = String(context?.inputText || '').trim() + const fallbackInput = inputText || definition?.officeMeta?.defaultPrompt || '' + const taskId = Number(context?.taskId || 0) + if (taskId > 0 && definition?.key) { + const res = await executeOfficeSkillRequest({ + task_id: taskId, + skill_key: definition.key, + input_text: fallbackInput, + mode: context?.mode || '', + }) + const result = res?.data?.result + if (result) return result + } + return buildOfficeSkillResult(definition, fallbackInput) +} diff --git a/eai_agentplatform/frontend/src/skills/shared/officeSkillCatalog.js b/eai_agentplatform/frontend/src/skills/shared/officeSkillCatalog.js deleted file mode 100644 index 56dec2a..0000000 --- a/eai_agentplatform/frontend/src/skills/shared/officeSkillCatalog.js +++ /dev/null @@ -1,355 +0,0 @@ -import { SKILL_SOURCES } from './types' -import { - buildComposerBlocks, - hasSendableContent, - serializeBlocksToText, -} from './runtime' -import { buildOfficeSkillExecution } from './officeArtifacts' -import { executeOfficeSkill as executeOfficeSkillRequest } from '@/api/officeSkill' - -export const officeSkillCatalog = [ - { - id: 'eai.ppt-generation', - code: 'EAI-T-201', - key: 'ppt-generation', - label: 'PPT 生成', - category: 'document', - artifactType: 'presentation', - fileFormat: 'pptx', - description: '根据主题、受众和材料,先整理演示逻辑,再生成可继续细化的演示文稿骨架。', - defaultPrompt: '为季度经营复盘生成一版 8 页 PPT 大纲', - resultTitle: '演示文稿骨架', - summary: '已按主题、受众和页数要求整理出演示逻辑,并给出可继续细化的文稿骨架。', - highlights: ['适合周报、汇报、方案、复盘', '会先拆页面逻辑,再组织每页要点', '默认沉淀演示大纲与讲稿备注'], - taskPlanTitle: 'PPT 生成流程', - planSteps: ['确认汇报目标', '规划页面结构', '提炼每页要点', '整理讲稿备注'], - }, - { - id: 'eai.mind-map', - code: 'EAI-T-202', - key: 'mind-map', - label: '思维导图', - category: 'structure', - artifactType: 'mindmap', - fileFormat: 'svg', - description: '把一句话主题、会议纪要或长文本快速展开成结构化导图,适合梳理任务与知识结构。', - defaultPrompt: '把这次项目启动会整理成一版思维导图', - resultTitle: '导图结构草案', - summary: '已把输入内容压缩成层级结构,方便继续扩展节点或导出成导图文件。', - highlights: ['适合任务拆解与知识梳理', '会优先生成主干与分支', '默认保留可继续编辑的导图源结构'], - taskPlanTitle: '思维导图生成流程', - planSteps: ['识别主题主线', '拆分一级分支', '补齐关键节点', '整理导图源结构'], - }, - { - id: 'eai.longform-writing', - code: 'EAI-T-203', - key: 'longform-writing', - label: '长文创作', - category: 'writing', - artifactType: 'document', - fileFormat: 'docx', - description: '统一承接论文、方案、制度文档、公众号长文等长文任务,先搭大纲,再分段生成正文。', - defaultPrompt: '围绕企业 AI 落地写一篇完整方案', - resultTitle: '长文草稿', - summary: '已按长文写作流程生成大纲与正文骨架,适合继续细化为正式稿。', - highlights: ['支持论文、方案、制度、长文', '先搭结构再出正文', '默认沉淀大纲、摘要和正文稿'], - taskPlanTitle: '长文创作流程', - planSteps: ['明确文体与受众', '规划章节大纲', '生成正文骨架', '整理摘要与交付稿'], - }, - { - id: 'eai.text-toolkit', - code: 'EAI-T-204', - key: 'text-toolkit', - label: '文本处理', - category: 'text', - artifactType: 'document', - fileFormat: 'md', - description: '用于去 Markdown 符号、格式整理、文本统计与批量清洗,让复制出来的内容更适合继续使用。', - defaultPrompt: '把这段 AI 回答清洗成适合粘贴到 Word 的纯文本', - resultTitle: '清洗后文本', - summary: '已按文本处理工具箱模式整理好内容,方便继续复制、统计或二次编辑。', - highlights: ['去 Markdown 噪音', '统一段落和标点', '适合高频复制粘贴场景'], - taskPlanTitle: '文本处理流程', - planSteps: ['识别清洗目标', '去除噪音符号', '统一格式与标点', '输出可复用文本'], - }, - { - id: 'eai.ocr-understanding', - code: 'EAI-T-205', - key: 'ocr-understanding', - label: 'OCR / 图文理解', - category: 'vision', - artifactType: 'document', - fileFormat: 'md', - description: '识别图片、截图和扫描件中的文字与结构信息,并提炼为摘要或结构化字段。', - defaultPrompt: '识别这张截图里的文字并整理成结构化摘要', - resultTitle: '识别结果摘要', - summary: '已按图文理解流程准备好识别与整理结果的第一版结构,方便继续抽字段或生成摘要。', - highlights: ['适合截图、扫描件、表格照片', '支持文字识别与结构摘要', '默认沉淀识别结果和字段草案'], - taskPlanTitle: '图文理解流程', - planSteps: ['识别图像内容', '抽取文字与字段', '整理结构摘要', '输出可复用结果'], - }, - { - id: 'eai.meeting-minutes', - code: 'EAI-T-206', - key: 'meeting-minutes', - label: '会议纪要', - category: 'document', - artifactType: 'document', - fileFormat: 'md', - description: '把会议记录、聊天记录和零散讨论整理成正式纪要、决议摘要和待办清单。', - defaultPrompt: '把这段项目例会记录整理成正式会议纪要', - resultTitle: '会议纪要草稿', - summary: '已按会议纪要结构整理出主题、结论和待办,方便直接同步给参会人。', - highlights: ['自动提炼结论与待办', '适合例会、评审会和复盘会', '默认沉淀纪要正文和行动项清单'], - taskPlanTitle: '会议纪要整理流程', - planSteps: ['识别会议主题', '提炼关键结论', '整理行动项', '输出同步版本'], - }, - { - id: 'eai.project-planning', - code: 'EAI-T-207', - key: 'project-planning', - label: '项目规划', - category: 'planning', - artifactType: 'plan', - fileFormat: 'md', - description: '根据目标、约束和现状整理项目阶段、里程碑、执行分工和风险清单。', - defaultPrompt: '为新产品上线项目整理一版执行计划和里程碑', - resultTitle: '项目规划草案', - summary: '已把目标拆成阶段计划、里程碑和风险项,方便直接进入执行或评审。', - highlights: ['适合启动、排期和推进对齐', '默认沉淀阶段计划与风险清单', '结果适合继续流转到任务治理'], - taskPlanTitle: '项目规划流程', - planSteps: ['明确目标边界', '拆解阶段路径', '整理里程碑', '补齐风险与依赖'], - }, - { - id: 'eai.email-drafting', - code: 'EAI-T-208', - key: 'email-drafting', - label: '邮件起草', - category: 'communication', - artifactType: 'document', - fileFormat: 'txt', - description: '把沟通意图、背景和要求整理成可直接发送的正式邮件,并附带简短跟进版本。', - defaultPrompt: '帮我起草一封推进客户回款的正式邮件', - resultTitle: '邮件草稿', - summary: '已生成一版可直接发送的邮件正文,并补了简短跟进版本和要点摘要。', - highlights: ['适合汇报、催办、确认和通知', '兼顾正式语气与清晰表达', '默认沉淀长邮件和短消息版本'], - taskPlanTitle: '邮件起草流程', - planSteps: ['识别沟通目的', '整理背景与重点', '生成正式邮件', '补充简短跟进版本'], - }, - { - id: 'eai.table-cleanup', - code: 'EAI-T-209', - key: 'table-cleanup', - label: '表格整理', - category: 'data', - artifactType: 'table', - fileFormat: 'md', - description: '把零散字段、半结构化列表和杂乱数据整理成清晰表格,并补出字段说明与清洗备注。', - defaultPrompt: '把这批客户跟进记录整理成结构化表格', - resultTitle: '表格整理结果', - summary: '已把输入内容整理成结构化表格,并补了字段说明和清洗备注。', - highlights: ['适合名单、台账、记录整理', '默认沉淀表格正文和字段说明', '便于继续导入 Excel 或系统'], - taskPlanTitle: '表格整理流程', - planSteps: ['识别字段结构', '清洗原始数据', '整理表格内容', '补充字段说明'], - }, - { - id: 'eai.proposal-summary', - code: 'EAI-T-210', - key: 'proposal-summary', - label: '方案摘要', - category: 'summary', - artifactType: 'document', - fileFormat: 'md', - description: '把长方案、材料包和售前文档压缩成高密度摘要,保留核心结论、价值点和实施建议。', - defaultPrompt: '把这份解决方案压成一版管理层可读摘要', - resultTitle: '方案摘要稿', - summary: '已提炼出方案核心结论、价值亮点和实施建议,适合快速汇报或同步。', - highlights: ['适合方案汇报和管理层预读', '自动压缩价值点与实施路径', '默认沉淀摘要正文和亮点清单'], - taskPlanTitle: '方案摘要流程', - planSteps: ['识别方案目标', '提炼核心亮点', '压缩实施路径', '输出摘要版本'], - }, - { - id: 'eai.progress-report', - code: 'EAI-T-211', - key: 'progress-report', - label: '周报 / 日报', - category: 'report', - artifactType: 'document', - fileFormat: 'md', - description: '把工作记录、聊天记录和事项进展整理成日报、周报或阶段同步,补齐风险和下步计划。', - defaultPrompt: '根据这些工作记录整理一版项目周报', - resultTitle: '进展汇报草稿', - summary: '已整理出本期进展、风险事项和下一步计划,方便直接同步给负责人。', - highlights: ['适合日报、周报和阶段同步', '自动补齐风险与下步计划', '默认沉淀正式汇报版和简短摘要版'], - taskPlanTitle: '进展汇报流程', - planSteps: ['归并本期工作', '提炼关键进展', '整理风险事项', '输出汇报版本'], - }, - { - id: 'eai.contract-brief', - code: 'EAI-T-212', - key: 'contract-brief', - label: '合同要点提炼', - category: 'document', - artifactType: 'summary', - fileFormat: 'md', - description: '把合同、协议和条款材料压缩成核心要点、风险提示和待确认事项,方便业务快速预读。', - defaultPrompt: '把这份采购合同提炼成要点摘要和待确认项', - resultTitle: '合同要点摘要', - summary: '已提炼出合同核心条款、主要风险和待确认事项,方便快速预读和继续审查。', - highlights: ['适合采购、合作、服务类合同', '自动压缩核心条款与风险点', '默认沉淀摘要、风险和待确认项'], - taskPlanTitle: '合同要点提炼流程', - planSteps: ['识别合同结构', '提炼核心条款', '标记风险事项', '输出摘要版本'], - }, - { - id: 'eai.interview-summary', - code: 'EAI-T-213', - key: 'interview-summary', - label: '访谈整理', - category: 'summary', - artifactType: 'document', - fileFormat: 'md', - description: '把访谈记录、用户调研和深度沟通内容整理成主题摘要、洞察结论和后续动作。', - defaultPrompt: '把这份用户访谈记录整理成主题摘要和洞察结论', - resultTitle: '访谈整理稿', - summary: '已把访谈内容收成主题摘要、洞察结论和后续建议,方便继续分析或汇报。', - highlights: ['适合用户访谈、需求调研、深度沟通', '自动提炼主题与洞察', '默认沉淀摘要、洞察和行动建议'], - taskPlanTitle: '访谈整理流程', - planSteps: ['归并访谈内容', '提炼主题观点', '总结关键洞察', '输出整理版本'], - }, - { - id: 'eai.policy-rewrite', - code: 'EAI-T-214', - key: 'policy-rewrite', - label: '制度改写', - category: 'writing', - artifactType: 'document', - fileFormat: 'md', - description: '把制度、规范和说明文本重写成更清楚、更统一的正式版本,并补出修订说明。', - defaultPrompt: '把这份报销制度改写成更正式清晰的版本', - resultTitle: '制度改写稿', - summary: '已将原制度改写为更清楚统一的正式版本,并补了修订说明和执行提示。', - highlights: ['适合制度、流程、规范文档', '兼顾正式表达与清晰可读', '默认沉淀改写正文和修订说明'], - taskPlanTitle: '制度改写流程', - planSteps: ['识别制度目标', '统一结构口径', '重写正式正文', '补充修订说明'], - }, - { - id: 'eai.survey-summary', - code: 'EAI-T-215', - key: 'survey-summary', - label: '表单问卷汇总', - category: 'report', - artifactType: 'report', - fileFormat: 'md', - description: '把问卷、报名表和收集表单结果整理成汇总报告、重点发现和后续动作建议。', - defaultPrompt: '把这批培训反馈问卷整理成汇总结论和改进建议', - resultTitle: '问卷汇总稿', - summary: '已整理出表单结果汇总、重点发现和改进建议,方便直接同步给负责人。', - highlights: ['适合反馈表、报名表、调查问卷', '自动压缩重点发现和改进建议', '默认沉淀汇总报告和行动建议'], - taskPlanTitle: '问卷汇总流程', - planSteps: ['归并表单结果', '提炼重点发现', '整理问题与建议', '输出汇总版本'], - }, -] - -function createOfficeSkillDefinition(options) { - return { - id: String(options?.id || `eai.${options?.key || 'office-skill'}`), - code: String(options?.code || '').trim(), - source: SKILL_SOURCES.EAI, - key: String(options?.key || '').trim(), - label: String(options?.label || '').trim(), - kind: 'skill', - icon: options?.icon || 'skill', - description: options?.description || '', - officeMeta: { - category: options?.category || 'office', - artifactType: options?.artifactType || 'document', - fileFormat: options?.fileFormat || 'md', - defaultPrompt: options?.defaultPrompt || '', - resultTitle: options?.resultTitle || options?.label || '技能结果', - summary: options?.summary || '', - highlights: Array.isArray(options?.highlights) ? options.highlights : [], - taskPlanTitle: options?.taskPlanTitle || `${options?.label || '技能'}流程`, - planSteps: Array.isArray(options?.planSteps) ? options.planSteps : [], - }, - executor: options?.executor, - resultCard: options?.resultCard, - } -} - -function buildOfficeSkillPayload(definition, inputText = '') { - const blocks = buildComposerBlocks({ - resource: { - key: definition?.key, - label: definition?.label, - uri: `skill://${definition?.key}`, - description: definition?.description, - skillId: definition?.key, - icon: definition?.icon || 'skill', - }, - text: inputText, - }) - return { - blocks, - plainText: serializeBlocksToText(blocks), - sendable: hasSendableContent(blocks), - artifactType: definition?.officeMeta?.artifactType || 'document', - fileFormat: definition?.officeMeta?.fileFormat || 'md', - } -} - -function buildOfficeSkillResult(definition, inputText = '') { - const planSteps = (definition?.officeMeta?.planSteps || []).map((step, index) => ({ - id: `${definition?.key || 'skill'}-${index + 1}`, - order: index + 1, - title: step, - desc: index === 0 ? '先根据你的输入确认处理口径。' : '生成可继续加工的中间结果。', - })) - const execution = buildOfficeSkillExecution(definition, inputText) - const primaryArtifact = execution?.artifacts?.[0] || null - return { - status: 'ready', - title: execution?.title || definition?.officeMeta?.resultTitle || `${definition?.label || '技能'}结果`, - summary: execution?.summary || definition?.officeMeta?.summary || '', - highlights: execution?.highlights || definition?.officeMeta?.highlights || [], - request: buildOfficeSkillPayload(definition, inputText), - interactive: execution?.interactive || null, - artifacts: Array.isArray(execution?.artifacts) ? execution.artifacts : [], - artifact: { - type: primaryArtifact?.kind || definition?.officeMeta?.artifactType || 'document', - title: primaryArtifact?.title || execution?.title || definition?.officeMeta?.resultTitle || definition?.label || '技能结果', - fileFormat: primaryArtifact?.fileFormat || definition?.officeMeta?.fileFormat || 'md', - preview: primaryArtifact?.content || inputText || definition?.officeMeta?.defaultPrompt || '', - }, - taskPlan: { - title: definition?.officeMeta?.taskPlanTitle || `${definition?.label || '技能'}流程`, - steps: planSteps, - }, - } -} - -async function executeOfficeSkill(context = {}) { - const inputText = String(context?.inputText || '').trim() - const fallbackInput = inputText || context.definition?.officeMeta?.defaultPrompt || '' - const taskId = Number(context?.taskId || 0) - if (taskId > 0 && context?.definition?.key) { - const res = await executeOfficeSkillRequest({ - task_id: taskId, - skill_key: context.definition.key, - input_text: fallbackInput, - mode: context?.mode || '', - }) - const result = res?.data?.result - if (result) return result - } - return buildOfficeSkillResult(context.definition, fallbackInput) -} - -export function createOfficeSkillRegistryDefinitions(resultCard) { - return officeSkillCatalog.map((item) => - createOfficeSkillDefinition({ - ...item, - executor: executeOfficeSkill, - resultCard, - }) - ) -} diff --git a/eai_agentplatform/frontend/src/skills/shared/schema.js b/eai_agentplatform/frontend/src/skills/shared/schema.js index 9eada51..c055e00 100644 --- a/eai_agentplatform/frontend/src/skills/shared/schema.js +++ b/eai_agentplatform/frontend/src/skills/shared/schema.js @@ -18,7 +18,7 @@ export const workbuddyReplicaSettingsSchema = Object.freeze([ }, ]) -export const workbuddyReplicaCapabilitySchema = Object.freeze({ +export const workbuddyReplicaRuntimeSchema = Object.freeze({ inputBlocks: ['resource_link', 'text'], sendProtocol: 'content-blocks', executionMode: 'workbench-attached', diff --git a/eai_agentplatform/frontend/src/skills/shared/workbuddyReplica.js b/eai_agentplatform/frontend/src/skills/shared/workbuddyReplica.js index e3aeea1..99ba4c7 100644 --- a/eai_agentplatform/frontend/src/skills/shared/workbuddyReplica.js +++ b/eai_agentplatform/frontend/src/skills/shared/workbuddyReplica.js @@ -5,7 +5,7 @@ import { serializeBlocksToText, } from './runtime' import { - workbuddyReplicaCapabilitySchema, + workbuddyReplicaRuntimeSchema, workbuddyReplicaSettingsSchema, } from './schema' @@ -22,7 +22,7 @@ export function createWorkBuddyReplicaSkillDefinition(options) { icon: options?.icon || 'skill', description: options?.description || '', replicaSource: 'workbuddy', - capabilitySchema: options?.capabilitySchema || workbuddyReplicaCapabilitySchema, + runtimeSchema: options?.runtimeSchema || workbuddyReplicaRuntimeSchema, settings: options?.settings || workbuddyReplicaSettingsSchema, workbuddyMeta: { skillId: options?.workbuddySkillId || key, diff --git a/eai_agentplatform/frontend/src/store/skillCatalog.js b/eai_agentplatform/frontend/src/skills/store/skillCatalog.js similarity index 91% rename from eai_agentplatform/frontend/src/store/skillCatalog.js rename to eai_agentplatform/frontend/src/skills/store/skillCatalog.js index 69ee93a..45af4dc 100644 --- a/eai_agentplatform/frontend/src/store/skillCatalog.js +++ b/eai_agentplatform/frontend/src/skills/store/skillCatalog.js @@ -1,7 +1,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { listSkills } from '@/api/skill' -import { staticSkillCatalog as staticSkills } from '@/config/workbench' +import { listSkills } from '@/skills/api/skill' +import { getStaticSkillByKey } from '@/skills/core/builtin' function safeParse(value, fallback) { if (value == null || value === '') return fallback @@ -65,9 +65,9 @@ function normalizeWorkflowSchema(remote, fallback) { return [] } -function normalizeCapabilityTags(remote, fallback) { - if (Array.isArray(fallback?.capabilityTags) && fallback.capabilityTags.length) { - return fallback.capabilityTags +function normalizeSkillTags(remote, fallback) { + if (Array.isArray(fallback?.skillTags) && fallback.skillTags.length) { + return fallback.skillTags } const ontology = safeParse(remote.ontology_binding_json, {}) return [ @@ -77,7 +77,7 @@ function normalizeCapabilityTags(remote, fallback) { } function normalizeSkill(item) { - const fallback = staticSkills.find((skill) => skill.key === item?.key) || null + const fallback = getStaticSkillByKey(item?.key) const starterPrompts = safeParse(item?.starter_prompts_json, []) const remote = { ...item, @@ -99,7 +99,7 @@ function normalizeSkill(item) { } return { ...remote, - capabilityTags: normalizeCapabilityTags(item, fallback), + skillTags: normalizeSkillTags(item, fallback), workflowSchema: normalizeWorkflowSchema(item, fallback), artifactSchema: normalizeArtifactSchema(item?.artifact_schema_json, fallback), interactionCard: buildFallbackInteractionCard(remote, fallback), diff --git a/eai_agentplatform/frontend/src/store/skillSettings.js b/eai_agentplatform/frontend/src/skills/store/skillSettings.js similarity index 96% rename from eai_agentplatform/frontend/src/store/skillSettings.js rename to eai_agentplatform/frontend/src/skills/store/skillSettings.js index 5a7d2b0..8836da9 100644 --- a/eai_agentplatform/frontend/src/store/skillSettings.js +++ b/eai_agentplatform/frontend/src/skills/store/skillSettings.js @@ -3,7 +3,7 @@ import { computed, ref } from 'vue' import { defaultsForSkill, settingsForSkill, -} from '@/config/skillSettings' +} from '@/skills/config/skillSettings' /** * 技能设置的值。 @@ -12,8 +12,8 @@ import { * 这样「恢复默认」就是删一条记录,「有没有改过」就是看这张表空不空 —— * 不用拿两份完整对象去逐字段比。 * - * 为什么值是前端独占的:决策 5(设置跟着任务存进 worker_task)后端还没做, - * worker_task 表上没有技能和参数字段。所以这些值活不过刷新,也不进任何请求体 —— + * 为什么值是前端独占的:决策 5(设置跟着任务存进 task_record)后端还没做, + * task_record 表上没有技能和参数字段。所以这些值活不过刷新,也不进任何请求体 —— * 技能页的 handleSend 是拿 valuesOf(SKILL) 现取一份发给后端的,后端不留。 * 这一点 SkillStrip 的脚注要写明,别让用户以为改了就存下来了。 * diff --git a/eai_agentplatform/frontend/src/api/specialist.js b/eai_agentplatform/frontend/src/specialists/api/specialist.js similarity index 94% rename from eai_agentplatform/frontend/src/api/specialist.js rename to eai_agentplatform/frontend/src/specialists/api/specialist.js index 3402fa7..a5a3cf9 100644 --- a/eai_agentplatform/frontend/src/api/specialist.js +++ b/eai_agentplatform/frontend/src/specialists/api/specialist.js @@ -1,4 +1,4 @@ -import http from './http' +import http from '@/api/http' export function listSpecialists(params) { return http.get('/specialists', { params }) diff --git a/eai_agentplatform/frontend/src/specialists/contracts/manifest.js b/eai_agentplatform/frontend/src/specialists/contracts/manifest.js new file mode 100644 index 0000000..3ef26cf --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/contracts/manifest.js @@ -0,0 +1,51 @@ +export const SPECIALIST_WORKBENCH_ROUTE = '/home' + +export function createInteractionCard({ + greeting, + tagline, + openingPrompt, + relationshipToUser = '', + tone = '专业、直接、可执行', + starterPrompts = [], +} = {}) { + return { + greeting, + tagline, + openingPrompt, + relationshipToUser, + tone, + starterPrompts, + } +} + +export function createSpecialistManifest({ + key, + label, + tier, + specialistMode, + marketTag = '已安装', + objectEntryRoute = SPECIALIST_WORKBENCH_ROUTE, + summary = '', + status = '', + risk = '', + color = '', + stage = '', + progress = 0, + interactionCard = {}, +}) { + return { + key, + label, + tier, + specialistMode, + marketTag, + objectEntryRoute, + summary, + status, + risk, + color, + stage, + progress, + interactionCard: createInteractionCard(interactionCard), + } +} diff --git a/eai_agentplatform/frontend/src/specialists/core/builtin.js b/eai_agentplatform/frontend/src/specialists/core/builtin.js new file mode 100644 index 0000000..90a4457 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/core/builtin.js @@ -0,0 +1,70 @@ +import { withObjectCodes } from '@/utils/objectCode' +import { createRegistry } from '@/specialists/core/registry' +import contractReviewManifest from '@/specialists/packages/contract-review/manifest' +import solutionProposalManifest from '@/specialists/packages/solution-proposal/manifest' +import trainingDeliveryManifest from '@/specialists/packages/training-delivery/manifest' +import knowledgeOperationsManifest from '@/specialists/packages/knowledge-operations/manifest' +import processCoordinationManifest from '@/specialists/packages/process-coordination/manifest' +import reportGenerationManifest from '@/specialists/packages/report-generation/manifest' +import wechatOfficialAccountManifest from '@/specialists/packages/wechat-official-account/manifest' +import logisticsFulfillmentManifest from '@/specialists/packages/logistics-fulfillment/manifest' +import hrEmailSorterManifest from '@/specialists/packages/hr-email-sorter/manifest' +import resumeProcessorManifest from '@/specialists/packages/resume-processor/manifest' +import presentationBriefingManifest from '@/specialists/packages/presentation-briefing/manifest' + +const builtinSpecialistRegistry = createRegistry([ + contractReviewManifest, + solutionProposalManifest, + trainingDeliveryManifest, + knowledgeOperationsManifest, + processCoordinationManifest, + reportGenerationManifest, + wechatOfficialAccountManifest, + logisticsFulfillmentManifest, + hrEmailSorterManifest, + resumeProcessorManifest, + presentationBriefingManifest, +]) + +export function getBuiltinSpecialistRegistry() { + return builtinSpecialistRegistry +} + +export const staticSpecialistCatalog = withObjectCodes( + builtinSpecialistRegistry.manifests(), + { kind: 'specialist' }, +) + +export function getSpecialistsByTier(tier) { + return staticSpecialistCatalog.filter((specialist) => specialist.tier === tier) +} + +export function getInstalledSpecialistCount() { + return staticSpecialistCatalog.length +} + +export function getUpgradeableSpecialistCount() { + return staticSpecialistCatalog.filter((specialist) => specialist.marketTag === '可升级').length +} + +export function getSpecialistStats() { + return { + total: getInstalledSpecialistCount(), + generic: getSpecialistsByTier('generic').length, + industry: getSpecialistsByTier('industry').length, + dw: staticSpecialistCatalog.filter((specialist) => specialist.specialistMode === 'dw').length, + adw: staticSpecialistCatalog.filter((specialist) => specialist.specialistMode === 'adw').length, + upgradeable: getUpgradeableSpecialistCount(), + } +} + +export function getMarketItemsByTier(tier) { + return getSpecialistsByTier(tier).map((specialist) => ({ + key: specialist.key, + title: specialist.label, + tag: specialist.marketTag || '已安装', + desc: specialist.summary, + meta: `当前阶段 ${specialist.stage} · ${specialist.status} · ${specialist.risk}`, + objectEntryRoute: specialist.objectEntryRoute, + })) +} diff --git a/eai_agentplatform/frontend/src/specialists/core/presentation.js b/eai_agentplatform/frontend/src/specialists/core/presentation.js new file mode 100644 index 0000000..40ff6af --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/core/presentation.js @@ -0,0 +1,140 @@ +function normalizeArray(values) { + return (Array.isArray(values) ? values : []) + .map((item) => String(item || '').trim()) + .filter(Boolean) +} + +function splitKeywords(value) { + return String(value || '') + .split(/[\/、,,和与·]/) + .map((item) => item.trim()) + .filter((item) => item && item.length <= 12) +} + +function buildTags(...sources) { + const collected = sources + .flatMap((source) => splitKeywords(source)) + .filter(Boolean) + + return [...new Set(collected)].slice(0, 6) +} + +function parseAllowedSkills(raw) { + if (Array.isArray(raw)) return raw.map((item) => String(item || '').trim()).filter(Boolean) + const text = String(raw || '').trim() + if (!text) return [] + try { + const parsed = JSON.parse(text) + return Array.isArray(parsed) + ? parsed.map((item) => String(item || '').trim()).filter(Boolean) + : [] + } catch { + return [] + } +} + +function splitChineseList(raw) { + return String(raw || '') + .split(/[、,,\n]/) + .map((item) => item.trim()) + .filter(Boolean) +} + +export function buildSpecialistCatalogCards(specialists = []) { + return specialists.map((specialist) => ({ + key: specialist.key, + display_code: specialist.display_code, + eailogic_code: specialist.eailogic_code, + title: specialist.label, + badge: specialist.marketTag, + kind: specialist.specialistMode === 'adw' ? '编排型专员' : '执行型专员', + desc: specialist.interactionCard?.tagline || specialist.summary, + color: specialist.color, + installState: specialist.marketTag === '已安装' ? 'installed' : 'pending', + meta: [], + config: [], + searchTokens: [ + specialist.tier === 'industry' ? '行业专员' : '通用专员', + specialist.specialistMode === 'adw' ? '编排协作' : '执行协作', + specialist.marketTag, + specialist.tier, + specialist.specialistMode, + ].filter(Boolean), + })) +} + +export function getSpecialistCatalogMeta() { + return { + title: '专员目录', + description: '这里收口平台内建专员与可升级目录项。你可以按对象方式浏览、检索、查看职责边界,并继续进入专员详情。', + searchPlaceholder: '搜索专员名称、定位或能力标签', + } +} + +export function getSpecialistSectionLabels() { + return { + installedTitle: '已上岗的专员', + installedDesc: '这些专员已经上岗,可直接查看其对象定义与详情。', + installedEmpty: '暂无已上岗的专员', + pendingTitle: '待上岗的专员', + pendingDesc: '这些专员还未上岗,包括可升级与试用目录项。', + pendingEmpty: '暂无待上岗的专员', + } +} + +export function buildSpecialistUseCase(specialist) { + const openingPrompt = String(specialist?.interactionCard?.openingPrompt || '').trim() + if (openingPrompt) { + return `当你已经有目标、材料,或者已经知道当前卡点时,就适合找它介入。${openingPrompt}` + } + return '当你想找一个专门的对象来协助推进、整理和交付时,就适合让它介入。' +} + +export function buildSpecialistExtraSections(remote, { lookupSkillLabel } = {}) { + if (!remote) return [] + const sections = [] + + const skillKeys = parseAllowedSkills(remote.allowed_skills) + if (skillKeys.length) { + sections.push({ + title: '它会调用的技能', + items: skillKeys.map((key) => { + const label = typeof lookupSkillLabel === 'function' ? lookupSkillLabel(key) : '' + return label ? `${label}(${key})` : key + }), + }) + } + + const infoSources = splitChineseList(remote.info_sources) + if (infoSources.length) { + sections.push({ title: '它读哪些信源', items: infoSources }) + } + const permissions = splitChineseList(remote.permission_scope) + if (permissions.length) { + sections.push({ title: '它可以动什么', items: permissions }) + } + + return sections +} + +export function buildSpecialistDetail(specialist, remote, { lookupSkillLabel } = {}) { + return { + title: specialist.label, + display_code: specialist.display_code, + eailogic_code: specialist.eailogic_code, + subtitle: specialist.interactionCard?.relationshipToUser || '你的协作搭档', + metaText: `${specialist.tier === 'industry' ? '行业专员' : '通用专员'} · ${specialist.specialistMode === 'adw' ? '流程推进型' : '执行处理型'} · ${specialist.marketTag || '可用'}`, + badge: '专员', + avatar: specialist.label.slice(0, 1), + color: specialist.color || '#409eff', + description: + specialist.interactionCard?.greeting || + `这个专员主要负责 ${specialist.interactionCard?.tagline || specialist.label},可以陪你把事情往前推进。`, + tags: buildTags(specialist.interactionCard?.tagline, specialist.summary), + sceneTitle: '适合什么时候用', + sceneText: buildSpecialistUseCase(specialist), + extraSections: buildSpecialistExtraSections(remote, { lookupSkillLabel }), + promptTitle: '专员帮你做', + prompts: normalizeArray(specialist.interactionCard?.starterPrompts), + } +} diff --git a/eai_agentplatform/frontend/src/specialists/core/registry.js b/eai_agentplatform/frontend/src/specialists/core/registry.js new file mode 100644 index 0000000..965860a --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/core/registry.js @@ -0,0 +1,13 @@ +export function createRegistry(items = []) { + const manifests = items.filter((item) => item && item.key) + const manifestMap = Object.fromEntries(manifests.map((item) => [item.key, item])) + + return { + manifests() { + return manifests.map((item) => ({ ...item })) + }, + byKey(key) { + return manifestMap[String(key || '').trim()] || null + }, + } +} diff --git a/eai_agentplatform/frontend/src/specialists/packages/contract-review/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/contract-review/manifest.js new file mode 100644 index 0000000..37727c5 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/contract-review/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'contract-review', + label: '合同审查专员', + tier: 'industry', + specialistMode: 'adw', + marketTag: '已安装', + summary: '华东客户主协议修订', + status: '2 个待确认', + risk: '1 个高风险', + color: '#f56c6c', + stage: '人工复核', + progress: 72, + interactionCard: { + greeting: '我是合同审查专员,已经准备好帮你定位条款风险、整理红线建议,并输出可交付的审查结果。', + tagline: '把合同风险、红线和交付意见整理清楚。', + openingPrompt: '把合同发给我,或者直接告诉我这次要重点盯哪些条款。', + relationshipToUser: '你的法务协作搭档', + starterPrompts: [ + '审查这份采购合同的风险点', + '帮我整理一版 redline 建议', + '重点看赔偿责任和终止条款', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/hr-email-sorter/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/hr-email-sorter/manifest.js new file mode 100644 index 0000000..c181f48 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/hr-email-sorter/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'hr-email-sorter', + label: 'HR 邮件整理专员', + tier: 'industry', + specialistMode: 'dw', + marketTag: '试用', + summary: '识别并整理招聘邮箱中的简历邮件', + status: '3 封待处理', + risk: '0 个异常', + color: '#409eff', + stage: '邮件处理', + progress: 0, + interactionCard: { + greeting: '我是 HR 邮件整理专员,可以帮你识别、分类并整理招聘邮箱里的邮件。', + tagline: '把招聘邮箱里的内容收成可继续处理的人才线索。', + openingPrompt: '把邮件内容或整理目标告诉我,我先帮你分组归档。', + relationshipToUser: '你的招聘整理协作搭档', + starterPrompts: [ + '帮我整理今天收到的招聘邮件', + '把这些邮件按岗位和优先级分类', + '先筛出适合继续跟进的候选人邮件', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/knowledge-operations/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/knowledge-operations/manifest.js new file mode 100644 index 0000000..e74ae9d --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/knowledge-operations/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'knowledge-operations', + label: '知识运营专员', + tier: 'generic', + specialistMode: 'dw', + marketTag: '已安装', + summary: '对象字典、证据索引与规则同步', + status: '4 个待整理', + risk: '1 个映射待确认', + color: '#8e6cf2', + stage: '证据整理', + progress: 63, + interactionCard: { + greeting: '我是知识运营专员,可以帮你整理对象字典、证据索引和规则映射。', + tagline: '把散的知识资产收成可查、可用、可治理的结构。', + openingPrompt: '告诉我这次要整理哪类知识资产,我先帮你搭索引。', + relationshipToUser: '你的知识治理协作搭档', + starterPrompts: [ + '帮我整理这一批知识条目和标签', + '把这些规则映射成对象字典', + '先盘点一下这组资料的证据索引结构', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/logistics-fulfillment/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/logistics-fulfillment/manifest.js new file mode 100644 index 0000000..896123d --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/logistics-fulfillment/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'logistics-fulfillment', + label: '履约跟单专员', + tier: 'industry', + specialistMode: 'adw', + marketTag: '试用', + summary: '美西航线本周履约看板', + status: '1 个异常', + risk: '2 个节点延迟', + color: '#e6a23c', + stage: '异常处置', + progress: 49, + interactionCard: { + greeting: '我是履约跟单专员,可以帮你跟进节点、识别异常并整理处置建议。', + tagline: '把履约过程里的异常、催办和交付节奏收在一条线上。', + openingPrompt: '把当前订单、节点和异常情况发给我,我先帮你排查。', + relationshipToUser: '你的履约协作搭档', + starterPrompts: [ + '帮我梳理这票货当前的履约状态', + '把这些延迟节点整理成跟单清单', + '针对这次异常给我一版处置建议', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/presentation-briefing/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/presentation-briefing/manifest.js new file mode 100644 index 0000000..2c50978 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/presentation-briefing/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'presentation-briefing', + label: '汇报专员', + tier: 'generic', + specialistMode: 'dw', + marketTag: '已安装', + summary: '经营汇报、方案汇报与培训课件统一出稿', + status: '2 个待成稿', + risk: '1 个页数待压缩', + color: '#4f46e5', + stage: '演示编排', + progress: 68, + interactionCard: { + greeting: '我是汇报专员,可以先判断汇报对象和页数,再把材料压成能讲、能交、能继续细化的演示骨架。', + tagline: '把散材料收成一版结构清楚、讲得出去的汇报 PPT。', + openingPrompt: '告诉我汇报对象、场景、目标和页数,我先帮你规划页面结构。', + relationshipToUser: '你的汇报交付协作搭档', + starterPrompts: [ + '帮我做一版 8 页管理层汇报 PPT', + '把这份方案压成可讲的客户汇报结构', + '根据这批材料生成培训课件骨架和讲稿备注', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/process-coordination/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/process-coordination/manifest.js new file mode 100644 index 0000000..839f07f --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/process-coordination/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'process-coordination', + label: '流程推进专员', + tier: 'generic', + specialistMode: 'adw', + marketTag: '可升级', + summary: '跨部门催办、checkpoint 跟进与阻塞升级', + status: '6 个待跟进', + risk: '2 个阻塞', + color: '#14b8a6', + stage: '阻塞清理', + progress: 57, + interactionCard: { + greeting: '我是流程推进专员,可以帮你梳理 checkpoint、跟进阻塞并推动事项落地。', + tagline: '把多方协作里的待办、阻塞和升级路径理清楚。', + openingPrompt: '把当前事项、参与方和卡点告诉我,我先帮你排推进节奏。', + relationshipToUser: '你的流程推进协作搭档', + starterPrompts: [ + '帮我梳理这个项目的推进 checkpoint', + '把当前阻塞整理成催办清单', + '根据这些会议纪要生成后续跟进事项', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/report-generation/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/report-generation/manifest.js new file mode 100644 index 0000000..8ad960a --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/report-generation/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'report-generation', + label: '报告生成专员', + tier: 'generic', + specialistMode: 'dw', + marketTag: '可升级', + summary: '日报、周报、复盘和交付说明自动生成', + status: '3 个待输出', + risk: '0 个异常', + color: '#ec4899', + stage: '报告汇总', + progress: 76, + interactionCard: { + greeting: '我是报告生成专员,可以把过程材料整理成日报、周报、复盘和交付说明。', + tagline: '把分散信息压成结构完整、可直接提交的报告。', + openingPrompt: '把素材贴给我,或者告诉我报告对象、周期和格式要求。', + relationshipToUser: '你的报告整理协作搭档', + starterPrompts: [ + '把今天的工作整理成日报', + '根据这些记录生成项目周报', + '帮我输出一版交付复盘说明', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/resume-processor/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/resume-processor/manifest.js new file mode 100644 index 0000000..8d7a980 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/resume-processor/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'resume-processor', + label: '简历处理专员', + tier: 'industry', + specialistMode: 'dw', + marketTag: '试用', + summary: '筛选候选人并安排面试', + status: '5 份待筛选', + risk: '1 个匹配待确认', + color: '#67c23a', + stage: '简历筛选', + progress: 0, + interactionCard: { + greeting: '我是简历处理专员,可以帮你筛选候选人、整理匹配理由并安排后续动作。', + tagline: '把简历筛选从海量阅读变成结构化判断。', + openingPrompt: '告诉我岗位要求和筛选标准,我先帮你做一轮初筛。', + relationshipToUser: '你的招聘筛选协作搭档', + starterPrompts: [ + '根据这个 JD 帮我筛选一批候选人', + '把这些简历按匹配度排序', + '帮我整理一版面试建议和待确认点', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/solution-proposal/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/solution-proposal/manifest.js new file mode 100644 index 0000000..59f1d0d --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/solution-proposal/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'solution-proposal', + label: '售前方案专员', + tier: 'industry', + specialistMode: 'adw', + marketTag: '可升级', + summary: 'A 客户智能培训升级方案', + status: '3 个待办', + risk: '1 个依赖阻塞', + color: '#409eff', + stage: '方案生成', + progress: 58, + interactionCard: { + greeting: '我是售前方案专员,可以帮你把客户背景、需求和方案结构快速组织起来。', + tagline: '把客户需求转成一版可讲、可交付、可评审的方案。', + openingPrompt: '告诉我客户背景、目标和限制条件,我先帮你起方案骨架。', + relationshipToUser: '你的售前协作搭档', + starterPrompts: [ + '帮我起一版智能培训升级方案', + '先整理这个客户的需求要点和方案结构', + '把这份需求纪要转成售前方案大纲', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/training-delivery/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/training-delivery/manifest.js new file mode 100644 index 0000000..5f10384 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/training-delivery/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'training-delivery', + label: '培训交付专员', + tier: 'generic', + specialistMode: 'dw', + marketTag: '已安装', + summary: '新人销售训练营第 3 周', + status: '5 个进行中', + risk: '0 个异常', + color: '#67c23a', + stage: '交付归档', + progress: 84, + interactionCard: { + greeting: '我是培训交付专员,可以帮你推进训练计划、交付节点和归档结果。', + tagline: '把培训交付从排期、推进到归档串成闭环。', + openingPrompt: '把交付背景、学员范围和目标发给我,我来帮你推进。', + relationshipToUser: '你的培训交付协作搭档', + starterPrompts: [ + '帮我整理这期训练营的交付计划', + '把这些交付节点汇总成执行清单', + '根据培训记录生成交付归档说明', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/specialists/packages/wechat-official-account/manifest.js b/eai_agentplatform/frontend/src/specialists/packages/wechat-official-account/manifest.js new file mode 100644 index 0000000..6037107 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/packages/wechat-official-account/manifest.js @@ -0,0 +1,26 @@ +import { createSpecialistManifest } from '@/specialists/contracts/manifest' + +export default createSpecialistManifest({ + key: 'wechat-official-account', + label: '公众号助手', + tier: 'generic', + specialistMode: 'dw', + marketTag: '已安装', + summary: '热点选题、标题、提纲、正文四步创作工作流', + status: '4 个节点可执行', + risk: '0 个异常', + color: '#2563eb', + stage: '正文创作', + progress: 0, + interactionCard: { + greeting: '我是公众号助手,可以帮你把选题、提纲、正文和发布准备串起来。', + tagline: '把内容创作流程从想法推进到可发布。', + openingPrompt: '告诉我主题、受众和语气,我先帮你起草内容结构。', + relationshipToUser: '你的内容创作协作搭档', + starterPrompts: [ + '围绕这个热点起 3 个公众号选题', + '帮我写一版文章提纲和开头', + '把这篇草稿润成适合公众号发布的正文', + ], + }, +}) diff --git a/eai_agentplatform/frontend/src/utils/specialistFlow.js b/eai_agentplatform/frontend/src/specialists/runtime/specialistFlow.js similarity index 95% rename from eai_agentplatform/frontend/src/utils/specialistFlow.js rename to eai_agentplatform/frontend/src/specialists/runtime/specialistFlow.js index 8a2da33..fe3d587 100644 --- a/eai_agentplatform/frontend/src/utils/specialistFlow.js +++ b/eai_agentplatform/frontend/src/specialists/runtime/specialistFlow.js @@ -9,10 +9,10 @@ // 由种子字段派生,每项 11 个键(action_name/action_type/trigger_mode/input_sources/ // expected_output/risk_level/status/approval_state/operator/started_at/finished_at)。 // 注意:**不含 action_key**,所以下面一律用 normalizeActionKey(action_name) 兜底。 -// worker_task_detail.runs ← GET /api/worker-tasks/:id +// task_detail.runs ← GET /api/tasks/:id // action_key 是后端 normalizeWorkerActionKey() 由 action_title 派生的,两边规则一致。 -/** 动作状态词表。ready/pending 是种子给的,running/done 是 worker_run 给的。 */ +/** 动作状态词表。ready/pending 是种子给的,running/done 是 task_run 给的。 */ export function statusText(value = '') { const map = { ready: '可执行', @@ -72,7 +72,7 @@ export function getActionType(title = '') { /** * 动作名 → 连接键。必须与后端 normalizeWorkerActionKey() - * (backend-go/internal/api/worker_task.go) 规则一致:空格 / \ · _ 都折成 “-”, + * (backend-go/internal/api/task_runtime.go) 规则一致:空格 / \ · _ 都折成 “-”, * 小写,压缩连续 “-”。两边只要有一边改了,配对就会静默断链。 */ export function normalizeActionKey(value = '') { @@ -110,7 +110,7 @@ function findLatestRun(runs = [], actionKey = '', actionTitle = '') { // --------------------------------------------------------------------------- // 推断的「进行中」——这一段在代码里明确是推断,不是后端上报的事实。 // -// worker_run.status 在后端恒为 "done"(backend-go/internal/api/worker_task.go 写入 +// task_run.status 在后端恒为 "done"(backend-go/internal/api/task_runtime.go 写入 // 时硬编码),所以「灰虚线 / 蓝 / 绿 / 红」四态里,真实数据只能表达「跑过(绿)」和 // 「没跑过(灰)」。红色要靠 run 真失败,当前写不出;蓝色没有任何字段能表达。 // @@ -118,7 +118,7 @@ function findLatestRun(runs = [], actionKey = '', actionTitle = '') { // 视为正在执行。这是**读出来的猜测**,不是执行状态——所以调用方必须能标出它是推断的, // 不要把蓝框当成进度事实展示给用户。 // -// 任务状态只有四个值(model/worker_task.go 默认「待处理」,setTaskLifecycle 可设为 +// 任务状态只有四个值(model/task_record.go 默认「待处理」,setTaskLifecycle 可设为 // 「进行中」「已完成」「已归档」),只有「进行中」意味着流程在推进。 // --------------------------------------------------------------------------- export const TASK_ACTIVE_STATUS = '进行中' @@ -157,9 +157,9 @@ export function markFlowState(records = [], task = null) { * 卡片(workspace.taskCards)按位置对齐。两条路径都用 runs 覆盖 status。 * * @param {object} p - * @param {object|null} p.specialist 专家对象(含 action_records_json / worker_type) + * @param {object|null} p.specialist 专家对象(含 action_records_json / specialist_mode) * @param {object} p.snapshot 工作台快照(bottomPanel.trace / outputs / evidence) - * @param {Array} p.runs 当前任务的 worker_run 列表 + * @param {Array} p.runs 当前任务的 task_run 列表 * @param {object} p.workspace 工作台描述符(taskCards) */ export function buildActionRecords({ specialist = null, snapshot = {}, runs = [], workspace = null } = {}) { @@ -184,7 +184,7 @@ export function buildActionRecords({ specialist = null, snapshot = {}, runs = [] inputs: listText(item.input_sources, '待补充输入信源'), outputs: listText(item.expected_output, '待补充预期结果'), approval: item.approval_state === 'approval_required' ? '需要确认' : '可直接执行', - operator: item.operator || (specialist?.worker_type === 'adw' ? 'ADW' : 'DW'), + operator: item.operator || (specialist?.specialist_mode === 'adw' ? 'ADW' : 'DW'), lastRunSummary: output.summary || '', // —— 给流程视图用的原始事实,不参与上面那些字段的渲染 —— // hasRun 决定灰框还是绿框(见 markFlowState)。 @@ -209,7 +209,7 @@ export function buildActionRecords({ specialist = null, snapshot = {}, runs = [] actionKey, title: actionTitle, type: getActionType(task.title), - triggerMode: specialist?.worker_type === 'adw' ? '自动 + 人工协同' : '人工触发 + AI辅助', + triggerMode: specialist?.specialist_mode === 'adw' ? '自动 + 人工协同' : '人工触发 + AI辅助', risk: getRiskLevel(tone), status: run ? statusText(run.status) : task.status, tone, @@ -217,7 +217,7 @@ export function buildActionRecords({ specialist = null, snapshot = {}, runs = [] inputs: (snapshot?.evidence || []).slice(0, 2).join(' / ') || '待补充输入信源', outputs: outputs[index] || outputs[0] || '待补充预期结果', approval: tone === 'warning' || tone === 'danger' ? '需要确认' : '可直接执行', - operator: specialist?.worker_type === 'adw' ? 'ADW' : 'DW', + operator: specialist?.specialist_mode === 'adw' ? 'ADW' : 'DW', trace: traces[index] || '', lastRunSummary: output.summary || '', hasRun: Boolean(run), diff --git a/eai_agentplatform/frontend/src/store/specialistCatalog.js b/eai_agentplatform/frontend/src/specialists/store/specialistCatalog.js similarity index 85% rename from eai_agentplatform/frontend/src/store/specialistCatalog.js rename to eai_agentplatform/frontend/src/specialists/store/specialistCatalog.js index 4f5e2a7..c0b378b 100644 --- a/eai_agentplatform/frontend/src/store/specialistCatalog.js +++ b/eai_agentplatform/frontend/src/specialists/store/specialistCatalog.js @@ -1,6 +1,7 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { listSpecialists } from '@/api/specialist' +import { listSpecialists } from '@/specialists/api/specialist' +import { staticSpecialistCatalog } from '@/specialists/core/builtin' function safeParseObject(value, fallback = {}) { if (value && typeof value === 'object' && !Array.isArray(value)) return value @@ -24,7 +25,7 @@ function normalizeSpecialist(item) { display_code: String(item?.display_code || '').trim(), eailogic_code: String(item?.eailogic_code || '').trim(), tier: String(item?.tier || '').trim(), - workerType: String(item?.worker_type || item?.workerType || '').trim(), + specialistMode: String(item?.specialist_mode || item?.specialistMode || '').trim(), objectEntryRoute: String(item?.object_entry_route || item?.objectEntryRoute || '').trim(), marketTag: String(item?.market_tag || item?.marketTag || '').trim(), workStatus: String(item?.work_status || item?.workStatus || '').trim(), @@ -38,7 +39,7 @@ function normalizeSpecialist(item) { export const useSpecialistCatalogStore = defineStore('specialistCatalog', () => { const loaded = ref(false) const loading = ref(false) - const specialists = ref([]) + const specialists = ref(staticSpecialistCatalog.map((item) => normalizeSpecialist(item))) const specialistMap = computed(() => Object.fromEntries(specialists.value.map((item) => [item.key, item])) @@ -56,6 +57,8 @@ export const useSpecialistCatalogStore = defineStore('specialistCatalog', () => loaded.value = true } catch (error) { console.warn('load specialist catalog failed', error) + specialists.value = staticSpecialistCatalog.map((item) => normalizeSpecialist(item)) + loaded.value = true } finally { loading.value = false } diff --git a/eai_agentplatform/frontend/src/specialists/views/SpecialistCatalogPage.vue b/eai_agentplatform/frontend/src/specialists/views/SpecialistCatalogPage.vue new file mode 100644 index 0000000..4017b56 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/views/SpecialistCatalogPage.vue @@ -0,0 +1,398 @@ + + + + + diff --git a/eai_agentplatform/frontend/src/specialists/views/SpecialistDetailRoutePage.vue b/eai_agentplatform/frontend/src/specialists/views/SpecialistDetailRoutePage.vue new file mode 100644 index 0000000..19483a9 --- /dev/null +++ b/eai_agentplatform/frontend/src/specialists/views/SpecialistDetailRoutePage.vue @@ -0,0 +1,307 @@ + + + + + diff --git a/eai_agentplatform/frontend/src/store/aiChat.js b/eai_agentplatform/frontend/src/store/aiChat.js deleted file mode 100644 index 71b48d5..0000000 --- a/eai_agentplatform/frontend/src/store/aiChat.js +++ /dev/null @@ -1,35 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' - -export const useAiChatStore = defineStore('aiChat', () => { - const visible = ref(true) - const messages = ref([]) - const context = ref(null) - const isStreaming = ref(false) - const abortController = ref(null) - - function toggle() { - visible.value = !visible.value - } - - function setContext(ctx) { - context.value = ctx - } - - function clearMessages() { - messages.value = [] - } - - function addMessage(msg) { - messages.value.push(msg) - } - - function setStreaming(val) { - isStreaming.value = val - } - - return { - visible, messages, context, isStreaming, abortController, - toggle, setContext, clearMessages, addMessage, setStreaming, - } -}) \ No newline at end of file diff --git a/eai_agentplatform/frontend/src/store/notification.js b/eai_agentplatform/frontend/src/store/notification.js deleted file mode 100644 index 5ac2d83..0000000 --- a/eai_agentplatform/frontend/src/store/notification.js +++ /dev/null @@ -1,22 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { unreadCount } from '@/api/notification' - -export const useNotificationStore = defineStore('notification', () => { - const unread = ref(0) - - async function refresh() { - try { - const res = await unreadCount() - unread.value = res.data?.unread || 0 - } catch (e) { - unread.value = 0 - } - } - - function setUnread(n) { - unread.value = n - } - - return { unread, refresh, setUnread } -}) diff --git a/eai_agentplatform/frontend/src/store/projectStore.js b/eai_agentplatform/frontend/src/store/project.js similarity index 94% rename from eai_agentplatform/frontend/src/store/projectStore.js rename to eai_agentplatform/frontend/src/store/project.js index 3d3cbcb..dcd2ea9 100644 --- a/eai_agentplatform/frontend/src/store/projectStore.js +++ b/eai_agentplatform/frontend/src/store/project.js @@ -7,12 +7,12 @@ import { listProjects, updateProject, } from '@/api/project' -import { listConnectors } from '@/api/connector' -import { listSpecialists } from '@/api/specialist' -import { GENERAL_ASSISTANT_KEY } from '@/store/workerRuntime' +import { listConnectors } from '@/connectors/api/connector' +import { listSpecialists } from '@/specialists/api/specialist' +import { GENERAL_ASSISTANT_KEY } from '@/store/taskRuntime' import { normalizeSkillKeys, SKILL_KEY_LABELS } from '@/config/projectTemplates' -// 后端把三个能力列表存成 JSON 数组字符串(空的时候是空串)。解析放在这一层: +// 后端把三个对象列表存成 JSON 数组字符串(空的时候是空串)。解析放在这一层: // 页面拿到 project 后直接用,不用各自记着「这个字段还要 JSON.parse 一次」。 function decodeKeys(raw) { if (!raw) return [] @@ -29,7 +29,7 @@ function decodeSkillKeys(raw) { return normalizeSkillKeys(decodeKeys(raw)) } -export function projectAbilities(project) { +export function projectObjectBindings(project) { return { specialists: decodeKeys(project?.specialist_keys), skills: decodeSkillKeys(project?.skill_keys), @@ -184,7 +184,7 @@ export const useProjectStore = defineStore('project', () => { } // ===== 可选项清单:新建弹窗的 chips 选择器与详情页的只读 chips 共用 ===== - // 专员和连接器要问后端,工具是前端路由表里的常量。三种都在这儿合成一份 + // 专员和连接器要问后端,技能是前端路由表里的常量。三种都在这儿合成一份 // { value, label } 列表,页面只管画,不用各自记得去哪拿。 const specialists = ref([]) const connectors = ref([]) @@ -204,7 +204,7 @@ export const useProjectStore = defineStore('project', () => { // 装一次就够了,所以 optionsLoaded 挡住重复请求 —— 列表页和详情页都会调, // 每次进页面重拉一遍没有意义(这份清单一天也变不了一次)。 - async function loadAbilityOptions() { + async function loadProjectObjectOptions() { if (optionsLoaded.value) return try { const [specialistRes, connectorRes] = await Promise.all([ @@ -217,7 +217,7 @@ export const useProjectStore = defineStore('project', () => { } catch (error) { // 拉不到就只是选择器里少几项,不该把整个页面拦下来,所以吞掉错误。 // 下次进页面还会再试一遍(optionsLoaded 保持 false)。 - console.warn('load project ability options failed', error) + console.warn('load project object options failed', error) } } @@ -235,7 +235,7 @@ export const useProjectStore = defineStore('project', () => { specialistOptions, skillOptions, connectorOptions, - loadAbilityOptions, + loadProjectObjectOptions, specialistLabelOf, skillLabelOf, connectorLabelOf, diff --git a/eai_agentplatform/frontend/src/store/workerRuntime.js b/eai_agentplatform/frontend/src/store/taskRuntime.js similarity index 88% rename from eai_agentplatform/frontend/src/store/workerRuntime.js rename to eai_agentplatform/frontend/src/store/taskRuntime.js index 22e9b3d..0751643 100644 --- a/eai_agentplatform/frontend/src/store/workerRuntime.js +++ b/eai_agentplatform/frontend/src/store/taskRuntime.js @@ -2,22 +2,22 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' import { createMyTask, - createWorkerTask, - // 这两个删除接口必须分别命名,不能都叫 deleteTask —— 见下面 deleteWorkerTask 的注释。 + createTask as apiCreateTask, + // 这两个删除接口必须分别命名,不能都叫 deleteTask —— 见下面 deleteTaskCascade 的注释。 // 别名是因为 store 导出的 action 要跟接口同名(调用点一眼能看出打的是哪个接口)。 deleteMyTask as apiDeleteMyTask, - deleteWorkerTask as apiDeleteWorkerTask, - executeWorkerTaskAction, - getWorkerArtifactDetail, - getWorkerTaskDetail, + deleteTask as apiDeleteTask, + executeTaskAction, + getArtifactDetail, + getTaskDetail, listMyTasks, - listWorkerTasks, + listTasks, updateMyTask, - updateWorkerArtifactStatus, - updateWorkerTask, - updateWorkerTaskStatus, -} from '@/api/worker' -import { getSpecialistByKey } from '@/api/specialist' + updateArtifactStatus as apiUpdateArtifactStatus, + updateTask as apiUpdateTask, + updateTaskStatus as apiUpdateTaskStatus, +} from '@/api/taskRuntime' +import { getSpecialistByKey } from '@/specialists/api/specialist' // 「新建任务」在没选专员之前,任务先落在后端那条内置的通用助手名下 // (seed 里 key = general-assistant、state = system 的记录)。 @@ -30,7 +30,7 @@ export const PENDING_TASK_TITLE = '未名任务' // 留着是因为它记录的是后端的契约,将来做重命名 UI 时要认这个值。 export const PLACEHOLDER_TASK_TITLE = '新任务' -export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { +export const useTaskRuntimeStore = defineStore('taskRuntime', () => { const tasksBySpecialist = ref({}) const detailByTask = ref({}) const artifactDetailById = ref({}) @@ -41,7 +41,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { if (!specialistKey) return [] loadingTasksBySpecialist.value = { ...loadingTasksBySpecialist.value, [specialistKey]: true } try { - const res = await listWorkerTasks({ specialist_key: specialistKey }) + const res = await listTasks({ specialist_key: specialistKey }) const items = res?.data || [] tasksBySpecialist.value = { ...tasksBySpecialist.value, [specialistKey]: items } return items @@ -55,7 +55,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { const key = String(taskId) loadingDetailByTask.value = { ...loadingDetailByTask.value, [key]: true } try { - const res = await getWorkerTaskDetail(taskId) + const res = await getTaskDetail(taskId) const detail = res?.data || null detailByTask.value = { ...detailByTask.value, [key]: detail } return detail @@ -65,7 +65,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } async function executeAction(taskId, payload) { - const res = await executeWorkerTaskAction(taskId, payload) + const res = await executeTaskAction(taskId, payload) const task = res?.data?.task if (task?.specialist_key) { await loadTasks(task.specialist_key) @@ -75,7 +75,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } async function createTask(payload) { - const res = await createWorkerTask(payload) + const res = await apiCreateTask(payload) const task = res?.data || null if (task?.specialist_key) { await loadTasks(task.specialist_key) @@ -87,7 +87,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } async function updateTask(taskId, payload) { - const res = await updateWorkerTask(taskId, payload) + const res = await apiUpdateTask(taskId, payload) const task = res?.data || null if (task?.specialist_key) { await loadTasks(task.specialist_key) @@ -98,18 +98,18 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { return task } - // 工作台「删除事项」:打 DELETE /api/worker/tasks/:id,后端在同一个事务里连带 - // 删掉这条任务的 worker_run 和 worker_artifact(见 backend-go/internal/api/worker_task.go - // 的 DeleteWorkerTask)。工作台的确认框承诺的就是这个,所以必须走这条。 + // 工作台「删除事项」:打 DELETE /api/tasks/:id,后端在同一个事务里连带 + // 删掉这条任务的任务运行记录和任务产物(见 backend-go/internal/api/task_runtime.go + // 的 DeleteTask)。工作台的确认框承诺的就是这个,所以必须走这条。 // // 注意它和下面 deleteMyTask 的区别,两条不能合并: - // deleteWorkerTask → /worker/tasks/:id 级联删 runs+artifacts,**不校验归属**(按 id 删) - // deleteMyTask → /worker/my-tasks/:id 只删任务本身,但**限定当前用户自己的任务** + // deleteTaskCascade → /tasks/:id 级联删 runs+artifacts,**不校验归属**(按 id 删) + // deleteMyTask → /my/tasks/:id 只删任务本身,但**限定当前用户自己的任务** // 侧边栏对所有人可见,改调这条等于给任何登录用户一个按 id 删任意任务的口子; // 工作台只对管理员开放,用不校验归属的这条是它原本的语义。 - async function deleteWorkerTask(taskId) { + async function deleteTaskCascade(taskId) { const existing = detailByTask.value[String(taskId)]?.task || null - const res = await apiDeleteWorkerTask(taskId) + const res = await apiDeleteTask(taskId) const specialistKey = res?.data?.specialist_key || existing?.specialist_key || '' if (specialistKey) { await loadTasks(specialistKey) @@ -122,7 +122,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { async function loadArtifactDetail(artifactId) { if (!artifactId) return null - const res = await getWorkerArtifactDetail(artifactId) + const res = await getArtifactDetail(artifactId) const detail = res?.data || null artifactDetailById.value = { ...artifactDetailById.value, @@ -132,7 +132,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } async function updateArtifactStatus(artifactId, payload) { - const res = await updateWorkerArtifactStatus(artifactId, payload) + const res = await apiUpdateArtifactStatus(artifactId, payload) const task = res?.data?.task if (task?.specialist_key) { await loadTasks(task.specialist_key) @@ -147,7 +147,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } async function updateTaskStatus(taskId, payload) { - const res = await updateWorkerTaskStatus(taskId, payload) + const res = await apiUpdateTaskStatus(taskId, payload) const task = res?.data || null if (task?.specialist_key) { await loadTasks(task.specialist_key) @@ -172,7 +172,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { const taskSkillKeyById = ref({}) const pendingSkillKey = ref(DEFAULT_SKILL_KEY) const taskFocusedObjectById = ref({}) - const pendingFocusedObject = ref({ kind: 'assistant', key: DEFAULT_SKILL_KEY }) + const pendingFocusedObject = ref({ kind: 'specialist', key: GENERAL_ASSISTANT_KEY }) const currentTask = computed( () => myTasks.value.find((task) => String(task.id) === String(currentTaskId.value)) || null @@ -200,8 +200,8 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { ? taskFocusedObjectById.value[taskId] || pendingFocusedObject.value : pendingFocusedObject.value }) - const currentFocusedObjectKind = computed(() => currentFocusedObject.value?.kind || 'assistant') - const currentFocusedObjectKey = computed(() => currentFocusedObject.value?.key || DEFAULT_SKILL_KEY) + const currentFocusedObjectKind = computed(() => currentFocusedObject.value?.kind || 'specialist') + const currentFocusedObjectKey = computed(() => currentFocusedObject.value?.key || GENERAL_ASSISTANT_KEY) const currentTaskDetail = computed(() => detailByTask.value[String(currentTaskId.value)] || null) @@ -258,7 +258,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { pendingSpecialistKey.value = '' openedSpecialistKey.value = '' pendingSkillKey.value = DEFAULT_SKILL_KEY - pendingFocusedObject.value = { kind: 'assistant', key: DEFAULT_SKILL_KEY } + pendingFocusedObject.value = { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } } function setTaskSkillKey(taskId, skillKey) { @@ -272,10 +272,10 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } function normalizeFocusedObject(kind, key) { - const objectKey = String(key || '').trim() || DEFAULT_SKILL_KEY + const objectKey = String(key || '').trim() if (kind === 'specialist') return { kind: 'specialist', key: objectKey } if (kind === 'skill' && objectKey !== DEFAULT_SKILL_KEY) return { kind: 'skill', key: objectKey } - return { kind: 'assistant', key: DEFAULT_SKILL_KEY } + return { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } } function setTaskFocusedObject(taskId, object) { @@ -309,7 +309,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { myTasks.value = [task, ...myTasks.value.filter((item) => item.id !== task.id)] pendingSpecialistKey.value = '' pendingSkillKey.value = DEFAULT_SKILL_KEY - pendingFocusedObject.value = { kind: 'assistant', key: DEFAULT_SKILL_KEY } + pendingFocusedObject.value = { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } } return task } @@ -335,7 +335,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { myTasks.value = [task, ...myTasks.value.filter((item) => item.id !== task.id)] pendingSpecialistKey.value = '' pendingSkillKey.value = DEFAULT_SKILL_KEY - pendingFocusedObject.value = { kind: 'assistant', key: DEFAULT_SKILL_KEY } + pendingFocusedObject.value = { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } } return task } @@ -388,9 +388,9 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { // 侧边栏的「删除任务」。后端是硬删,删了找不回来,所以确认框由调用方负责。 // - // 走的是 /worker/my-tasks/:id(DeleteMyTask 按 owner 限定,只动自己名下的任务), + // 走的是 /my/tasks/:id(DeleteMyTask 按 owner 限定,只动自己名下的任务), // 不是上面工作台那条按 id 删的接口 —— 侧边栏对所有人可见,这里不能给别人删别人 - // 任务的口子。代价是这条接口**不级联**:任务上挂着的 worker_run / worker_artifact + // 任务的口子。代价是这条接口**不级联**:任务上挂着的运行记录 / 任务产物 // 会留在库里。要根治得让后端 DeleteMyTask 也开事务连带删(跟 DeleteWorkerTask // 一样),那是后端的事,前端这边删不掉别人的 run。 async function deleteMyTask(taskId) { @@ -416,7 +416,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { pendingTaskTitle.value = '' openedSpecialistKey.value = '' pendingSkillKey.value = DEFAULT_SKILL_KEY - pendingFocusedObject.value = { kind: 'assistant', key: DEFAULT_SKILL_KEY } + pendingFocusedObject.value = { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } } } @@ -444,14 +444,14 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { if (!currentTaskId.value) { pendingSpecialistKey.value = '' pendingSkillKey.value = DEFAULT_SKILL_KEY - pendingFocusedObject.value = { kind: 'assistant', key: DEFAULT_SKILL_KEY } + pendingFocusedObject.value = { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } return null } const res = await updateMyTask(currentTaskId.value, { specialist_key: GENERAL_ASSISTANT_KEY }) applyTaskUpdate(res?.data) openedSpecialistKey.value = GENERAL_ASSISTANT_KEY setTaskSkillKey(currentTaskId.value, DEFAULT_SKILL_KEY) - setTaskFocusedObject(currentTaskId.value, { kind: 'assistant', key: DEFAULT_SKILL_KEY }) + setTaskFocusedObject(currentTaskId.value, { kind: 'specialist', key: GENERAL_ASSISTANT_KEY }) return res?.data || null } @@ -463,7 +463,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { pendingSkillKey.value = key pendingFocusedObject.value = key === DEFAULT_SKILL_KEY - ? { kind: 'assistant', key: DEFAULT_SKILL_KEY } + ? { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } : { kind: 'skill', key } return } @@ -474,7 +474,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { ? ( currentSpecialistKey.value && currentSpecialistKey.value !== GENERAL_ASSISTANT_KEY ? { kind: 'specialist', key: currentSpecialistKey.value } - : { kind: 'assistant', key: DEFAULT_SKILL_KEY } + : { kind: 'specialist', key: GENERAL_ASSISTANT_KEY } ) : { kind: 'skill', key }, ) @@ -514,7 +514,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { } else if (specialistKey && specialistKey !== GENERAL_ASSISTANT_KEY) { setTaskFocusedObject(id, { kind: 'specialist', key: specialistKey }) } else { - setTaskFocusedObject(id, { kind: 'assistant', key: DEFAULT_SKILL_KEY }) + setTaskFocusedObject(id, { kind: 'specialist', key: GENERAL_ASSISTANT_KEY }) } } } @@ -580,7 +580,7 @@ export const useWorkerRuntimeStore = defineStore('workerRuntime', () => { createTask, updateTask, updateTaskStatus, - deleteWorkerTask, + deleteTaskCascade, updateArtifactStatus, } }) diff --git a/eai_agentplatform/frontend/src/store/workbench.js b/eai_agentplatform/frontend/src/store/workbench.js index 432a5cf..752ecd9 100644 --- a/eai_agentplatform/frontend/src/store/workbench.js +++ b/eai_agentplatform/frontend/src/store/workbench.js @@ -89,10 +89,10 @@ export const useWorkbenchStore = defineStore('workbench', () => { persist() } - function setSelectedCase(appKey, caseId) { + function setSelectedCase(workspaceKey, caseId) { selectedCases.value = { ...selectedCases.value, - [appKey]: caseId, + [workspaceKey]: caseId, } persist() } diff --git a/eai_agentplatform/frontend/src/styles/global.css b/eai_agentplatform/frontend/src/styles/global.css index 7e0027d..ca55fd9 100644 --- a/eai_agentplatform/frontend/src/styles/global.css +++ b/eai_agentplatform/frontend/src/styles/global.css @@ -53,23 +53,23 @@ html, body, #app { overflow-y: auto; } -.gov-page { +.governance-page { display: flex; flex-direction: column; gap: 12px; padding: 16px 20px 24px; } -.gov-card, -.gov-hero, -.gov-summary-card, -.gov-section-panel { +.governance-card, +.governance-hero, +.governance-summary-card, +.governance-section-panel { border: 1px solid #e7ebf0; background: #fff; box-shadow: none; } -.gov-hero { +.governance-hero { display: block; padding: 0 0 6px; border: none; @@ -77,22 +77,22 @@ html, body, #app { background: transparent; } -.gov-eyebrow, -.gov-summary-label, -.gov-hero-side-label, -.gov-section-desc, -.gov-panel-caption { +.governance-eyebrow, +.governance-summary-label, +.governance-hero-side-label, +.governance-section-desc, +.governance-panel-caption { font-size: 11px; color: #8d98a5; } -.gov-hero-main h1 { +.governance-hero-main h1 { margin: 4px 0 6px; font-size: 24px; color: #172533; } -.gov-hero-main p { +.governance-hero-main p { max-width: 72ch; margin: 0; line-height: 1.65; @@ -100,24 +100,24 @@ html, body, #app { color: #667689; } -.gov-hero-actions { +.governance-hero-actions { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; } -.gov-hero-side { +.governance-hero-side { display: none; } -.gov-summary-grid { +.governance-summary-grid { display: flex; flex-wrap: wrap; gap: 8px; } -.gov-summary-card { +.governance-summary-card { min-width: 0; border-radius: 999px; padding: 7px 12px; @@ -127,48 +127,48 @@ html, body, #app { background: #fff; } -.gov-summary-card strong { +.governance-summary-card strong { font-size: 13px; font-weight: 600; color: #172533; } -.gov-summary-card small { +.governance-summary-card small { display: none; } -.gov-section-panel { +.governance-section-panel { border: none; border-radius: 0; padding: 0; background: transparent; } -.gov-page .el-card, -.gov-page .el-tabs--border-card, -.gov-page .el-table, -.gov-page .el-table__inner-wrapper, -.gov-page .el-alert { +.governance-page .el-card, +.governance-page .el-tabs--border-card, +.governance-page .el-table, +.governance-page .el-table__inner-wrapper, +.governance-page .el-alert { border-radius: 16px; } -.gov-page .el-card, -.gov-page .el-tabs--border-card, -.gov-page .el-table__inner-wrapper { +.governance-page .el-card, +.governance-page .el-tabs--border-card, +.governance-page .el-table__inner-wrapper { overflow: hidden; } -.gov-page .el-tabs--border-card > .el-tabs__header { +.governance-page .el-tabs--border-card > .el-tabs__header { border-top-left-radius: 16px; border-top-right-radius: 16px; } -.gov-page .el-tabs--border-card > .el-tabs__content { +.governance-page .el-tabs--border-card > .el-tabs__content { border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; } -.gov-section-head { +.governance-section-head { display: flex; align-items: flex-start; justify-content: space-between; @@ -176,25 +176,25 @@ html, body, #app { margin-bottom: 8px; } -.gov-section-title { +.governance-section-title { font-size: 15px; font-weight: 700; color: #172533; } -.gov-section-subtitle { +.governance-section-subtitle { margin-top: 2px; font-size: 12px; color: #7b8896; } -.gov-chip-row { +.governance-chip-row { display: flex; flex-wrap: wrap; gap: 8px; } -.gov-chip { +.governance-chip { padding: 5px 10px; border-radius: 999px; background: #f3f6fa; @@ -203,7 +203,7 @@ html, body, #app { } @media (max-width: 1180px) { - .gov-page { + .governance-page { padding: 14px 16px 20px; } } diff --git a/eai_agentplatform/frontend/src/utils/objectCode.js b/eai_agentplatform/frontend/src/utils/objectCode.js new file mode 100644 index 0000000..feb98fd --- /dev/null +++ b/eai_agentplatform/frontend/src/utils/objectCode.js @@ -0,0 +1,35 @@ +const OBJECT_CODE_SCHEMES = { + specialist: { displayPrefix: '专', logicPrefix: 'S' }, + skill: { displayPrefix: '能', logicPrefix: 'K' }, + xapp: { displayPrefix: '应', logicPrefix: 'X' }, + connector: { displayPrefix: '连', logicPrefix: 'C' }, +} + +export function getObjectCodeScheme(kind) { + return OBJECT_CODE_SCHEMES[kind] || { displayPrefix: '', logicPrefix: '' } +} + +export function formatObjectSequence(index) { + return String(index).padStart(2, '0') +} + +export function formatDisplayCode(kind, index) { + const scheme = getObjectCodeScheme(kind) + return `${scheme.displayPrefix}${formatObjectSequence(index)}` +} + +export function formatLogicCode(kind, index) { + const scheme = getObjectCodeScheme(kind) + return `EAI-${scheme.logicPrefix}-${formatObjectSequence(index)}` +} + +export function withObjectCodes(items, { kind, start = 1 }) { + return items.map((item, index) => { + const sequence = start + index + return { + ...item, + display_code: item.display_code || formatDisplayCode(kind, sequence), + eailogic_code: item.eailogic_code || formatLogicCode(kind, sequence), + } + }) +} diff --git a/eai_agentplatform/frontend/src/utils/workbenchNavigation.js b/eai_agentplatform/frontend/src/utils/workbenchNavigation.js index df39df8..9d97059 100644 --- a/eai_agentplatform/frontend/src/utils/workbenchNavigation.js +++ b/eai_agentplatform/frontend/src/utils/workbenchNavigation.js @@ -2,18 +2,18 @@ import { WORKBENCH_ROUTE } from '@/config/workbench' export const WORKBENCH_HOME_ROUTE = WORKBENCH_ROUTE -import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY } from '@/store/workerRuntime' +import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY } from '@/store/taskRuntime' export async function openSpecialistInWorkbench({ router, currentPath = '', - workerRuntime, + taskRuntime, specialistKey, replace = false, }) { const key = String(specialistKey || '').trim() if (!key) return - await workerRuntime.attachSpecialistToCurrentTask(key) + await taskRuntime.attachSpecialistToCurrentTask(key) if (currentPath !== WORKBENCH_HOME_ROUTE) { await router[replace ? 'replace' : 'push'](WORKBENCH_HOME_ROUTE) } @@ -22,10 +22,10 @@ export async function openSpecialistInWorkbench({ export async function openGeneralAssistantInWorkbench({ router, currentPath = '', - workerRuntime, + taskRuntime, replace = false, }) { - await workerRuntime.activateGeneralAssistant({ + await taskRuntime.activateGeneralAssistant({ specialistKey: GENERAL_ASSISTANT_KEY, skillKey: DEFAULT_SKILL_KEY, }) @@ -37,13 +37,13 @@ export async function openGeneralAssistantInWorkbench({ export async function openSkillInWorkbench({ router, currentPath = '', - workerRuntime, + taskRuntime, skillKey, replace = false, }) { const key = String(skillKey || '').trim() if (!key) return - workerRuntime.attachSkillToCurrentTask(key) + taskRuntime.attachSkillToCurrentTask(key) if (currentPath !== WORKBENCH_HOME_ROUTE) { await router[replace ? 'replace' : 'push'](WORKBENCH_HOME_ROUTE) } diff --git a/eai_agentplatform/frontend/src/views/Login.vue b/eai_agentplatform/frontend/src/views/LoginPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/Login.vue rename to eai_agentplatform/frontend/src/views/LoginPage.vue diff --git a/eai_agentplatform/frontend/src/views/companyTrain/CompanyTrain.vue b/eai_agentplatform/frontend/src/views/companyTrain/CompanyTrainPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/companyTrain/CompanyTrain.vue rename to eai_agentplatform/frontend/src/views/companyTrain/CompanyTrainPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/ExamFormal.vue b/eai_agentplatform/frontend/src/views/exam/ExamFormalPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/ExamFormal.vue rename to eai_agentplatform/frontend/src/views/exam/ExamFormalPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/ExamMyRecord.vue b/eai_agentplatform/frontend/src/views/exam/ExamMyRecordPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/ExamMyRecord.vue rename to eai_agentplatform/frontend/src/views/exam/ExamMyRecordPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/ExamSelfTest.vue b/eai_agentplatform/frontend/src/views/exam/ExamSelfTestPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/ExamSelfTest.vue rename to eai_agentplatform/frontend/src/views/exam/ExamSelfTestPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/ExamTabsView.vue b/eai_agentplatform/frontend/src/views/exam/ExamTabsView.vue deleted file mode 100644 index 9d03e91..0000000 --- a/eai_agentplatform/frontend/src/views/exam/ExamTabsView.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/eai_agentplatform/frontend/src/views/exam/Leaderboard.vue b/eai_agentplatform/frontend/src/views/exam/LeaderboardPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/Leaderboard.vue rename to eai_agentplatform/frontend/src/views/exam/LeaderboardPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/MyCertificates.vue b/eai_agentplatform/frontend/src/views/exam/MyCertificatesPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/MyCertificates.vue rename to eai_agentplatform/frontend/src/views/exam/MyCertificatesPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/MyMistakes.vue b/eai_agentplatform/frontend/src/views/exam/MyMistakesPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/MyMistakes.vue rename to eai_agentplatform/frontend/src/views/exam/MyMistakesPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/MyPosition.vue b/eai_agentplatform/frontend/src/views/exam/MyPositionPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/MyPosition.vue rename to eai_agentplatform/frontend/src/views/exam/MyPositionPage.vue diff --git a/eai_agentplatform/frontend/src/views/exam/MyProfile.vue b/eai_agentplatform/frontend/src/views/exam/MyProfilePage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/exam/MyProfile.vue rename to eai_agentplatform/frontend/src/views/exam/MyProfilePage.vue diff --git a/eai_agentplatform/frontend/src/views/knowledge/ExamRecordManage.vue b/eai_agentplatform/frontend/src/views/knowledge/KnowledgeExamRecordsPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/knowledge/ExamRecordManage.vue rename to eai_agentplatform/frontend/src/views/knowledge/KnowledgeExamRecordsPage.vue diff --git a/eai_agentplatform/frontend/src/views/knowledge/MaterialAudit.vue b/eai_agentplatform/frontend/src/views/knowledge/KnowledgeMaterialAuditPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/knowledge/MaterialAudit.vue rename to eai_agentplatform/frontend/src/views/knowledge/KnowledgeMaterialAuditPage.vue diff --git a/eai_agentplatform/frontend/src/views/knowledge/MaterialManage.vue b/eai_agentplatform/frontend/src/views/knowledge/KnowledgeMaterialsPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/knowledge/MaterialManage.vue rename to eai_agentplatform/frontend/src/views/knowledge/KnowledgeMaterialsPage.vue diff --git a/eai_agentplatform/frontend/src/views/knowledge/ExamQuestionBank.vue b/eai_agentplatform/frontend/src/views/knowledge/KnowledgeQuestionBankPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/knowledge/ExamQuestionBank.vue rename to eai_agentplatform/frontend/src/views/knowledge/KnowledgeQuestionBankPage.vue diff --git a/eai_agentplatform/frontend/src/views/me/MySettingsPage.vue b/eai_agentplatform/frontend/src/views/me/MySettingsPage.vue index b5ef472..d7cf8dd 100644 --- a/eai_agentplatform/frontend/src/views/me/MySettingsPage.vue +++ b/eai_agentplatform/frontend/src/views/me/MySettingsPage.vue @@ -42,7 +42,7 @@ - + diff --git a/eai_agentplatform/frontend/src/views/product/ProductDetail.vue b/eai_agentplatform/frontend/src/views/product/ProductDetailPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/product/ProductDetail.vue rename to eai_agentplatform/frontend/src/views/product/ProductDetailPage.vue diff --git a/eai_agentplatform/frontend/src/views/product/ProductList.vue b/eai_agentplatform/frontend/src/views/product/ProductListPage.vue similarity index 98% rename from eai_agentplatform/frontend/src/views/product/ProductList.vue rename to eai_agentplatform/frontend/src/views/product/ProductListPage.vue index 59d83e8..5ddff56 100644 --- a/eai_agentplatform/frontend/src/views/product/ProductList.vue +++ b/eai_agentplatform/frontend/src/views/product/ProductListPage.vue @@ -103,6 +103,7 @@ import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { listProducts, createProduct, updateProduct, deleteProduct } from '@/api/products' import { useAuthStore } from '@/store/auth' +import { trainingProductDetailRoute } from '@/xapps/config/xappRoutes' import { PRODUCT_CATEGORIES, labelOf } from '@/utils/labels' const router = useRouter() @@ -140,7 +141,7 @@ async function load() { } function goDetail(row) { - router.push(`/products/${row.id}`) + router.push(trainingProductDetailRoute(row.id)) } // —— 管理员编辑 —— diff --git a/eai_agentplatform/frontend/src/views/salesTrain/CourseDetail.vue b/eai_agentplatform/frontend/src/views/salesTrain/CourseDetailPage.vue similarity index 100% rename from eai_agentplatform/frontend/src/views/salesTrain/CourseDetail.vue rename to eai_agentplatform/frontend/src/views/salesTrain/CourseDetailPage.vue diff --git a/eai_agentplatform/frontend/src/views/salesTrain/CourseList.vue b/eai_agentplatform/frontend/src/views/salesTrain/CourseListPage.vue similarity index 97% rename from eai_agentplatform/frontend/src/views/salesTrain/CourseList.vue rename to eai_agentplatform/frontend/src/views/salesTrain/CourseListPage.vue index d125156..c28cd3c 100644 --- a/eai_agentplatform/frontend/src/views/salesTrain/CourseList.vue +++ b/eai_agentplatform/frontend/src/views/salesTrain/CourseListPage.vue @@ -68,6 +68,7 @@ import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import { listCourses, updateCourse } from '@/api/courses' import { useAuthStore } from '@/store/auth' +import { trainingCourseDetailRoute } from '@/xapps/config/xappRoutes' import { COURSE_CATEGORIES, labelOf } from '@/utils/labels' const router = useRouter() @@ -90,7 +91,7 @@ async function load() { } function goDetail(item) { - router.push(`/courses/${item.id}`) + router.push(trainingCourseDetailRoute(item.id)) } const dialogVisible = ref(false) diff --git a/eai_agentplatform/frontend/src/views/system/AdminOverviewPage.vue b/eai_agentplatform/frontend/src/views/system/AdminOverviewPage.vue index d2d3e29..af0785d 100644 --- a/eai_agentplatform/frontend/src/views/system/AdminOverviewPage.vue +++ b/eai_agentplatform/frontend/src/views/system/AdminOverviewPage.vue @@ -144,7 +144,7 @@ import { getDashboard } from '@/api/system' import { departmentStats } from '@/api/department' import { examStatsByPosition } from '@/api/position' import { getAiUsageUsers } from '@/api/ai' -import { listConnectors } from '@/api/connector' +import { listConnectors } from '@/connectors/api/connector' const router = useRouter() @@ -205,9 +205,9 @@ const governanceDomains = computed(() => [ pageRoute: '/knowledge-management', }, { - key: 'capability', - title: '能力治理', - subtitle: '专家、技能、APP 与连接器', + key: 'object', + title: '对象治理', + subtitle: '专员、技能、APP 与连接器', count: `${liveConnectorCount.value} 个`, pageRoute: '/console', }, diff --git a/eai_agentplatform/frontend/src/views/system/AiUsage.vue b/eai_agentplatform/frontend/src/views/system/AiUsagePage.vue similarity index 77% rename from eai_agentplatform/frontend/src/views/system/AiUsage.vue rename to eai_agentplatform/frontend/src/views/system/AiUsagePage.vue index 386a9b1..fbe2f74 100644 --- a/eai_agentplatform/frontend/src/views/system/AiUsage.vue +++ b/eai_agentplatform/frontend/src/views/system/AiUsagePage.vue @@ -1,26 +1,26 @@