diff --git a/TOP_CODING_RULES.md b/TOP_CODING_RULES.md index a3ad349..6fbe4f3 100644 --- a/TOP_CODING_RULES.md +++ b/TOP_CODING_RULES.md @@ -175,6 +175,7 @@ - 文件扩展名白名单校验(仅允许 ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg) - 文件名重命名为存储 UUID,杜绝路径穿越 - 上传目录对静态预览只读,禁止直接执行 +5. **开发阶段路由调整直接收口到新结构**:在开发阶段,信息架构或导航结构调整时,默认**不需要保留老路由**。除非用户明确要求兼容、灰度、外链保持可用,否则应直接删除旧路由与旧入口,避免同时维护新旧两套路由造成漂移与误判。 --- @@ -565,4 +566,4 @@ --- -*注:本准则放置于项目根目录,作为全局 Rule 永久锁定。* \ No newline at end of file +*注:本准则放置于项目根目录,作为全局 Rule 永久锁定。* diff --git a/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY17_工作台界面线框稿.md b/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY17_工作台界面线框稿.md index 3b74492..76ac621 100644 --- a/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY17_工作台界面线框稿.md +++ b/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY17_工作台界面线框稿.md @@ -164,7 +164,7 @@ | [我的待办] [我的业务流] [最近交付物] [高风险事项] | +--------------------------------------------------------------------------------------------------+ | +-------------------------+ +-------------------------+ +-------------------------+ | -| | 公众号专员 | | 售前方案专员 | | 合同审查专员 | | +| | 公众号创作专员 | | 售前方案专员 | | 合同审查专员 | | | | 通用专员 / 内容运营 | | 行业专员 / 售前 | | 行业专员 / 法务 | | | | 3 个待办 / 1 篇待发布 | | 2 个待审批 / 1 个高风险 | | 5 个进行中 | | | | [进入应用] | | [进入应用] | | [进入应用] | | diff --git a/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY23_专员规则文件与技能绑定方案.md b/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY23_专员规则文件与技能绑定方案.md index 3e2e697..ffdc35c 100644 --- a/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY23_专员规则文件与技能绑定方案.md +++ b/docs/01_System_Overall/History_And_Retrospectives/系统演进旧稿/SY23_专员规则文件与技能绑定方案.md @@ -299,7 +299,7 @@ var ValidSkillKeys = map[string]bool{ | `openclaw-setup`、`aionui-assistant`、`aionui-*` 技能 | AionUi 自身的安装运维助手,与业务无关 | | `moltbook` | 面向其自有平台的发布助手 | | `game-3d`、`story-roleplay`、`human-3-coach`、`ui-ux-pro-max` | 游戏 / 角色扮演 / 个人成长 / 设计工具,与企业办公无关 | -| `weixin-file-send` | 是「把文件发到微信」的客户端脚本,依赖其本地 Electron 环境,不可移植(公众号专员要的是发文,不是发文件) | +| `weixin-file-send` | 是「把文件发到微信」的客户端脚本,依赖其本地 Electron 环境,不可移植(公众号创作专员要的是发文,不是发文件) | ### 前置依赖:OfficeCLI 未下载 diff --git a/docs/01_System_Overall/SY25_业务逻辑拆解与数字员工抽取方法.md b/docs/01_System_Overall/SY25_业务逻辑拆解与数字员工抽取方法.md index e830ff5..4fde3f4 100644 --- a/docs/01_System_Overall/SY25_业务逻辑拆解与数字员工抽取方法.md +++ b/docs/01_System_Overall/SY25_业务逻辑拆解与数字员工抽取方法.md @@ -263,7 +263,7 @@ skill 是能力片段,不是岗位。 错误示例: - `客户跟进专员` -- `招聘筛选专员` +- `简历筛选与招聘专员` 问题: diff --git a/docs/02_Architecture/AR05_工作台架构约定.md b/docs/02_Architecture/AR05_工作台架构约定.md index 949bbe7..fe58a64 100644 --- a/docs/02_Architecture/AR05_工作台架构约定.md +++ b/docs/02_Architecture/AR05_工作台架构约定.md @@ -3,7 +3,7 @@ > 落盘日期:2026-09-16 > 状态:架构合同 > 作用:作为后续前端重构的强约束,先定模型,再做代码迁移 -> 关联文档:`AR08_角色交互设计.md` +> 关联文档:`AR08_角色交互设计.md`、`AR12_对话驱动与结构化交互架构.md` --- @@ -275,6 +275,33 @@ type TaskRuntimeContext = { 3. 工具焦点时,展示工具级执行流与产物 4. 右栏不允许只支持专员、不支持工具 +### 6.6 关键分叉节点交互 + +Workbench 采用混合交互架构: + +- 对话负责发起任务与局部修订 +- 结构化 UI 负责关键分叉节点接管 +- 右栏负责持续展示阶段、结果与回退点 + +因此,以下节点不允许只靠消息流向下滚动: + +1. 选题 +2. 标题 +3. 路由 +4. Provider +5. 发布 +6. 删除 +7. 覆盖已有结果 +8. 切换当前主责对象 + +这些节点一律视为关键分叉节点,必须满足: + +1. 显式列出候选项 +2. 提供一键确认入口 +3. 允许用户手工改写,而不是只能选现有项 +4. 标明当前结果是“用户确认”还是“系统默认” +5. 未确认前,不得静默继续执行下游步骤 + --- ## 7. 对象生命周期 diff --git a/docs/02_Architecture/AR12_对话驱动与结构化交互架构.md b/docs/02_Architecture/AR12_对话驱动与结构化交互架构.md new file mode 100644 index 0000000..35ceae8 --- /dev/null +++ b/docs/02_Architecture/AR12_对话驱动与结构化交互架构.md @@ -0,0 +1,259 @@ +# AR12 — 对话驱动与结构化交互架构 + +> 状态:架构讨论点 + 后续 Workbench 交互约束来源 +> 最后更新:2026-09-23 +> +> 关联文档: +> - `AR02_前端架构.md` +> - `AR05_工作台架构约定.md` +> - `docs/01_System_Overall/SY22_角色技能应用统一任务架构.md` + +--- + +## 1. 背景 + +EAI 平台当前正在从传统页面式软件,转向“主对话入口 + 任务上下文 + 对象挂载”的新工作台形态。 + +这个转向带来一个新的核心问题: + +**用户应该通过自然语言持续推进任务,还是在关键节点切换到结构化 UI 做选择。** + +这个问题不是前端组件样式问题,而是下一代 AI 驱动软件的主交互范式问题。 + +如果这条架构判断不明确,后续专员、技能、xApp、右栏工作流、操作按钮都会出现各自实现、口径分裂的问题。 + +--- + +## 2. 问题定义 + +对话驱动式工作方式有明确优势: + +1. 用户可以一句话启动任务 +2. 用户不必先理解系统表单结构 +3. 用户可以用自然语言表达模糊目标 +4. AI 可以在早期帮助补全需求 + +对话驱动式工作方式也有明确缺点: + +1. 信息天然按时间线滚动,分叉选择容易被淹没 +2. 用户很难判断系统是在“建议”,还是已经“替用户决定” +3. 关键状态与可选项不够显式,可控性下降 +4. 过程虽然看起来流畅,但审计性、回退性、对比性不足 + +传统 UI 的优劣恰好相反: + +1. 它强在并列展示、状态显式、可比较、可回退 +2. 它弱在启动成本高、表单感强、难以承接模糊需求 + +因此,EAI 不应在“纯对话”与“纯页面表单”之间二选一。 + +--- + +## 3. 架构结论 + +EAI 的正式交互结论是: + +**系统采用“对话发起 + 结构化决策接管 + 对话继续推进”的混合交互架构。** + +换句话说: + +1. **对话**负责表达目标、补充上下文、提出修改意见 +2. **结构化 UI**负责承接关键分叉、显式展示备选项、确认后果 +3. **工作流状态面板**负责持续展示当前阶段、可回退点、执行结果 + +这不是“聊天框旁边加几个按钮”。 + +这是把 AI 软件拆成三层协作: + +1. **对话层**:负责意图生成 +2. **决策层**:负责关键节点接管 +3. **执行层**:负责自动推进与结果回写 + +--- + +## 4. 交互分层原则 + +### 4.1 对话适用的阶段 + +以下阶段优先使用对话: + +1. 用户第一次描述任务目标 +2. 用户补充背景、约束、材料 +3. 用户对已生成结果提出局部修改意见 +4. 用户要求 AI 解释建议原因 +5. 用户在开放空间中重新定义问题 + +典型例子: + +- “围绕这个热点写一篇面向医院管理层的文章” +- “第三段太硬了,改得更像公众号” +- “把语气改得更专业,但不要太官腔” + +### 4.2 结构化 UI 适用的阶段 + +以下阶段必须由结构化 UI 接管: + +1. 存在有限个备选项,且用户必须做一次显式决定 +2. 每个选项存在不同后果、成本或执行路径 +3. 系统需要记录这次选择是“用户确认”还是“系统默认” +4. 后续步骤会基于该选择继续自动推进 + +典型例子: + +- 选题候选 +- 标题候选 +- 路由选择 +- Provider 选择 +- 执行方式选择 +- 是否覆盖已有结果 +- 是否发布 / 删除 / 切换对象 + +--- + +## 5. 关键分叉点规则 + +Workbench 中凡是满足“改变后续执行路径”的节点,都属于关键分叉点。 + +关键分叉点必须满足以下规则: + +1. 系统必须显式列出候选项 +2. 每个候选项必须展示最小必要解释 +3. 用户必须能一键确认,而不是被迫输入“选第二个” +4. 系统必须记录当前结果是: + - 用户显式选择 + - 系统自动默认 + - 用户手工输入新方案 +5. 用户必须能看到“继续生成”和“返回修改”的边界 + +这条规则直接适用于专员工作流。 + +例如公众号创作专员在“选题”和“标题”阶段,不能只在消息流里丢出一大段文本然后自动往下跑。 + +它必须提供: + +1. 候选卡片 +2. 选择按钮 +3. 重生成动作 +4. 手工改写入口 +5. 当前默认项标识 + +--- + +## 6. 问答式与按钮式的正式判断 + +正式判断不是“问答式更先进”或“按钮式更稳定”。 + +正式判断是: + +**开放问题用问答式,有限决策用按钮式或卡片式。** + +进一步细化如下: + +### 6.1 优先使用问答式的情况 + +1. 目标尚未清晰 +2. 用户需要补充背景 +3. 用户想用自然语言直接改结果 +4. 系统无法预先枚举所有合理选项 + +### 6.2 优先使用按钮 / 卡片式的情况 + +1. 系统已经生成有限候选集 +2. 用户需要从候选集中选一个继续 +3. 系统需要明确记录选中的那一项 +4. 不同选项会触发不同下游动作 + +### 6.3 必须同时提供两者的情况 + +当系统给出候选项时,必须同时允许: + +1. 直接点选现有候选 +2. 手工输入“都不要,我自己改一个” + +因此,最佳形态不是单选按钮,也不是纯聊天。 + +最佳形态是: + +**候选卡片 + 一键选择 + 手工改写入口。** + +--- + +## 7. 对 Workbench 的直接影响 + +这条架构结论会直接改写 Workbench 的交互职责。 + +### 7.1 主对话区 + +主对话区不再只负责消息流。 + +它还必须承担: + +1. 意图发起 +2. 阶段性建议解释 +3. 结构化候选项承接 +4. 人工修改后的继续推进 + +### 7.2 右栏工作流 + +右栏工作流不只是“执行日志”。 + +它还必须承担: + +1. 当前阶段展示 +2. 待用户选择节点提示 +3. 当前默认项显示 +4. 历史选择与回退入口 + +### 7.3 对象工作流定义 + +后续每个专员 / 技能 / xApp 的工作流定义,不仅要写“执行步骤”,还要写清楚: + +1. 哪些步骤是开放输入 +2. 哪些步骤是候选选择 +3. 哪些步骤允许自动默认 +4. 哪些步骤禁止 AI 静默跨过 + +--- + +## 8. 系统禁止事项 + +以下做法禁止作为正式交互方案: + +1. 系统给出多个候选项,但没有显式选择入口 +2. 系统明明处在关键分叉点,却继续自动执行下游步骤 +3. 用户只能靠回复“第 2 个”“用第三个”完成选择 +4. 工作流已经进入“待确认”状态,但界面没有任何结构化承接 +5. 系统默认选择了某项,但没有标注“这是默认,不是用户确认” + +这些行为会让系统看起来更像一个会说话的黑箱,而不是可控的智能工作台。 + +--- + +## 9. 当前产品阶段的指导结论 + +EAI 当前阶段的正式判断如下: + +1. 一句话启动任务,继续保留 +2. 关键分叉显式选择,必须补齐 +3. 执行过程持续可见,必须通过工作流面板承接 +4. 局部修改回到对话,继续保留 +5. AI 不得静默跨过高价值决策点 + +其中“高价值决策点”至少包括: + +1. 选题 +2. 标题 +3. 路由 +4. Provider +5. 发布 +6. 删除 +7. 覆盖已有结果 +8. 切换当前主责对象 + +--- + +## 10. 一句话结论 + +**EAI 不是用聊天框取代 UI。EAI 是用对话降低启动门槛,用结构化交互接管关键决策,用工作流面板维持持续可控。** + +这条结论从现在开始,作为后续 Workbench、专员工作流、技能运行面、xApp 承接面的共同交互依据。 diff --git a/docs/02_Architecture/目录说明.md b/docs/02_Architecture/目录说明.md index e40d98f..6fbaafc 100644 --- a/docs/02_Architecture/目录说明.md +++ b/docs/02_Architecture/目录说明.md @@ -9,6 +9,7 @@ > 当前产品导航与工作台口径,请以 `docs/01_System_Overall/SY22_角色技能应用统一任务架构.md` 为准: > `新建任务 / 项目 / 专员·技能·APP·连接器 / 长程APP / 知识库 / 后台管理 / 我的` > **`AR09` 是规范性文件(对象命名规范),不是设计稿。** 它约束新增代码的命名,并登记了当前已核实的命名问题与修复流程;与 `TOP_CODING_RULES.md` 的 G03 配套使用。 +> **`AR12` 是当前交互范式讨论的主文档。** 它定义“对话发起 + 结构化决策接管 + 对话继续推进”的混合交互架构,后续 Workbench 与专员工作流交互以此为依据。 > 当前正式对象目录请以 `docs/05_Object_Catalog/` 为准;本目录负责跨层架构契约,不再承担对象清单主仓职责。 ## 文件清单 @@ -29,4 +30,5 @@ | `AR09_对象命名规范.md` | 对象命名规范(**规范性文件**:原理 / 判据 / 术语表 / 分层规范 / 检查守卫 / 修复流程 / 现状问题登记 / 反例库) | | `AR10_应用可删除封装规范.md` | XApp 可删除封装规范(面向一级业务包,目标是“删目录 + 删注册 + 跑卸载”) | | `AR11_技能专员连接器可删除封装规范.md` | Skill / Specialist / Connector 可删除封装规范(复用 AR10 思想,但区分能力包、策略定义包、连接插件包的边界) | +| `AR12_对话驱动与结构化交互架构.md` | Workbench 交互范式主文档(对话发起 + 结构化决策接管 + 对话继续推进) | | `Historical_Records/目录说明.md` | 架构迁移说明、历史清单、旧版 AR01–AR03 与阶段性记录索引 | diff --git a/docs/05_Object_Catalog/specialists/目录.md b/docs/05_Object_Catalog/specialists/目录.md index f847ed7..fe8d8e6 100644 --- a/docs/05_Object_Catalog/specialists/目录.md +++ b/docs/05_Object_Catalog/specialists/目录.md @@ -13,9 +13,9 @@ | `专01` | `EAI-S-01` | `contract-review` | 合同审查专员 | 条款风险、红线建议、审查交付 | active | 保留,后面再看是否补强法务数据面 | | `专02` | `EAI-S-02` | `solution-proposal` | 售前方案专员 | 客户需求澄清、方案草案、评审材料 | active | 保留,后面可补 CRM / 方案模板绑定 | | `专03` | `EAI-S-03` | `training-delivery` | 培训交付专员 | 培训计划、交付节点、归档闭环 | active | 继续观察是否需要拆出交付 xapp | -| `专04` | `EAI-S-04` | `wechat-official-account` | 公众号专员 | 选题、提纲、正文、发布准备 | active | 继续包化公众号工作流与模型 | +| `专04` | `EAI-S-04` | `wechat-official-account` | 公众号创作专员 | 选题、提纲、正文、发布准备 | active | 继续包化公众号工作流与模型 | | `专05` | `EAI-S-05` | `customer-followup` | 客户跟进专员 | 客户资料整理、意向分层、联系推进、会议预约 | active | 作为新口径继续打磨;详见 [客户跟进专员.md](./客户跟进专员.md) | -| `专06` | `EAI-S-06` | `resume-processor` | 招聘筛选专员 | 招聘入口归档、候选人筛选、面试推进 | active | 继续观察是否需要补独立招聘连接器 | +| `专06` | `EAI-S-06` | `resume-processor` | 简历筛选与招聘专员 | 招聘入口归档、候选人筛选、面试推进 | active | 继续观察是否需要补独立招聘连接器 | ## 已退出当前目录但保留历史意义的对象 diff --git a/docs/06_Product_Lines/PL04_云趣能力追平路线图.md b/docs/06_Product_Lines/PL04_云趣能力追平路线图.md index 2392224..7e6315f 100644 --- a/docs/06_Product_Lines/PL04_云趣能力追平路线图.md +++ b/docs/06_Product_Lines/PL04_云趣能力追平路线图.md @@ -103,9 +103,9 @@ - `知识运营专员` - `流程推进专员` - `报告生成专员` -- `公众号专员` +- `公众号创作专员` - `客户跟进专员` -- `招聘筛选专员` +- `简历筛选与招聘专员` ### 4.2 已有执行层基础 diff --git a/docs/20_Global_Workbench_Evolution/GW01_全球主流软件工作台历史演进.md b/docs/20_Global_Workbench_Evolution/GW01_全球主流软件工作台历史演进.md new file mode 100644 index 0000000..78286fd --- /dev/null +++ b/docs/20_Global_Workbench_Evolution/GW01_全球主流软件工作台历史演进.md @@ -0,0 +1,326 @@ +# GW01_全球主流软件工作台历史演进 + +> 状态:研究主文 +> 最后更新:2026-09-23 +> 关联文档: +> - `docs/02_Architecture/AR12_对话驱动与结构化交互架构.md` +> - `GW02_对话式AI工作台与传统UI分界判断.md` + +--- + +## 1. 背景 + +“工作台”不是一个固定不变的 UI 样式,而是软件在不同时代对同一个问题的回答: + +**人如何向系统发起任务、查看状态、做关键选择、协调资源,并把结果沉淀为持续可复用的工作资产。** + +如果只把工作台理解成一个页面布局,就会误判今天的 AI 工作台。 +真正的历史主线不是“按钮变聊天”,而是: + +1. 从命令执行界面,走向图形化操作面 +2. 从孤立应用,走向集成式工作面 +3. 从个人文件处理,走向协同式共享空间 +4. 从信息显示与流程填写,走向角色化任务入口 +5. 从辅助建议,走向 AI 参与执行 +6. 从单代理对话,走向多代理编排与人工接管 + +--- + +## 2. 核心判断 + +全球主流软件工作台形态的演进,并不是线性替换,而是不断叠层。 + +旧层没有消失,新层只是接管了更高价值的控制面: + +1. CLI 没有死,它变成了专家层和自动化层 +2. GUI 没有死,它变成了大众软件的主操作面 +3. 表单和事务界面没有死,它们在 ERP、CRM、后台系统里仍是高密度输入面 +4. 协同工作台没有死,它变成了组织知识和任务的主枢纽 +5. 对话式 AI 没有吞掉 UI,它只是新增了一个极低门槛的意图输入层 +6. Agent Workbench 不是“聊天升级版”,而是新的协调层 + +所以今天讨论 AI 工作台时,不能把问题简化成“要不要聊天框”。 +真正的问题是: + +**哪一层负责发起,哪一层负责决策,哪一层负责执行,哪一层负责审计。** + +--- + +## 3. 历史分期 + +### 3.1 第一阶段:批处理与命令行工作面 + +这一阶段的核心不是“界面美观”,而是“机器终于允许人直接发出可执行指令”。 + +早期交互从打孔卡、批处理到终端命令,解决的是“如何让机器接收任务”。 +CLI 带来的关键变化是: + +1. 首次形成实时往返的人机回路 +2. 用户开始直接操控系统,而不是通过操作员间接提交任务 +3. 任务执行以命令为中心,而不是以可见对象为中心 + +这一阶段的工作台特点是: + +1. 强表达力 +2. 强组合性 +3. 低可见性 +4. 高学习门槛 + +CLI 的历史地位不是“过时的输入法”,而是软件工作台第一次拥有“可编排执行”能力。 +今天 AI Agent 的工具调用、命令执行和脚本编排,本质上都继承了这条线路。 + +### 3.2 第二阶段:GUI 与桌面隐喻 + +GUI 的意义不是“更好看”,而是把机器操作从语法记忆,转移到视觉识别与对象操作。 + +从 Sketchpad、Engelbart 的演示,到 Xerox PARC、桌面隐喻、窗口和鼠标,软件开始把“命令”翻译成“对象 + 动作”的关系。 +这一步把软件工作台从专家工具推进到大众工具。 + +这一阶段奠定了三个长期结构: + +1. 窗口是工作上下文容器 +2. 图标 / 文档 / 文件夹是可见对象 +3. 菜单 / 按钮 / 拖拽是标准动作 + +GUI 最大的贡献不是替代 CLI,而是让“状态可见”和“操作可发现”成为主流软件的基本要求。 + +### 3.3 第三阶段:集成开发工作台与专业工作面 + +IDE 是现代工作台思想第一次被做成高完成度产品。 + +IDE 不只是把编辑器、编译器、调试器放在一起,它定义了“一个工作面如何承接完整任务闭环”: + +1. 代码编写 +2. 运行与调试 +3. 错误定位 +4. 项目导航 +5. 构建与发布 + +这一步的关键不是视觉变化,而是: + +**从单功能工具,升级为围绕任务闭环组织的统一工作面。** + +这条线后来影响了许多企业软件和内容生产软件。 +“左侧导航 + 中央主区 + 底部状态/日志 + 右侧属性/上下文”的经典工作台骨架,很大程度上就是专业工作面的产物。 + +### 3.4 第四阶段:企业事务系统与角色化工作面 + +当 ERP、CRM、ITSM、BPM 系统进入企业核心流程后,工作台开始从“工具整合”走向“角色整合”。 + +这类系统解决的是: + +1. 谁在什么岗位上处理什么事务 +2. 谁能看到哪些数据和动作 +3. 哪些流程必须被审批、留痕、追责 + +这一阶段的典型变化是: + +1. 从功能菜单走向角色入口 +2. 从自由探索走向事务驱动 +3. 从单用户文件处理走向组织流程处理 + +CRM 的演进很有代表性:它从纸质联系人和 Rolodex,发展到 ACT!、SFA、Siebel,再到 Salesforce 的云化,再到 AI 和 agentic CRM,整个演进始终围绕“组织如何围绕客户任务协同”展开,而不只是数据录入 ([TechTarget, 2024](https://www.techtarget.com/searchcustomerexperience/infographic/The-history-and-evolution-of-CRM))。 + +SAP 的演进也说明了同一件事:传统 SAP GUI 的强项是事务深度和专家效率,但 Fiori 明确把方向改成角色化、移动化和 launchpad 化,也就是从“复杂事务屏”走向“角色工作台” ([SAP PRESS, 2026](https://learning.sap-press.com/sap-fiori-overview))。 + +### 3.5 第五阶段:Web / SaaS 工作台 + +SaaS 时代最大的变化,不只是部署方式变成浏览器,而是: + +1. 工作台变成持续在线的服务入口 +2. 组织成员开始共享同一个实时系统 +3. 集成能力成为产品竞争核心 + +这使工作台从本地软件壳,变成组织级云端操作面。 +这一步之后,“一个页面就是一个应用”的理解开始失效,取而代之的是: + +1. 首页总览 +2. 模块导航 +3. 搜索入口 +4. 角色仪表盘 +5. 跨应用跳转 +6. 消息与通知中心 + +也就是说,SaaS 重新定义了工作台的边界: +工作台不再只是当前功能页,而是整个平台的统一入口层。 + +### 3.6 第六阶段:协同工作台与共享上下文 + +Lotus Notes、Google Docs、Slack、Teams、Notion、Figma 这一线,给工作台带来的最大变化是: + +**工作对象从“文件副本”变成“共享上下文”。** + +这一步至少有四个决定性变化: + +1. 文档从静态文件变成共享空间 +2. 消息从即时通知变成可搜索知识 +3. 任务不再附着于单人界面,而是附着于团队上下文 +4. 工具集成开始被工作台统一编排 + +Google Docs 的历史意义,不是“在线版 Word”,而是把文档从附件制、版本制,改成单一实时源 ([Google, 2021](https://blog.google/products-and-platforms/products/workspace/happy-15-years-google-docs/))。 + +Slack 的历史意义,也不是“企业 IM”,而是把人、知识、工具和工作流集中到同一环境里,并明确提出自己是 “operating system for work” ([Slack, 2026](https://slack.com/resources/why-use-slack/what-is-slack-and-how-does-it-work))。 + +Teams 则把协同工作台继续往“整套生产力套件前台”推进,把聊天、会议、文件、日程、自动化和权限体系统一挂到 Microsoft 365 上,形成真正的平台级工作枢纽 ([m.io, 2026](https://www.m.io/blog/history-of-microsoft-teams))。 + +从这一阶段开始,工作台的核心不再只是“我如何操作软件”,而是: + +**团队如何围绕同一个任务上下文持续协作。** + +### 3.7 第七阶段:Copilot 与对话入口 + +ChatGPT 以及随后进入各种软件的 Copilot,把软件工作台再次向前推了一步: + +1. 自然语言成为新的启动入口 +2. 用户可以先表达目标,再让系统反推动作 +3. 软件第一次在大众层面拥有“解释、建议、草拟、改写”的统一入口 + +这一阶段真正被验证的,不是“聊天界面很新鲜”,而是: + +**自然语言把复杂软件的启动门槛显著降低了。** + +这也是为什么 2023 之后,大量成熟软件不是先重写全部 UI,而是先把 Copilot / Assistant 插到现有工作台里。 +因为它们看见的是一个新增入口层,而不是一次性替换全部交互层。 + +### 3.8 第八阶段:Agent Workbench 与控制面升级 + +2025 之后的新变化,不是“聊天更像人”,而是软件开始出现真正的 agent orchestration layer。 + +这一阶段的工作台,已经不只是一个对话框,而是一个控制面: + +1. 用户发起任务 +2. 系统拆解任务 +3. Agent 调用工具执行 +4. 人在关键节点审阅、接管、回退 +5. 平台记录过程、产物与状态 + +GitHub Copilot 的 agent mode 已经明确越过“回答问题”阶段,开始自动识别子任务、跨文件修改、建议或调用工具,并进入自修复循环 ([GitHub Blog, 2025](https://github.blog/news-insights/product-news/github-copilot-agent-mode-activated/))。 + +开发类产品还进一步出现了 agent command center、parallel sessions、agent fleet management 等结构,说明工作台的重心正在从“单轮交互”转向“多执行体编排与治理”。 + +这一步意味着: + +**AI 工作台的主价值,不再只是生成内容,而是协调执行。** + +--- + +## 4. 代表性工作台类型 + +站在 2026 年看,全球主流软件工作台大致可分为六类: + +1. **命令工作台** + - 代表:CLI、终端、脚本环境 + - 强项:表达力、组合性、自动化 + +2. **对象工作台** + - 代表:桌面 GUI、文件管理器、传统设计软件 + - 强项:状态可见、对象直观、通用性高 + +3. **专业工作台** + - 代表:IDE、分析平台、工程软件 + - 强项:围绕专业闭环组织能力 + +4. **事务工作台** + - 代表:ERP、CRM、后台系统、ITSM + - 强项:角色化、审批化、留痕化 + +5. **协同工作台** + - 代表:Docs、Slack、Teams、Notion、Figma + - 强项:共享上下文、多人协作、知识沉淀 + +6. **Agent 工作台** + - 代表:Copilot Workspace、AI-native IDE、Agent Control Plane + - 强项:自然语言发起、工具调用、自动推进、人工接管 + +注意,这六类不是相互排斥。 +今天最有竞争力的产品,往往恰恰是多层叠加: + +1. 用对话发起 +2. 用事务面控制权限与状态 +3. 用协同面承接共享上下文 +4. 用 Agent 层接管执行 + +--- + +## 5. 对当前 AI 工作台讨论的直接启示 + +### 5.1 聊天框不是工作台的全部 + +聊天框只解决了“怎么开始说”,没有天然解决: + +1. 过程状态可见 +2. 候选项并列比较 +3. 默认与确认边界 +4. 回退与审计 +5. 多对象、多工具、多步骤协同 + +### 5.2 现代工作台的核心价值已经变成“协调” + +从 Slack 到 Teams,再到 Agent Control Plane,最值钱的都不是单一功能,而是协调层: + +1. 协调人和人 +2. 协调人和文档 +3. 协调任务和工具 +4. 协调模型和执行体 + +### 5.3 AI 没有取消 UI,AI 把 UI 重新分层了 + +现在真正发生的事不是“界面被对话吞掉”,而是界面被重新分成四层: + +1. 意图输入层 +2. 决策接管层 +3. 执行编排层 +4. 状态审计层 + +哪怕在最先进的 AI 工作台里,这四层也都存在,只是表达方式不同。 + +### 5.4 新一代产品竞争点是“工作台控制权” + +未来产品竞争,不只是谁的模型更强,而是谁控制: + +1. 任务入口 +2. 上下文组织 +3. 工具调用 +4. 审批回路 +5. 结果回写 + +也就是说,真正的战略高地不是单个 Agent,而是 Agent 所附着的工作台控制面。 + +--- + +## 6. 结论 + +全球主流软件工作台的历史演进,说明了一个非常稳定的规律: + +**每一代工作台的胜出,不是因为它删掉了上一代,而是因为它把“发起任务、做决策、调动资源、沉淀结果”的闭环做得更完整。** + +所以,今天讨论对话式 AI 软件时,真正的问题不是: + +- 要不要聊天框 +- 要不要按钮 +- 要不要 Tab + +真正的问题是: + +1. 对话负责什么 +2. 结构化界面负责什么 +3. Agent 负责什么 +4. 人工确认点放在哪里 +5. 过程如何保持可见、可接管、可回退 + +这也是为什么下一代 AI 工作台,不会回到传统 GUI,也不会停在纯聊天界面。 +它会走向一种新的混合形态: + +**对话负责发起,结构化界面负责关键接管,Agent 负责执行推进,工作流面板负责持续可见。** + +--- + +## 7. 参考来源 + +1. The History of User Interfaces, https://www.historyofui.com/ +2. TechTarget, The history and evolution of CRM, https://www.techtarget.com/searchcustomerexperience/infographic/The-history-and-evolution-of-CRM +3. Slack, What is Slack and how does it work?, https://slack.com/resources/why-use-slack/what-is-slack-and-how-does-it-work +4. Google, 15 milestones, moments and more for Google Docs’ 15th birthday, https://blog.google/products-and-platforms/products/workspace/happy-15-years-google-docs/ +5. SAP PRESS, SAP Fiori Overview: UX, App Types, and Deployment Models, https://learning.sap-press.com/sap-fiori-overview +6. m.io, The History of Microsoft Teams, https://www.m.io/blog/history-of-microsoft-teams +7. GitHub Blog, Vibe coding with GitHub Copilot: Agent mode and MCP support rolling out to all VS Code users, https://github.blog/news-insights/product-news/github-copilot-agent-mode-activated/ diff --git a/docs/20_Global_Workbench_Evolution/GW02_对话式AI工作台与传统UI分界判断.md b/docs/20_Global_Workbench_Evolution/GW02_对话式AI工作台与传统UI分界判断.md new file mode 100644 index 0000000..6299bdf --- /dev/null +++ b/docs/20_Global_Workbench_Evolution/GW02_对话式AI工作台与传统UI分界判断.md @@ -0,0 +1,361 @@ +# GW02_对话式AI工作台与传统UI分界判断 + +> 状态:专题判断 +> 最后更新:2026-09-23 +> 关联文档: +> - `GW01_全球主流软件工作台历史演进.md` +> - `docs/02_Architecture/AR12_对话驱动与结构化交互架构.md` + +--- + +## 1. 问题定义 + +新一代 AI 驱动软件,最大的误判之一,就是把问题理解成: + +- “聊天框会不会取代传统 UI” +- “问答式是不是比按钮式先进” +- “有了自然语言之后,页面结构是不是就不重要了” + +这些提法都不够准确。 + +真正的问题是: + +**当软件具备自然语言理解、内容生成、工具调用和阶段性自动执行能力之后,传统 UI 的职责如何被重排。** + +也就是说,我们讨论的不是“聊天 vs 页面”,而是: + +1. 哪些环节适合开放表达 +2. 哪些环节必须显式选择 +3. 哪些环节可以自动执行 +4. 哪些环节必须由人工接管 + +--- + +## 2. 结论先行 + +结论非常明确: + +**对话式 AI 工作台不会消灭传统 UI,但会把传统 UI 从“唯一入口层”改造成“结构化接管层和可见性层”。** + +所以,新一代 AI 工作台最合理的主形态不是: + +1. 纯聊天 +2. 纯表单 +3. 聊天框旁边随便贴几个按钮 + +而是: + +**对话发起 + 结构化决策接管 + 持续可见的工作流状态面。** + +--- + +## 3. 为什么纯对话不够 + +### 3.1 对话擅长启动,不擅长稳定决策 + +用户在任务开始时,经常只能说出目标,说不清完整参数。 + +例如: + +- “帮我围绕这个热点写一篇公众号稿” +- “给我配一条 AI 路由策略” +- “把组织配置整理成更适合后台管理的结构” + +这时候对话很强,因为它允许模糊表达、逐步澄清和低门槛启动。 + +但一旦进入关键分叉点,例如: + +1. 选哪个选题 +2. 选哪个标题 +3. 走哪个 Provider +4. 是否继续发布 +5. 是否覆盖已有结果 + +纯对话就会开始失控。 + +原因很简单: + +1. 候选项会被消息流淹没 +2. 用户不容易对比多个候选 +3. 用户不清楚系统是“建议”还是“已经默认执行” +4. 历史决策边界不清楚 + +### 3.2 对话天然是线性的,任务天然是分叉的 + +聊天是时间线结构。 +工作流是状态机结构。 + +这两者在关键节点上天然冲突。 + +只靠聊天往下滚,会出现三个问题: + +1. 分叉点不可见 +2. 默认选择不可见 +3. 回退路径不可见 + +所以只要任务不是单轮问答,而是持续推进的工作闭环,对话就必须让位给某种结构化承接。 + +### 3.3 对话会放大“黑箱感” + +当系统既能理解自然语言,又能自动往下执行时,如果没有显式状态承接,用户会产生强烈的不确定感: + +1. AI 为什么选了这个方案 +2. 还有哪些备选没展示 +3. 这一步是不是已经不可逆 +4. 是我确认的,还是系统默认的 + +这会直接伤害信任。 + +所以对话式 AI 产品如果不补足结构化接管层,看起来很智能,实际会越来越难控。 + +--- + +## 4. 为什么纯传统 UI 也不够 + +传统 UI 的强项也很明确: + +1. 候选项并列展示 +2. 状态稳定 +3. 可见性强 +4. 回退路径清楚 +5. 权限和审计友好 + +但传统 UI 有三个天然短板: + +1. 启动成本高 +2. 表单感强 +3. 难以承接模糊目标 + +用户在任务开始时,往往根本不知道该先填哪一项。 +如果系统一上来就是大表单和流程页,用户会把认知负担提前承担掉。 + +所以传统 UI 很适合“确认和控制”,不适合“生成需求和探索方向”。 + +--- + +## 5. 正式分界:开放表达与有限决策 + +这一代 AI 工作台最关键的交互边界,可以用一句话概括: + +**开放问题用对话,有限决策用结构化 UI。** + +### 5.1 适合对话的环节 + +以下环节优先用自然语言: + +1. 用户第一次描述目标 +2. 用户补充背景和约束 +3. 用户要求 AI 解释建议 +4. 用户对结果做局部修改 +5. 用户重新定义问题 + +### 5.2 适合结构化 UI 的环节 + +以下环节必须有显式承接: + +1. 系统已经形成有限候选集 +2. 用户必须在候选中选一个继续 +3. 不同选项会导致不同下游动作 +4. 系统需要记录“用户确认”还是“默认沿用” +5. 一旦继续执行,会产生不可忽略的时间、资源或结果成本 + +### 5.3 两者必须共存的环节 + +最理想的形态不是按钮替代对话,而是: + +1. 给出候选项 +2. 提供一键选择 +3. 同时允许用户说“都不要,我自己改一个” + +所以最佳交互单元不是单纯按钮,也不是单纯问答。 + +最佳交互单元是: + +**候选卡片 + 一键确认 + 手工改写入口。** + +--- + +## 6. 对话式 AI 软件和传统软件的根本差异 + +传统软件大多要求: + +1. 用户先学会系统结构 +2. 用户再进入正确页面 +3. 用户再按流程填写和点击 + +对话式 AI 软件把这件事倒过来了: + +1. 用户先说目标 +2. 系统再帮助补全结构 +3. 再在关键节点要求用户接管 + +这带来两个本质变化: + +### 6.1 入口从“功能入口”变成“意图入口” + +传统软件的入口是模块和菜单。 +AI 软件的入口可以是目标和需求。 + +这让系统第一次可以从“我要做什么”而不是“我要进哪个页面”开始。 + +### 6.2 软件开始承担“中间推理” + +传统 UI 默认把中间推理留给用户。 +AI 工作台开始替用户做一部分: + +1. 补参数 +2. 提候选 +3. 生成草稿 +4. 给建议 +5. 编排执行路径 + +但中间推理越强,越需要“中间可见”。 + +这就是为什么 AI 软件不可能只有聊天框。 +因为它承担了更多中间推理,所以更需要中间状态可视化。 + +--- + +## 7. 对工作台设计的具体要求 + +### 7.1 必须区分三类状态 + +所有 AI 工作台都必须明确区分: + +1. `用户输入` +2. `系统建议` +3. `系统已执行` + +如果这三类状态在界面上混在一起,用户很快就会失去控制感。 + +### 7.2 必须标明默认值与确认值 + +很多系统喜欢“先帮你默认选一个再继续跑”,短期看流畅,长期看一定出问题。 + +因为用户会分不清: + +1. 这是我选的 +2. 这是系统代我选的 +3. 这是系统只是先暂存的 + +所以默认项必须显式标注。 + +### 7.3 必须有待接管状态 + +一旦系统来到关键分叉点,不能继续伪装成自然流畅对话。 + +它必须明确进入一种待接管状态,例如: + +1. 待选题 +2. 待选标题 +3. 待确认发布 +4. 待选择路由 + +这一步是 AI 工作台和“会聊天的功能插件”之间的分水岭。 + +### 7.4 必须允许回到对话局部修改 + +结构化接管不是把系统重新做回旧表单。 + +用户在接管后,仍然应该能立刻回到对话里说: + +- “第三个标题好一点,但再稳一点” +- “不要这些候选,我自己写一个” +- “就按这个选题,但换个更像行业观察的语气” + +也就是说: + +**结构化接管负责做决定,对话负责做微调。** + +--- + +## 8. 对 EAI 这类系统的直接启示 + +EAI 这类“任务中心 + 对象中心”的平台,不应该模仿传统后台,也不应该停在单一聊天产品形态。 + +它更接近一种“可控的 AI 工作台”。 + +所以 EAI 的正确方向应当是: + +1. 一句话发起任务 +2. 系统自动形成候选与工作流初稿 +3. 在关键节点由用户显式接管 +4. 系统继续自动推进 +5. 全过程在工作流面板中保持可见 + +这意味着: + +### 8.1 专员工作流不能只靠消息流表达 + +例如公众号创作专员的: + +1. 选题 +2. 标题 +3. 提纲 +4. 配图 +5. 发布准备 + +其中至少前两步就必须有结构化承接。 + +### 8.2 技能执行不能只显示“开始执行 / 执行完成” + +技能如果涉及多候选、多配置、多路由选择,也必须有中间状态和待确认点。 + +### 8.3 后台治理界面仍然必须保留高度结构化 + +AI 调用量、路由策略、Provider 配置、权限与审计,这些都不是适合纯对话承接的主面。 +这些地方仍然需要明确的表格、Tab、对比视图和可追责记录。 + +所以 AI 平台永远不会完全取消“传统后台”,它只会改变前台任务发起和工作流协作方式。 + +--- + +## 9. 一句话判断 + +**对话式 AI 工作台最大的创新,不是让软件变成聊天,而是让用户可以先说目标,再在关键节点低成本接管系统。** + +这也是它与传统 UI 的根本区别: + +1. 传统 UI 要求用户先理解结构再操作 +2. AI 工作台允许用户先表达目标再逐步接管 + +但这条路成立的前提是: + +**系统必须把关键决策点重新显式化。** + +没有这一步,对话式 AI 软件就会沦为: + +1. 启动很惊艳 +2. 中途很混乱 +3. 结果很难控 + +--- + +## 10. 结论 + +新一代对话式 AI 软件和传统 UI 之间的重大区别,不在于有没有聊天框,而在于: + +**系统把“需求形成”前移给对话,把“关键决策”交还给结构化界面,把“持续推进”交给 Agent,把“状态与审计”交给工作流控制面。** + +这四层缺一不可。 + +因此,真正成熟的 AI 工作台不会长成纯聊天产品,也不会回退成纯表单后台。 +它会长成一种混合形态: + +1. 对话负责发起 +2. 卡片 / 按钮 / Tab 负责接管 +3. Agent 负责执行 +4. 工作流面板负责可见性和回退 + +这不是传统 UI 的终结。 +这是传统 UI 被重新分工。 + +--- + +## 11. 参考来源 + +1. The History of User Interfaces, https://www.historyofui.com/ +2. Slack, What is Slack and how does it work?, https://slack.com/resources/why-use-slack/what-is-slack-and-how-does-it-work +3. Google, 15 milestones, moments and more for Google Docs’ 15th birthday, https://blog.google/products-and-platforms/products/workspace/happy-15-years-google-docs/ +4. SAP PRESS, SAP Fiori Overview: UX, App Types, and Deployment Models, https://learning.sap-press.com/sap-fiori-overview +5. GitHub Blog, Vibe coding with GitHub Copilot: Agent mode and MCP support rolling out to all VS Code users, https://github.blog/news-insights/product-news/github-copilot-agent-mode-activated/ diff --git a/docs/20_Global_Workbench_Evolution/GW03_专员工作交互形式分析.md b/docs/20_Global_Workbench_Evolution/GW03_专员工作交互形式分析.md new file mode 100644 index 0000000..028e04c --- /dev/null +++ b/docs/20_Global_Workbench_Evolution/GW03_专员工作交互形式分析.md @@ -0,0 +1,554 @@ +# GW03_专员工作交互形式分析 + +> 状态:专题分析 +> 最后更新:2026-09-23 +> 关联文档: +> - `GW01_全球主流软件工作台历史演进.md` +> - `GW02_对话式AI工作台与传统UI分界判断.md` +> - `docs/05_Object_Catalog/specialists/`(各专员详细定义) + +--- + +## 1. 分析目标 + +GW02 给出了"对话 vs 结构化 UI"的分界原则。本文件把它落到具体对象上: + +**平台中每个专员,它的启动、决策、执行、反馈分别应该走对话还是走 UI 选择。** + +分析维度统一为四项: + +| 维度 | 说明 | +|---|---| +| **启动方式** | 用户如何开始与该专员的交互 | +| **决策方式** | 系统给出候选后,用户如何选择 | +| **执行方式** | 系统自动推进还是人工辅助 | +| **反馈/审计方式** | 结果如何展示、如何回查 | + +所有分析基于以下分界标准(来自 GW02): + +1. 开放问题用对话 +2. 有限决策用结构化 UI +3. 候选卡片 + 一键确认 + 手工改写入口 = 最佳交互单元 + +--- + +## 2. 通用级专员分析 + +### 2.1 通用助手(general-assistant) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话**。用户直接说目标,不进入任何应用路由。这是系统唯一入口。 | +| **决策方式** | **对话为主,路由选择用结构化**。通用助手不自己做领域决策,它的核心职责是"判断意图 → 推荐专员"。当它推荐多个专员时,需要用结构化卡片展示选项,而不是纯对话列表。 | +| **执行方式** | **dw(默认工作站)**。它可以自主执行简单任务(闲聊、查资料、任务拆解),但遇到领域任务时必须路由到其他专员。 | +| **反馈/审计** | **对话流 + 任务记录**。简单任务直接在对话中反馈;路由决策需要记录"用户选择了哪个专员、从什么入口进入"。 | + +**交互关键判断:** + +通用助手不应该只是一个"会聊天的功能插件"。它是平台的路由中枢。它的关键交互特征是: + +1. 用户说"帮我写一篇公众号稿" → 对话启动 +2. 通用助手判断意图 → 对话分析 +3. 系统展示"公众号创作专员 / 汇报专员 / 通用任务"等候选卡片 → **结构化选择** +4. 用户选"公众号创作专员" → 进入公众号创作专员的工作流 + +所以通用助手的"对话"只是入口层,它的真正价值在"路由决策的透明化"。 + +--- + +### 2.2 公众号创作专员(wechat-official-account) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话**。用户说"帮我起一个公众号选题"或"围绕这个热点写一篇文章"。 | +| **决策方式** | **必须结构化接管**。公众号创作专员有四步工作流:选题 → 标题 → 提纲 → 正文。每一步产出多个候选后,都必须用结构化卡片展示,让用户做选择。不能只在聊天流里说"我生成了3个选题"让用户回复序号。 | +| **执行方式** | **dw(默认工作站)**。内容生成可自主执行,但每个候选都需要用户确认后才进入下一步。 | +| **反馈/审计** | **对话流 + 工作流面板**。生成过程在对话中展示,但选题、标题等关键决策必须在工作流面板中可见,支持回退和修改。 | + +**交互关键判断:** + +公众号创作专员是平台中工作流最清晰的专员之一。它的工作流四步不是四段对话,而是**四次决策分叉**: + +``` +启动对话 → 生成3个选题 → [用户选第2个] → 生成3个标题 → [用户选第1个] +→ 生成提纲 → [用户修改后确认] → 生成正文 → [用户编辑后发布准备] +``` + +四次分叉,四次结构化接管。 + +如果只做纯对话,会出现以下问题: + +1. 三次选题在聊天流中被淹没,用户找不到"我到底选了哪个" +2. 用户无法回退到"换个标题"而不重写整篇文章 +3. 用户不知道系统已经执行到哪一步,哪些已确认、哪些是默认 + +所以公众号创作专员的正确交互形态是: + +**对话启动 → 候选卡片选择 → 候选卡片选择 → 候选卡片选择 → 候选卡片选择 → 最终编辑** + +每一步都是"对话生成 + 卡片选择"的闭环。 + +--- + +### 2.3 汇报专员(presentation-briefing) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话**。用户说"帮我做一版 8 页管理层汇报 PPT"。 | +| **决策方式** | **混合。页数/结构用结构化,内容用对话微调**。汇报专员的输出是 PPT 结构,这是一个强结构化的文档类型。用户应该先选择汇报类型(经营汇报/方案汇报/培训课件/项目复盘),然后系统生成大纲结构,用户确认或修改。 | +| **执行方式** | **dw(默认工作站)**。PPT 生成可自主执行,但大纲确认后才能生成具体内容。 | +| **反馈/审计** | **结构化预览 + 对话微调**。PPT 大纲需要结构化预览,生成后的内容允许用户在对话中说"第三页太长了,精简一下"。 | + +**交互关键判断:** + +汇报专员和公众号创作专员不同。公众号是"从零创作",汇报专员是"基于已有材料整理"。所以它的启动阶段可能需要用户上传文件或粘贴文本,这本身就是结构化输入。 + +关键决策点: + +1. 选择汇报类型 → **结构化卡片** +2. 上传/粘贴材料 → **结构化文件上传** +3. 生成大纲 → **结构化预览 + 确认** +4. 生成内容 → **对话微调(逐页说)** +5. 最终导出 → **结构化确认** + +--- + +## 3. 行业级专员分析 + +### 3.1 合同审查专员(contract-review) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话 + 文件上传**。用户说"审查这份采购合同",同时上传合同文件。 | +| **决策方式** | **必须结构化接管**。合同审查的输出是风险分级(critical/high/medium/low),这是一个高度结构化的结果。用户必须能看到完整的条款列表、风险等级、红线建议,并逐项确认。纯对话无法承载这种复杂信息。 | +| **执行方式** | **adw(辅助默认工作站)**。合同审查涉及法律合规,必须人工确认每个风险点。系统可以自动识别条款和标记风险等级,但用户必须逐项审核。 | +| **反馈/审计** | **高度结构化**。审查结果需要以表格形式展示:条款名、风险等级、建议、原始条款位置。这是审计友好的界面。 | + +**交互关键判断:** + +合同审查专员是"adw 模式"的典型代表。它的工作流程是: + +``` +上传合同 → [系统自动分析] → 结构化展示风险表格 → [用户逐项确认/修改] → 生成审查报告 → [用户确认/导出] +``` + +每一步都不能用纯对话替代。原因: + +1. 风险分级是四级结构化数据,聊天流无法清晰展示 +2. 用户需要能"只看 critical 和 high"、"按条款类型筛选" +3. 审查结果是法律级文件,必须有完整的审计追踪 + +所以合同审查专员的"对话"只发生在启动阶段(说一句话 + 上传文件),后续全部是结构化交互。 + +--- + +### 3.2 售前方案专员(solution-proposal) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话 + 结构化输入**。用户说"帮我起一版智能培训升级方案",系统追问客户背景、行业、规模、预算等关键参数。 | +| **决策方式** | **必须结构化接管**。售前方案的核心输出是"方案结构 + 需求澄清 + 风险边界",这些都是高度结构化的内容。 | +| **执行方式** | **adw(辅助默认工作站)**。售前方案涉及客户关系,需要人工确认哪些是"已确认需求"、哪些是"我方假设"。 | +| **反馈/审计** | **结构化方案预览 + 对话微调**。方案大纲需要结构化展示,内容细节允许对话微调。 | + +**交互关键判断:** + +售前方案专员的交互关键在"需求澄清"环节。系统不能直接生成方案,而是应该: + +``` +用户说需求 → [系统追问关键参数] → 生成需求清单 → [用户确认需求] +→ 生成方案大纲 → [用户确认大纲] → 生成方案内容 → [用户修改后确认] +``` + +其中"系统追问关键参数"阶段是**对话引导的结构化采集**——系统用对话问用户,但用户回答的内容是结构化的(下拉选择、数字输入、多选)。 + +--- + +### 3.3 客户跟进专员(customer-followup) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话 + 数据导入**。用户说"帮我跟进这些客户",系统导入客户数据或用户粘贴客户信息。 | +| **决策方式** | **混合。意向分级用结构化,联系策略用对话生成**。客户意向分级(A/B/C/D)是结构化判断,但首轮联系话术可以先生成多条供用户选择。 | +| **执行方式** | **adw(辅助默认工作站)**。客户跟进涉及对外沟通,需要人工确认每条联系消息后再发送。 | +| **反馈/审计** | **结构化客户列表 + 对话生成的话术**。客户状态需要结构化表格展示,话术可以对话生成。 | + +**交互关键判断:** + +客户跟进专员是"数据驱动型"专员。它的工作流程是: + +``` +导入客户数据 → [系统自动分级] → 结构化展示客户列表和意向等级 → [用户确认分级] +→ 生成首轮联系话术 → [用户选择/修改] → 预约建议 → [用户确认] → 状态回写 +``` + +关键决策点: + +1. 客户分级 → **结构化(系统建议等级 + 用户确认)** +2. 联系话术 → **对话生成 + 卡片选择** +3. 预约时间 → **结构化日历选择** +4. 状态回写 → **结构化确认** + +--- + +### 3.4 简历筛选与招聘专员(resume-processor) + +| 维度 | 分析 | +|---|---| +| **启动方式** | **对话 + 批量导入**。用户说"帮我筛这批简历",系统导入简历文件或用户粘贴候选人信息。 | +| **决策方式** | **必须结构化接管**。招聘筛选的核心输出是"候选人排序 + 匹配度评分 + 推荐理由",这是一个典型的排序决策问题。聊天流无法清晰展示排名和对比。 | +| **执行方式** | **dw(默认工作站)**。简历筛选可自主执行,但用户必须能看到完整的候选人列表、匹配度、筛选理由,并手动调整排序。 | +| **反馈/审计** | **高度结构化表格**。候选人列表必须按匹配度排序展示,支持按技能、经验、学历等维度筛选。这是招聘场景的刚需。 | + +**交互关键判断:** + +简历筛选与招聘专员是"数据驱动型"专员中最需要结构化 UI 的一个。它的工作流程: + +``` +导入简历 → [系统自动解析和评分] → 结构化展示候选人排名表格 → [用户调整排序/标记面试/淘汰] +→ 生成面试建议 → [用户确认] → 面试安排 → [用户确认] +``` + +关键决策点: + +1. 简历解析 → **系统自动执行 + 结构化展示** +2. 匹配度评分 → **系统建议 + 用户调整** +3. 面试/淘汰标记 → **结构化按钮操作** +4. 面试建议 → **对话生成 + 卡片选择** +5. 面试安排 → **结构化日历选择** + +这个专员有一个特殊特征:**排序决策**。聊天流完全不适合做排序和对比。用户需要能看到 Top 10 候选人的横向对比。 + +#### 3.4.1 功能拆解:它真正要负责什么 + +这个专员不能只做“筛简历”这个单点动作,它应该负责一条完整但边界清晰的招聘推进链: + +| 功能层 | 必须覆盖的能力 | 不能缺的输出 | +|---|---|---| +| **招聘入口归档** | 拉取招聘邮箱、上传附件、识别简历、去重合并、保留来源 | 候选人入口清单、重复投递合并记录 | +| **岗位要求建模** | 读取 JD、拆出硬门槛、加分项、淘汰项、面试关注项 | 结构化筛选规则卡、评分维度表 | +| **候选人解析** | OCR、字段抽取、技能识别、项目经历拆解、年限估算 | 候选人标准档案 | +| **匹配度评分** | 按岗位维度打分、给排序理由、标不确定项 | 排名表、评分证据、待确认问题 | +| **推进建议** | 推荐进入面试/淘汰/保留池、给面试重点、生成沟通建议 | 推荐清单、面试建议卡、淘汰原因 | +| **招聘协同** | 面试排期、状态回写、和招聘经理确认 | 面试安排单、状态流转记录 | + +这里最关键的一点是:**它的主价值不是“帮你读一份简历”,而是“把一批候选人变成一张可推进的决策清单”。** + +如果只做解析或摘要,它只是技能;如果能把入口、评分、排序、推进连起来,它才配叫专员。 + +#### 3.4.2 好用的目标工作流:不是线性表单,而是批处理决策流 + +这个专员的理想工作流应该分成 6 段,每一段都有清晰输入、系统动作和人工确认点: + +1. **创建招聘任务** + 用户输入岗位名称、JD、招聘邮箱或上传简历包,选择本轮筛选目标。 + +2. **建立筛选规则** + 系统把 JD 拆成: + - 硬门槛:必须满足 + - 加分项:满足越多越靠前 + - 风险项:信息缺失或冲突 + - 面试关注项:需要面谈验证 + +3. **批量解析候选人** + 系统读取邮件、附件、PDF、图片简历,抽取候选人结构化字段,并做重复投递识别。 + +4. **自动评分与排序** + 系统按统一评分卡打分,输出总分、分项分、排名、理由、不确定项。 + +5. **人工校正与分桶** + 用户直接在表格里做四类动作: + - 调整排序 + - 标记进入面试 + - 标记淘汰 + - 标记待确认 + +6. **生成推进动作** + 系统批量生成: + - 推荐候选人清单 + - 面试重点摘要 + - 邀约邮件/消息草稿 + - 面试安排建议 + +真正影响可用性的,不是第 3 步“AI 能不能抽字段”,而是第 5 步“用户能不能极快地改、看、比、确认”。 + +#### 3.4.3 UI 结构:主界面必须围绕“候选人排名表”展开 + +这个专员最适合的不是纯聊天页,而是一个 **1 个主表 + 2 个侧栏 + 1 个底部审计区** 的工作台。 + +建议布局: + +``` +┌──────────────────────────────────────────────────────────────┐ +│ 顶部任务条:岗位 / 当前轮次 / 待处理数 / 面试推荐数 / 风险数 │ +├──────────────┬──────────────────────────────┬───────────────┤ +│ 左侧规则栏 │ 中央候选人主表 │ 右侧详情栏 │ +│ │ │ │ +│ JD 规则卡 │ 排名 / 候选人 / 总分 / 分项分 │ 候选人详情 │ +│ 硬门槛 │ 来源 / 风险 / 推荐动作 │ 简历原文片段 │ +│ 加分项 │ [筛选][排序][批量操作] │ 评分证据 │ +│ 面试关注项 │ │ 待确认点 │ +│ │ │ 操作记录 │ +├──────────────┴──────────────────────────────┴───────────────┤ +│ 底部审计区:时间线 / 回放 / 对话微调 / 导出结果 │ +└──────────────────────────────────────────────────────────────┘ +``` + +四个关键区块一个都不能少: + +1. **左侧规则栏** + 让用户始终看到“系统按什么筛人”。没有这个区块,评分就会变成黑箱。 + +2. **中央候选人主表** + 这是整个页面的核心。列建议固定为: + - 排名 + - 候选人 + - 当前阶段 + - 总分 + - 核心维度分 + - 来源 + - 风险 + - 推荐动作 + - 人工最终动作 + +3. **右侧详情栏** + 点击任一候选人后展开,展示: + - 基本信息 + - 匹配证据 + - 简历原文引用 + - 缺失信息 + - 推荐理由 + - 面试重点 + +4. **底部审计区** + 不只是“日志”。它应该包含: + - 时间线 + - 回放节点 + - 对话微调入口 + - 导出记录 + +#### 3.4.4 什么叫“能用好用”:要把高频动作压缩成一屏完成 + +招聘同学的真实高频动作不是聊天,而是以下几类: + +- 先看前 10 名 +- 看某人为什么排这里 +- 快速标记进面 / 淘汰 / 待确认 +- 调整某个维度权重 +- 找出“学历不满足但经验很强”的例外 +- 导出一版给招聘经理确认 + +所以“好用”的标准不是页面好看,而是下面这些动作能不能极快完成: + +| 目标动作 | 好用的交互方式 | 不好用的交互方式 | +|---|---|---| +| 看前 10 名 | 默认按总分排序,首屏直出 | 让用户先问一句“谁最合适” | +| 看排序理由 | 行内展开 + 右栏证据 | 只给一句文本总结 | +| 标记进面/淘汰 | 表格行内按钮 + 批量操作 | 打开详情页再二次确认 | +| 改筛选规则 | 左栏直接改权重或门槛 | 回到聊天里重新描述 | +| 看信息缺口 | 风险标签 + 缺失字段提示 | 把问题埋在长段解释里 | +| 导出决策单 | 一键导出推荐清单 | 手工复制聊天内容 | + +一句话:**这个专员的“好用”来自可批量处理、可横向比较、可快速确认,而不是来自会聊天。** + +#### 3.4.5 对话在这里仍然重要,但只能做三件事 + +这个专员不是不要对话,而是要把对话压缩到最有价值的三个位置: + +1. **启动任务** + 例如:“帮我按这个 JD 先筛今天邮箱里收到的简历。” + +2. **局部调参** + 例如:“这轮更看重 ToB 销售经验,学历权重降一点。” + +3. **生成解释与沟通文本** + 例如:“帮我把前 3 名的推荐理由整理给招聘经理。” + +除此之外的核心决策,都应该回到结构化 UI。 + +#### 3.4.6 当前实现与目标状态之间的差距 + +结合当前仓库状态,这个专员已经有了对象定义、工作台样例数据和基础话术,但距离“能用好用”还有几处硬缺口: + +1. **当前入口仍是通用工作台入口** + 前端专员 manifest 默认 `objectEntryRoute = '/home'`,说明它目前是“挂载到通用工作台”的对象,不是独立招聘工作区。 + +2. **当前工作台主要还是静态场景稿** + `workbench.js` 里已经有“候选人列表、时间线、回放”等结构,但本质还是演示态数据,不是招聘域对象驱动的真实 UI。 + +3. **缺少岗位规则建模面** + 现在定义里强调了“按岗位要求评分”,但没有看到专门承载 JD 拆解、权重调节、硬门槛确认的页面结构。 + +4. **缺少候选人证据面** + 真正让招聘经理敢用的不是分数,而是“分数背后的证据引用”。这一层目前还不够具体。 + +5. **缺少批量动作面** + 招聘场景的效率来自批处理:批量标记、批量淘汰、批量发起邀约。没有批量动作,页面再结构化也不够好用。 + +#### 3.4.7 产品落地优先级:先把什么做出来 + +如果按“最快形成可用闭环”排序,我建议按下面三层推进: + +**P0:先把最小可用闭环做出来** + +- 独立招聘任务页 +- JD 结构化规则卡 +- 候选人主表 +- 候选人详情侧栏 +- 进面 / 淘汰 / 待确认 三类动作 +- 推荐清单导出 + +**P1:把它做顺手** + +- 权重调节 +- 缺失信息高亮 +- 例外候选人提示 +- 批量操作 +- 面试重点自动生成 +- 邀约草稿生成 + +**P2:把它做成真正的招聘工作台** + +- 招聘邮箱实时同步 +- 多岗位并行筛选 +- 面试反馈回写 +- 录用结果反哺评分模型 +- “为什么这次排序和上次不同”的版本对比 + +结论很明确:**这个专员不应该继续停留在“聊天入口 + 几段说明文案”的层级,它必须升级成一个围绕候选人排序表运转的招聘工作台。** + +## 4. 综合分析 + +### 4.1 按交互模式分类 + +将所有专员按"对话 vs 结构化"的比例排序: + +| 专员 | 对话占比 | 结构化占比 | 模式 | +|---|---|---|---| +| **通用助手** | 70% | 30% | 对话入口 + 结构化路由 | +| **公众号创作专员** | 40% | 60% | 对话生成 + 四次结构化选择 | +| **汇报专员** | 40% | 60% | 对话生成 + 结构化大纲 | +| **简历筛选与招聘专员** | 20% | 80% | 对话启动 + 高度结构化表格 | +| **合同审查专员** | 20% | 80% | 对话启动 + 高度结构化表格 | +| **售前方案专员** | 30% | 70% | 对话引导 + 结构化方案 | +| **客户跟进专员** | 30% | 70% | 对话启动 + 结构化客户列表 | + +### 4.2 关键发现 + +**发现 1:对话占比最高的不是"对话式 AI",而是路由层** + +通用助手对话占比 70%,但它不做领域决策。真正做领域决策的专员,对话占比都在 20%-40%。这说明: + +> 对话的最大价值在"启动和路由",不在"执行和决策"。 + +**发现 2:所有涉及排序、对比、分级的任务必须用结构化 UI** + +招聘筛选(候选人排序)、合同审查(风险分级)、客户跟进(意向分级)——这三个专员的共同特征是"多选项对比决策"。聊天流无法承载这种决策需求。 + +**发现 3:adw 模式的专员一定有更强烈的结构化需求** + +合同审查和售前方案都是 adw 模式,它们的共同特征是"涉及合规/风险/客户关系",所以每一步决策都需要人工确认,这也意味着需要更丰富的结构化界面来支撑确认过程。 + +**发现 4:工作流越清晰的专员,越不适合纯对话** + +公众号创作专员有四步明确工作流(选题→标题→提纲→正文),每一步都需要用户确认。这种"多阶段决策"是结构化 UI 的强项,不是聊天流的强项。 + +--- + +## 5. 对 EAI 平台的交互设计建议 + +基于以上分析,对平台交互设计提出以下建议: + +### 5.1 通用助手:对话入口 + 结构化路由卡片 + +通用助手的核心价值是路由。当它推荐多个专员时,必须用结构化卡片展示: + +``` +┌─────────────────────────────────────┐ +│ 你可以根据需要选择: │ +│ │ +│ [📝 公众号创作专员] 内容创作 │ +│ [📊 汇报专员] 演示汇报 │ +│ [📋 合同审查专员] 法务审查 │ +│ │ +│ 或者继续用通用助手聊天 │ +└─────────────────────────────────────┘ +``` + +### 5.2 公众号创作专员:对话生成 + 卡片选择 + 工作流面板 + +公众号创作专员的四步工作流应该在工作流面板中持续可见: + +``` +┌──────────────────┬──────────────────────────┐ +│ 工作流面板 │ 对话区 │ +│ │ │ +│ ① 选题 ● 已选 │ 生成了3个选题: │ +│ ② 标题 ○ 待选 │ [卡片A] [卡片B] [卡片C] │ +│ ③ 提纲 ○ 待选 │ 请选择一个 │ +│ ④ 正文 ○ 待选 │ │ +│ │ │ +│ 已选:选题2 │ │ +│ │ │ +│ [← 上一步] [下一步 →] │ +└──────────────────┴──────────────────────────┘ +``` + +### 5.3 合同审查/招聘筛选:高度结构化表格 + 对话辅助 + +这些专员的"主界面"应该是表格: + +``` +┌──────┬──────────────┬────────┬──────────────────┐ +│ 排名 │ 候选人/条款 │ 等级 │ 建议/理由 │ +├──────┼──────────────┼────────┼──────────────────┤ +│ 1 │ 张三 │ CRITICAL│ 赔偿条款无限额 │ +│ 2 │ 李四 │ HIGH │ 缺少保密条款 │ +│ 3 │ 王五 │ MEDIUM │ 工作时间不合规 │ +│ ... │ ... │ ... │ ... │ +└──────┴──────────────┴────────┴──────────────────┘ + +[筛选: 全部 | CRITICAL | HIGH | MEDIUM] +[导出审查报告] [确认并继续] +``` + +在表格下方或侧边保留对话入口,允许用户说"第2条再严格一点"。 + +### 5.4 所有专员的共同模式 + +无论哪个专员,都应该遵循以下统一模式: + +``` +对话启动 → 系统分析 → 结构化展示候选 → 用户选择/修改 + → 系统执行 → 结构化展示结果 → 用户确认/修改 + → 继续或结束 +``` + +关键原则: + +1. **启动用对话**(低门槛进入) +2. **候选用卡片**(多选项并列展示) +3. **决策用表格**(排序/对比/分级) +4. **微调用对话**(局部修改不需要回到表格) +5. **确认用按钮**(明确用户确认动作) +6. **状态永远可见**(工作流面板/进度条) + +--- + +## 6. 一句话总结 + +**所有专员的交互形式可以归结为一句话:** + +> 对话负责"说清楚要什么",结构化 UI 负责"从系统中选出来",表格和卡片负责"看清楚选对了没有"。 + +这三层缺一不可,也不能互相替代。 + +--- + +## 8. 参考来源 + +1. GW01_全球主流软件工作台历史演进.md +2. GW02_对话式AI工作台与传统UI分界判断.md +3. SAP PRESS, SAP Fiori Overview: UX, App Types, and Deployment Models, https://learning.sap-press.com/sap-fiori-overview(Fiori 的 1-1-3 规则) +4. GitHub Blog, Vibe coding with GitHub Copilot: Agent mode and MCP support, https://github.blog/news-insights/product-news/github-copilot-agent-mode-activated/ +5. 各专员详细定义:`docs/05_Object_Catalog/specialists/` diff --git a/docs/20_Global_Workbench_Evolution/GW04_公众号创作专员可用性改进分析.md b/docs/20_Global_Workbench_Evolution/GW04_公众号创作专员可用性改进分析.md new file mode 100644 index 0000000..5cbf333 --- /dev/null +++ b/docs/20_Global_Workbench_Evolution/GW04_公众号创作专员可用性改进分析.md @@ -0,0 +1,519 @@ +# GW04_公众号创作专员可用性改进分析 + +> 状态:专题分析 +> 最后更新:2026-09-23 +> 关联文档: +> - `GW02_对话式AI工作台与传统UI分界判断.md` +> - `GW03_专员工作交互形式分析.md` +> - `docs/05_Object_Catalog/specialists/wechat-official-account/`(对象目录) + +--- + +## 1. 核心结论 + +**公众号创作专员的后端已经 100% 实现,前端只有骨架没有血肉。** + +后端有完整的工作流引擎(9步)、热点抓取(6个业务域、多数据源)、AI集成(选题/标题/提纲/正文生成)、图片生成、预览导出、DB持久化、API路由全部到位。前端只有 manifest 定义和 API 封装函数,没有任何组件、页面、路由或交互。 + +要让它"真正可用",需要补充的是**整个前端工作流 UI 层**——这是唯一缺失的部分。 + +--- + +## 2. 现状全貌 + +### 2.1 已实现(后端 100%) + +| 层级 | 组件 | 文件数 | 行数 | +|------|------|--------|------| +| Manifest + 岗位说明书 | 专员定义、技能绑定、边界规则 | 1 | 30 | +| 工作流引擎 | 9步工作流、步骤执行器、错误处理 | 1 | 1623 | +| 文章服务 | 文章状态CRUD、选题候选构建 | 1 | 357 | +| 热点服务 | RSS/Web抓取、评分、缓存、降级 | 1 | 507 | +| 交付/导出 | 配图、预览、分页、3种导出格式 | 1 | 619 | +| 图片服务 | base64持久化、静态服务 | 1 | 176 | +| 业务域配置 | 6个业务域、热点源、关键词 | 1 | 239 | +| 图片生成 | media 持久化、静态路由 | 1 | 176 | +| Runtime 辅助 | 常量、压缩、底部面板 | 1 | 58 | +| 数据模型 | 文章表(26字段)、热点表(14字段) | 2 | 61 | +| DAO | 文章DAO、热点DAO、包级注册 | 3 | 133 | +| Router 注册 | 7个API端点 | 1 | 7 | +| DB 迁移 | 两张表 AutoMigrate | 1 | 2 | +| Seed 数据 | 专员种子数据 | 1 | 26 | +| Task Runtime 集成 | 产物压缩、运行记录压缩 | 1 | 2 | +| Core 注册 | builtinRegistry 注册为第一专员 | 1 | 6 | +| 测试 | 专员 prompt 测试 | 1 | 6 | + +**后端总计约 4300+ 行有效代码,覆盖完整。** + +### 2.2 已实现(前端骨架) + +| 层级 | 组件 | 说明 | +|------|------|------| +| Manifest | manifest.js (26行) | 专员定义 + InteractionCard | +| API 封装 | officialAccount.js (33行) | 6个API函数 | +| 内置注册 | builtin.js 注册 | 作为第4个内置专员注册 | +| 项目模板 | content-operations 绑定 | 绑定公众号创作专员 | + +### 2.3 完全缺失(前端) + +| 缺失项 | 级别 | 说明 | +|--------|------|------| +| **专属路由页面** | 必须 | `/apps/wechat-official-account` 无对应前端页面 | +| **工作流配置页** | 必须 | 创建任务时输入业务域、关键词、受众、目标、语气等 | +| **工作流步骤面板** | 必须 | 选题候选卡片选择、标题选择、提纲编辑、正文编辑 | +| **配图管理 UI** | 必须 | 配图提示词查看、图片预览、图片重生成 | +| **预览/导出 UI** | 必须 | Markdown/HTML 预览、导出按钮 | +| **底部面板** | 可选 | timeline / replay / trace / logs 展示 | +| **状态指示器** | 可选 | 当前步骤进度、完成步骤标记 | +| **错误处理 UI** | 可选 | 步骤执行失败的错误展示 | + +--- + +## 3. 公众号创作专员完整工作流(后端已有,前端未实现) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Step 0: 创建任务(前端缺失) │ +│ 输入:业务域、关键词、受众、目标、语气、正文字数、提纲字数、配图数 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 1: 推荐热点选题(后端完整,前端缺失) │ +│ 1) 从热点源抓取(RSS 36kr/IT之家等 + 网页源 国家医保局等) │ +│ 2) 评分过滤(域名词 + 业务词 + 加分词) │ +│ 3) AI 增强生成 3-5 个选题候选 │ +│ 4) 每个选题含:标题、角度、推荐理由、热度等级(爆热/高/中高/中) │ +│ 前端需做:展示候选卡片 → 用户选一个 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 2: 标题生成(后端完整,前端缺失) │ +│ 1) 基于选定选题,AI 生成 3 个标题 │ +│ 2) 兜底模板生成(无AI也能工作) │ +│ 前端需做:展示候选卡片 → 用户选一个 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 3: 提纲生成(后端完整,前端缺失) │ +│ 1) 基于选定标题,AI 生成结构化学术型/行业观察型提纲 │ +│ 2) 兜底模板生成 │ +│ 前端需做:展示提纲 → 用户可编辑/修改 → 确认 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 4: 正文创作(后端完整,前端缺失) │ +│ 1) 基于提纲,AI 生成长文(1400字默认) │ +│ 2) 口语化但不油腻,观点有依据 │ +│ 3) 兜底模板生成 │ +│ 前端需做:展示正文 → 用户可编辑/修改 → 确认 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 5: 配图提示词(后端完整,前端缺失) │ +│ 1) 从正文自动提取配图位置(封面图 + 每个章节配图) │ +│ 2) 为每个配图生成 AI 提示词 │ +│ 前端需做:展示提示词列表 → 用户可编辑/删除 → 确认 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 6: 图片生成(后端完整,前端缺失) │ +│ 1) 调用 image_gen 路由的图片生成 API │ +│ 2) 每个配图生成后持久化 base64 到文件系统 │ +│ 3) 返回图片 URL │ +│ 前端需做:展示生成结果 → 用户可预览 → 对某张图点"重生成" │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 7: 预览与导出(后端完整,前端缺失) │ +│ 1) 生成 Markdown 预览稿(标题 + 正文 + 配图) │ +│ 2) 生成 HTML 预览稿(简易排版) │ +│ 3) 生成分页 Markdown(每章节一页,带 `` 分隔)│ +│ 前端需做:展示预览 → 提供3种导出格式按钮 │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 8: 分页 MD(后端完整,前端缺失) │ +│ 1) 解析分页内容,生成结构化分页数据 │ +│ 2) 每页含:页码、页标题、页面内容 │ +│ 前端需做:展示分页数据(可选,已完成的元数据步骤) │ +│ ─────────────────────────────────────────────────────────────── │ +│ Step 9: 完成 │ +│ 工作流结束,文章状态标记为完成 │ +│ 前端需做:展示完成状态、可导出 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. 改进方案:从"可用"到"好用" + +### 4.1 第一层:可用(核心工作流跑通) + +**目标:用户能完整走完一遍工作流,产出可发布的 Markdown 预览稿。** + +需要实现的前端页面结构: + +``` +/apps/wechat-official-account +├── 创建页:输入业务域、关键词、受众、目标、语气、字数要求 +├── 工作流页(主页面) +│ ├── 左侧:步骤导航栏(9步进度条) +│ ├── 中间:当前步骤的交互区 +│ └── 右侧:预览面板(最终稿预览) +│ +│ Step 1 交互:热点选题候选卡片 → 选一个 +│ Step 2 交互:标题候选卡片 → 选一个 +│ Step 3 交互:提纲文本编辑 → 确认 +│ Step 4 交互:正文文本编辑 → 确认 +│ Step 5 交互:配图提示词列表 → 编辑/删除 → 确认 +│ Step 6 交互:图片预览网格 → 预览/重生成 +│ Step 7 交互:Markdown/HTML 预览 + 导出按钮 +│ Step 8 交互:分页数据展示(只读) +│ Step 9 交互:完成状态 +└── 导出下载功能 +``` + +**关键 UI 组件:** + +1. **创建表单组件** — 输入业务域、关键词、受众、目标、语气、正文字数、提纲风格、最大配图数 +2. **步骤导航组件** — 9步进度条,显示每步状态(pending/running/completed/error) +3. **选题候选卡片组件** — 每个选题一张卡片,含标题、角度、热度标签、推荐理由,支持点击选择 +4. **标题候选卡片组件** — 3个标题卡片,点击选择 +5. **文本编辑器组件** — 提纲和正文的编辑区,支持 Markdown 编辑 +6. **配图管理组件** — 提示词列表 + 图片预览网格 + 重生成按钮 +7. **预览面板组件** — Markdown/HTML 双栏预览 +8. **导出按钮组** — 3个导出格式按钮 + +### 4.2 第二层:好用(符合 GW02 的交互原则) + +基于 GW02 的分析,公众号创作专员的交互应该遵循: + +> **对话发起 + 结构化决策接管 + 工作流可见性** + +具体改进: + +**a) 创建任务时,对话式启动** + +用户不说"我要创建一个公众号创作专员任务,业务域是AI,关键词是智能体",而是直接说: + +> "帮我围绕智能体趋势写一篇文章" + +系统自动识别业务域 = AI,关键词 = 智能体,弹出表单确认: + +``` +确认以下设置: +业务域:AI / 智能体(自动识别) +关键词:智能体 +受众:公众号读者(默认,可改) +目标:输出一篇可发布的公众号文章(默认,可改) +语气:专业但好懂(默认,可改) +正文字数:1400字(默认,可改) + +[确认] [修改] +``` + +**b) 选题决策用卡片,不是列表** + +GW02 的核心判断:候选项在消息流中容易被淹没。 + +``` +┌─────────────────────────────────────────────────────┐ +│ 已抓取 42 条热点,AI 帮你选出了 3 个选题 │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 🔥 爆热 │ AI Agent 正在替代客服:真的还是炒作? │ │ +│ │ │ 角度:热点追踪 · 推荐理由:今日有3家大厂│ │ +│ │ │ 宣布客服转型 Agent │ │ +│ │ │ 热度:████████░░ 12分 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ [ 选择这个 ] [ 再看 ] │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ ⚡ 高 │ 中小企业为什么不敢做 AI Agent 转型? │ │ +│ │ │ 角度:产业动向 · 推荐理由:痛点明确, │ │ +│ │ │ 读者共鸣度高 │ │ +│ │ │ 热度:███████░░░ 8分 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ [ 选择这个 ] [ 再看 ] │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ 🔸 中 │ Agent 开发工具链盘点:谁在跑在前面? │ │ +│ │ │ 角度:技术盘点 · 推荐理由:工具盘点类 │ │ +│ │ │ 内容长期引流效果好 │ │ +│ │ │ 热度:████░░░░░░ 5分 │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +│ [ 都不要,我自己写一个 ] [ 刷新热点 ] │ +└─────────────────────────────────────────────────────┘ +``` + +**c) 正文编辑允许对话微调** + +用户在编辑区改完后,可以在侧边对话中说: + +> "第三段太技术了,换成更通俗的说法" +> "加一段关于 Agent 安全风险的讨论" +> "结尾要更有力,给一个行动号召" + +**d) 底部面板始终可见工作流状态** + +``` +┌─────────────────────────────────────────────┐ +│ 时间线:热点抓取完成(09:20) → 选题确认(09:25) │ +│ 回放节点:1 2 3 4 5 6 7 8 │ +│ 追踪:plugin.hotspot.fetch() → plugin.ai.topic() │ +│ 日志:[INFO] 抓取42条热点 → [INFO] 过滤6条 │ +└─────────────────────────────────────────────┘ +``` + +### 4.3 第三层:好用(差异化体验) + +**a) 业务域模板** + +每个业务域(AI/医疗/保险/HR/法务/通用)提供预设模板: + +| 业务域 | 推荐受众 | 推荐语气 | 推荐字数 | 推荐提纲风格 | +|--------|----------|----------|----------|-------------| +| AI/智能体 | 技术从业者 | 专业但好懂 | 1400 | 问题拆解型 | +| 医疗/健康 | 普通用户 | 通俗温暖 | 1200 | 场景故事型 | +| 保险/金融 | 投资者/消费者 | 客观严谨 | 1600 | 数据对比型 | +| HR/招聘 | HR从业者 | 实用专业 | 1300 | 方法流程型 | +| 法务/合规 | 法务从业者 | 严谨准确 | 1500 | 条款分析型 | +| 通用 | 公众号读者 | 专业但好懂 | 1400 | 问题拆解型 | + +**b) 热点趋势趋势面板** + +展示该业务域最近7天的热点趋势图: + +``` +热点趋势(近7天) +[████████] 周一 32条 +[███████░] 周二 28条 +[█████░░░] 周三 21条 +[████████] 周四 35条 ← 今天 +[█████░░░] 周五 22条 +``` + +**c) 选题热度预测** + +AI 不仅给热度标签(爆热/高/中高/中),还给出预估阅读量范围: + +``` +选题1:AI Agent 正在替代客服 + 热度:爆热 · 预估阅读:5000-15000 +选题2:中小企业 AI 转型 + 热度:高 · 预估阅读:3000-8000 +``` + +**d) 多版本草稿对比** + +支持并行生成多版本: + +``` +版本 A(选定):智能体替代客服 +版本 B(草稿):中小企业 AI 转型 +版本 C(草稿):Agent 工具链盘点 + +[ A ] [ B ] [ C ] [+ 新增版本] +``` + +--- + +## 5. 已知 Bug + +### 5.1 `isOfficialAccountStep` 缺少 `page_markdown` case + +后端工作流引擎的 `isOfficialAccountStep` 函数(约第 919-924 行)的 switch 语句中缺少对 `officialAccountStepKeyPageMD` 的检查,而 `officialAccountNextStep` 函数(约第 950-968 行)会将其作为 `preview_export` 的下一步。这意味着执行分页 MD 步骤时会返回 "工作流步骤不存在" 错误。 + +**修复方法:** 在 `isOfficialAccountStep` 的 switch 中添加: + +```go +case officialAccountStepKeyPageMD: + return true +``` + +--- + +## 6. 前端实现方案 + +### 6.1 目录结构 + +``` +frontend/src/specialists/packages/wechat-official-account/ +├── manifest.js ← 已存在,不需改 +├── views/ +│ ├── OfficialAccountCreatePage.vue ← 创建任务页 +│ ├── OfficialAccountWorkbenchPage.vue ← 工作流主页面(核心) +│ └── OfficialAccountPreviewPage.vue ← 预览页(可选,可嵌入 Workbench) +├── components/ +│ ├── WorkflowStepNav.vue ← 步骤导航(9步进度条) +│ ├── TopicCandidateCard.vue ← 选题候选卡片 +│ ├── TitleCandidateCard.vue ← 标题候选卡片 +│ ├── OutlineEditor.vue ← 提纲编辑器 +│ ├── ContentEditor.vue ← 正文编辑器 +│ ├── ImagePromptList.vue ← 配图提示词管理 +│ ├── ImageGallery.vue ← 图片预览网格 +│ ├── PreviewPanel.vue ← 预览面板 +│ └── BottomPanel.vue ← 底部面板 +├── composables/ +│ ├── useOfficialAccountWorkflow.js ← 工作流状态管理 +│ └── useOfficialAccountExport.js ← 导出逻辑 +└── api/ + └── officialAccount.js ← 已存在,不需改 +``` + +### 6.2 核心交互流程伪代码 + +```javascript +// OfficialAccountWorkbenchPage.vue + +``` + +### 6.3 创建任务流程 + +```javascript +// 1. 用户从专员市场或通用助手点击"公众号创作专员" +// 2. 跳转到 /apps/wechat-official-account +// 3. 展示创建表单(或对话式启动) +// 4. 填写表单 → 创建任务 → 跳转到工作流页 + +// 表单提交 +const task = await createOfficialAccountTask({ + business_domain: 'ai', + keyword: '智能体', + audience: '技术从业者', + goal: '输出一篇可发布的公众号文章', + tone: '专业但好懂', + content_target_words: 1400, + max_content_images: 3, +}) + +// 5. 自动执行 Step 1(热点抓取 + 选题生成) +await fetchWorkflow(task.id) +// 返回的 workflow.steps['topic_recommendation'].status = 'pending' +// workflow.shared.topicCandidates = [候选1, 候选2, 候选3] +``` + +### 6.4 步骤执行流程 + +```javascript +// 用户选择选题后,自动进入下一步 +async function handleSelectTopic(topic) { + // 保存选题 + await updateOfficialAccountTask(taskId, { selected_topic: topic }) + // 执行下一步:标题生成 + await executeStep(taskId, 'title_generation', { topic }) + // 刷新工作流 + await fetchWorkflow(taskId) +} + +// 每个步骤的交互模式: +// - 有候选项:展示卡片 → 用户选一个 → 自动进入下一步 +// - 有文本内容:展示编辑器 → 用户编辑 → 点击确认 → 自动进入下一步 +// - 有图片:展示预览 → 用户查看 → 可重生成 → 自动进入下一步 +// - 最后一步:展示预览 + 导出按钮 → 完成 +``` + +--- + +## 7. 工作量评估(按优先级) + +### P0 — 核心可用(必须做) + +| 组件 | 预估工作量 | 说明 | +|------|-----------|------| +| 创建表单 | 1天 | 业务域、关键词、受众、目标、语气、字数 | +| 路由配置 | 0.5天 | `/apps/wechat-official-account` 路由 | +| 步骤导航 | 1天 | 9步进度条,状态指示 | +| 选题卡片 | 1.5天 | 候选展示、选择、刷新热点 | +| 标题卡片 | 0.5天 | 3个标题候选、选择 | +| 提纲编辑器 | 1天 | Markdown 编辑器、确认 | +| 正文编辑器 | 1天 | Markdown 编辑器、确认 | +| 配图提示词 | 1天 | 提示词列表、编辑、确认 | +| 图片预览 | 1天 | 图片网格、预览、重生成 | +| 预览面板 | 1天 | Markdown/HTML 双栏预览 | +| 导出功能 | 0.5天 | 3种格式下载 | +| **合计** | **~10天** | 一个前端开发可以完成 | + +### P1 — 提升体验(建议做) + +| 组件 | 预估工作量 | 说明 | +|------|-----------|------| +| 对话式启动 | 1.5天 | 自然语言解析业务域/关键词 | +| 底部面板 | 1天 | timeline / replay / trace / logs | +| 对话微调 | 1天 | 编辑区侧边对话、局部修改 | +| 业务域模板 | 0.5天 | 预设模板选择 | +| **合计** | **~4天** | 在 P0 基础上 | + +### P2 — 差异化功能(可选) + +| 组件 | 预估工作量 | 说明 | +|------|-----------|------| +| 热点趋势图 | 1天 | 折线图展示热点趋势 | +| 选题热度预测 | 1天 | 预估阅读量范围 | +| 多版本草稿 | 2天 | 并行生成、对比切换 | +| **合计** | **~4天** | 在 P1 基础上 | + +--- + +## 8. 总结 + +公众号创作专员是平台中**后端实现最完整**的专员——从工作流引擎、热点抓取、AI生成、图片生成、预览导出到DB持久化全部到位。唯一缺失的是前端 UI 层。 + +要让它"真正可用",核心工作就是做一个完整的工作流页面。这个页面的设计原则应该遵循 GW02 的结论: + +1. **启动用对话/表单** — 用户输入目标,系统自动补全结构 +2. **选择用卡片** — 热点选题、标题都是多候选场景,卡片并列展示 +3. **编辑用文本区** — 提纲、正文允许用户自由修改 +4. **预览用双栏** — Markdown 编辑 + HTML 实时预览 +5. **状态永远可见** — 步骤导航 + 底部面板 + +预计 **10天核心实现 + 4天体验优化 + 4天差异化功能**,一个有经验的 Vue 前端工程师可以独立完成。 diff --git a/docs/20_Global_Workbench_Evolution/GW05_公众号创作专员与对话框交互兼容分析.md b/docs/20_Global_Workbench_Evolution/GW05_公众号创作专员与对话框交互兼容分析.md new file mode 100644 index 0000000..54adba9 --- /dev/null +++ b/docs/20_Global_Workbench_Evolution/GW05_公众号创作专员与对话框交互兼容分析.md @@ -0,0 +1,485 @@ +# GW05_公众号创作专员与对话框交互兼容分析 + +> 状态:专题分析 +> 最后更新:2026-09-23 +> 关联文档: +> - `GW02_对话式AI工作台与传统UI分界判断.md` +> - `GW04_公众号创作专员可用性改进分析.md` + +--- + +## 1. 问题定义 + +公众号创作专员的工作流 UI(选题卡片、标题选择、正文编辑、配图管理、预览导出)如何与当前的**对话框布局**兼容? + +当前架构的核心矛盾: + +- **对话框是主交互面**:用户通过 SmartAssistantPage 的 ChatLayout 发起一切 +- **工作流是右栏被动面板**:SpecialistPanel 只展示已完成的步骤,不提供交互式步骤执行 +- **公众号创作专员需要完整的前端交互**:选题选择、标题选择、文本编辑、配图管理等操作需要主动输入 + +所以问题不是"怎么加一个页面",而是:**这个工作流交互应该长在对话框上,还是长在对话框旁边?** + +--- + +## 2. 当前架构分析 + +### 2.1 SmartAssistantPage 布局 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ ChatLayout(全屏对话布局) │ +│ ┌──────────┬──────────────────────────────────────────────────────┐│ +│ │ 左侧 │ 中间区域 ││ +│ │ 任务列表 │ ┌────────────────────────────────────────────────┐ ││ +│ │ │ │ 消息流 │ ││ +│ │ 任务A │ │ 用户:围绕AI趋势写一篇公众号文章 │ ││ +│ │ 任务B │ │ 助手:好的,我帮你起草 │ ││ +│ │ 任务C │ │ · 热点抓取 → 选题推荐 → 标题生成 │ ││ +│ │ │ │ · 3 个选题候选 │ ││ +│ │ │ │ 🔥 选题1:AI Agent 正在替代客服 · 爆热 │ ││ +│ │ │ │ ⚡ 选题2:中小企业 AI 转型 · 高 │ ││ +│ │ │ │ 🔸 选题3:Agent 工具链盘点 · 中 │ ││ +│ │ │ │ 请选择一个选题 │ ││ +│ │ │ ├────────────────────────────────────────────────┤ ││ +│ │ 右侧面板 │ │ 输入框 + 技能芯片 + 发送按钮 │ ││ +│ │ │ └────────────────────────────────────────────────┘ ││ +│ │ SpecialistPanel │ ││ +│ │ ┌──────────────────────────┐ │ ││ +│ │ │ 工作流 上传物与产物 │ │ ││ +│ │ ├──────────────────────────┤ │ ││ +│ │ │ 1. ✓ 更新任务配置 │ │ ││ +│ │ │ 2. ✓ 推荐热点选题 │ │ ││ +│ │ │ 3. 4. 标题/提纲生成 │ │ ││ +│ │ │ 5. 正文创作 │ │ ││ +│ │ │ 6. 7. 配图生成 │ │ ││ +│ │ │ 8. 预览导出 │ │ ││ +│ │ │ 9. 分页 MD │ │ ││ +│ │ └──────────────────────────┘ │ ││ +│ └──────────┴──────────────────────────────────────────────────────┘│ +└────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 SpecialistPanel 的现状 + +SpecialistPanel 是一个**只读面板**,它的职责是: + +1. 从 `action_records_json` 解析已执行的步骤(后端工作流引擎每执行一步就写入此字段) +2. 从 `task_artifact` 读取步骤产物 +3. 展示步骤进度(`doneCount / steps.length`) +4. 点击产物卡片跳到产物 tab + +**它不做什么:** + +- 不提供步骤执行按钮(执行由后端自动推进) +- 不提供交互选择(选题选择、标题选择等) +- 不提供文本编辑(提纲编辑、正文编辑) +- 不提供配图管理 + +### 2.3 当前工作流推进方式 + +当前工作流(包括公众号创作专员)的推进方式是: + +1. 用户在对话框输入"帮我写一篇公众号文章" +2. 后端解析出要创建公众号创作专员任务 +3. 后端自动执行所有工作流步骤(自动抓取热点、自动选题、自动标题、自动正文……) +4. 每一步完成后在对话消息中展示产出 +5. 用户被动阅读 + +**没有交互选择的余地。** 用户在对话框里只能看、不能选。 + +### 2.4 现有交互模式的例外 + +目前唯一的"结构化选择"发生在**技能选择**时: + +- 用户在技能面板选择一个技能(如"公文辅助写作") +- 技能面板展示技能的输入表单 +- 用户填写后点击"执行" + +但这种交互发生在**任务创建之前**(选择要用的技能),而不是任务执行中间。 + +--- + +## 3. 公众号创作专员需要什么样的交互? + +根据 GW02 的分析,公众号创作专员有多个**多候选决策点**: + +| 步骤 | 决策类型 | 候选数 | 对话适合度 | +|------|---------|--------|-----------| +| 选题推荐 | 候选选择 | 3-5 个 | 差:消息流中候选容易淹没 | +| 标题选择 | 候选选择 | 3 个 | 差:同上 | +| 提纲编辑 | 文本修改 | 一个 | 中:对话可以说"改第三段"但不够精确 | +| 正文编辑 | 文本修改 | 一个 | 中:同上 | +| 配图提示词 | 编辑管理 | 3-5 个 | 差:需要看到所有提示词的完整内容 | +| 图片生成 | 预览选择 | 3-5 张 | 中:对话里能看缩略图但不够清晰 | +| 预览导出 | 查看导出 | 一个 | 好:直接下载即可 | + +**结论:公众号创作专员的工作流中有 4 个决策点(选题、标题、提纲、配图)不适合纯对话交互,需要结构化 UI 接管。** + +--- + +## 4. 兼容方案 + +### 方案 A:扩展 SpecialistPanel(推荐) + +**思路:** 不新增页面,在右侧 SpecialistPanel 中嵌入交互式步骤组件。 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ 对话区域(不变) │ +│ ┌──────────┬──────────────────────────────────────────────────────┐│ +│ │ 左侧 │ 中间区域 ││ +│ │ 任务列表 │ 消息流(同上) ││ +│ └──────────┴──────────────────────────────────────────────────────┘│ +│ ┌─────────────────────────────────────────────────────────────────┐│ +│ │ 右侧面板(扩展为交互式) ││ +│ │ ┌──────────────────────────────────────────────────────────┐ ││ +│ │ │ 工作流 产物 预览 │ 公众号创作专员 ││ ││ +│ │ ├──────────────────────────────────────────────────────────┤ ││ +│ │ │ [1] ✓ 热点抓取 │ ││ +│ │ │ [2] ✓ 选题推荐 │ ││ +│ │ │ [3] ⚡ 标题生成(进行中) │ ││ +│ │ │ [4] ○ 提纲生成 │ ││ +│ │ │ [5] ○ 正文创作 │ ││ +│ │ │ [6] ○ 配图生成 │ ││ +│ │ │ [7] ○ 预览导出 │ ││ +│ │ ├──────────────────────────────────────────────────────────┤ ││ +│ │ │ │ ││ +│ │ │ 当前步骤:标题生成 │ ││ +│ │ │ ┌────────────────────────────────────────────────────┐ │ ││ +│ │ │ │ 📰 基于选题:"AI Agent 正在替代客服" │ │ ││ +│ │ │ │ │ │ ││ +│ │ │ │ ① 客服革命还是噱头? │ │ ││ +│ │ │ │ 热度:爆热 · 点击选择 │ │ ││ +│ │ │ │ │ │ ││ +│ │ │ │ ② 中小企业不敢做 AI 客服的三个原因 │ │ ││ +│ │ │ │ 热度:高 · 点击选择 │ │ ││ +│ │ │ │ │ │ ││ +│ │ │ │ ③ 2026 客服 Agent 行业盘点 │ │ ││ +│ │ │ │ 热度:中 · 点击选择 │ │ ││ +│ │ │ │ │ │ ││ +│ │ │ │ [ 重新生成 ] [ 我不要这些,换个热点 ] │ │ ││ +│ │ │ └────────────────────────────────────────────────────┘ │ ││ +│ │ │ │ ││ +│ │ │ 底部面板: │ ││ +│ │ │ 时间线 / 回放 / 追踪 / 日志 │ ││ +│ │ │ │ ││ +│ │ └──────────────────────────────────────────────────────────┘ ││ +│ └─────────────────────────────────────────────────────────────────┘│ +└────────────────────────────────────────────────────────────────────┘ +``` + +**优点:** +- 不破坏现有的对话架构 +- 与现有 SpecialistPanel 的只读模式自然融合——新增"交互模式" +- 用户始终在同一页面内操作,不需要跳转 +- 对话框和交互面板并行展示,信息不冲突 +- 最小改动,复用现有的 SpecialistPanel 组件结构 +- 对话消息和工作流步骤的状态同步已有基础(通过 `taskRuntime.loadTaskDetail`) + +**缺点:** +- 面板宽度有限,复杂的编辑区可能放不下 +- 需要大幅扩展 SpecialistPanel 的渲染逻辑 + +**实施建议:** +- SpecialistPanel 根据 `currentStep` 动态渲染不同组件 +- 对话消息中展示工作流摘要 +- 面板中展示完整交互 +- 两个区域的同步通过 `fetchOfficialAccountWorkflow` API + 定时刷新 + +--- + +### 方案 B:独立工作台页面 + +**思路:** 用户从对话框点击"用公众号创作专员处理"后,跳转到 `/apps/wechat-official-account` 独立页面。 + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ 独立工作页面 │ +│ ┌─────────────────────────────────────────────────────────────────┐│ +│ │ 公众号创作专员工作台 ││ +│ │ ┌──────────┬──────────────────────────────────────────────────┐││ +│ │ │ 步骤导航 │ 主交互区 │││ +│ │ │ 1. ✓ │ 选题候选卡片(完整版) │││ +│ │ │ 2. ✓ │ [ 选择这个 ] [ 刷新热点 ] [ 换选题 ] │││ +│ │ │ 3. ⚡ │ │││ +│ │ │ 4. ○ │ ┌────────────────────────────────────────────┐ │││ +│ │ │ 5. ○ │ │ 编辑区:基于选题写提纲 │ │││ +│ │ │ 6. ○ │ │ · 第一段:AI 客服的现状 │ │││ +│ │ │ 7. ○ │ │ · 第二段:中小企业的顾虑 │ │││ +│ │ │ 8. ○ │ │ · 第三段:未来趋势 │ │││ +│ │ │ │ │ │ │ │ · 第四段:行业预测 │ │││ +│ │ │ │ │ │ │ └────────────────────────────────────────────┘ │││ +│ │ │ │ │ │ │ [ 保存草稿 ] [ 确认 ] [ 重新生成 ] │││ +│ │ │ │ │ │ │ │││ +│ │ │ └──────────┴──────────────────────────────────────────────────┘││ +│ │ │ ┌──────────────────────────────────────────────────────────┐ │││ +│ │ │ │ 底部面板:时间线 / 回放 / 追踪 / 日志 │ │││ +│ │ │ └──────────────────────────────────────────────────────────┘ │││ +│ └─────────────────────────────────────────────────────────────────┘│ +└────────────────────────────────────────────────────────────────────┘ +``` + +**优点:** +- 工作流交互可以完整展示,不受面板宽度限制 +- 适合复杂编辑场景(正文编辑、配图管理) +- 与"工作流页面"的概念更匹配 + +**缺点:** +- 用户从对话页面跳转到工作台页面,上下文不连续 +- 对话消息中的上下文(上传的文件、上传的图片、前文)需要重新获取 +- 用户可能不知道工作台页面的存在(对话框是唯一的入口心智) +- 与现有"对话即工作台"的产品定位冲突 + +--- + +### 方案 C:对话内嵌卡片 + 侧栏扩展(混合模式) + +**思路:** 结合方案 A 和 B 的优点。 + +**场景 1:简单决策(选题、标题)** + +在对话消息中直接展示卡片组件,用户直接在对话消息里选择: + +``` +助手:已生成 3 个选题候选,请选择: + +┌────────────────────────────────────────┐ +│ 🔥 爆热 │ AI Agent 正在替代客服 │ +│ │ 角度:热点追踪 │ +│ │ 热度:████████░░ 12分 │ +│ │ [ 选择这个 ] [ 不看 ] │ +└────────────────────────────────────────┘ +┌────────────────────────────────────────┐ +│ ⚡ 高 │ 中小企业 AI 转型的三个原因 │ +│ │ 角度:产业动向 │ +│ │ 热度:███████░░░ 8分 │ +│ │ [ 选择这个 ] [ 不看 ] │ +└────────────────────────────────────────┘ +``` + +选择后自动执行下一步,新的卡片出现在对话流中。 + +**场景 2:复杂编辑(正文、配图)** + +当工作流进入正文创作阶段,在 SpecialistPanel 中自动切换到"编辑模式": + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 右侧面板自动切换为编辑模式 ││ +│ ┌──────────────────────────────────────────────────────────┐ ││ +│ │ 编辑区(正文创作) │ ││ +│ │ ┌────────────────────────────────────────────────────┐ │ ││ +│ │ │ # AI 客服的现状 │ │ ││ +│ │ │ │ │ ││ +│ │ │ 2026 年,越来越多的企业开始尝试用 AI 替代人工客服 │ │ ││ +│ │ │ ... │ │ ││ +│ │ │ │ │ ││ +│ │ │ [ 粗体 ] [ 斜体 ] [ 链接 ] [ 插入图片 ] [ Markdown ]│ │ ││ +│ │ └────────────────────────────────────────────────────┘ │ ││ +│ │ [ 保存草稿 ] [ 确认并继续 ] [ 对话微调 ] │ ││ +│ │ │ ││ +│ │ 对话微调面板(可选展开) │ ││ +│ │ ┌────────────────────────────────────────────────────┐ │ ││ +│ │ │ 输入修改建议:第三段太技术了,换成更通俗的说法 │ │ ││ +│ │ │ [ 执行 ] │ │ ││ +│ │ └────────────────────────────────────────────────────┘ │ ││ +│ └──────────────────────────────────────────────────────────┘ ││ +└─────────────────────────────────────────────────────────────────┘ +``` + +**优点:** +- 简单决策在对话中直接完成,不打断流程 +- 复杂编辑在工作台区域完成,有足够空间 +- 对话和工作台并行,上下文不断开 +- 符合 GW02 的"对话发起 + 结构化决策接管"原则 + +**缺点:** +- 实现复杂度最高 +- 需要设计对话消息中卡片组件与面板编辑组件的切换逻辑 +- 需要在 SpecialistPanel 中嵌入多种不同的组件 + +--- + +## 5. 推荐方案与演进路径 + +### 阶段 1:方案 A 为基础(快速可用) + +**目标:10 天内让公众号创作专员核心工作流可用。** + +实现方式: +1. 在 SpecialistPanel 中新增"交互渲染区" +2. 根据 `currentStep` 动态渲染对应组件 +3. 简单步骤(选题、标题)在小面板内展示卡片 +4. 复杂步骤(正文编辑)在面板内展示精简编辑器 +5. 预览导出直接展示结果 + +这是最省力的路径,与现有代码改动最小。 + +### 阶段 2:对话消息内嵌卡片(提升体验) + +**目标:让用户在对话框中就能看到工作流产出,不切到面板。** + +实现方式: +1. 工作流引擎每完成一个步骤,自动在对话消息中展示结果卡片 +2. 选题卡片直接渲染在对话消息中(`msg.skillResultCard` 机制复用) +3. 用户在对话消息中直接点击选择 +4. 选择后自动触发下一步,新的卡片出现在消息流中 + +这需要: +- 工作流引擎在完成每一步时自动向对话消息推送结果 +- 对话消息支持可交互卡片(不只是展示) + +### 阶段 3:完整独立工作台(可选) + +当公众号创作专员的使用量增长后,如果方案 A/B 的空间不够用(比如需要并排查看热点趋势图、选题对比、多版本草稿),再考虑完整独立工作台页面。 + +这个页面应该是"对话 + 工作台"的双栏布局: +- 左侧:对话流(作为上下文入口和交互补充) +- 右侧:工作流面板(完整编辑区) +- 底部:状态栏(时间线、回放、追踪) + +--- + +## 6. 关键设计原则 + +综合 GW02 的结论,公众号创作专员的交互设计必须遵循: + +### 6.1 对话不代替 UI,对话引导 UI + +对话的作用是: +- 告诉系统"要做什么"("围绕 AI 趋势写文章") +- 在编辑阶段进行局部修改("第三段太技术") + +对话**不应该**用来: +- 展示所有选题候选让用户体验 +- 逐个询问标题选择 +- 展示配图效果 + +### 6.2 结构化决策必须有 UI 空间 + +所有需要**比较、选择、编辑**的操作必须在一个稳定的 UI 区域内完成: +- 选题卡片:并排展示,一目了然 +- 标题选择:三个并排,点击即可 +- 正文编辑:完整的文本编辑区 +- 配图管理:网格布局,点击放大 + +### 6.3 可见性层始终在线 + +用户需要始终知道: +- 当前走到哪一步了(步骤导航) +- 已完成什么(步骤列表标记) +- 下一步是什么(自动高亮当前步骤) +- 产出是什么(产物 tab) + +### 6.4 对话与 UI 的同步 + +- 对话消息中展示工作流摘要 +- 工作流 UI 中展示对话上下文 +- 用户在对话中的选择应该实时反映在工作流 UI 中 +- 用户在工作流 UI 中的操作应该在对话中留下记录 + +--- + +## 7. 具体实现建议 + +### 7.1 SpecialistPanel 改造 + +现有 SpecialistPanel 的 `tab` 结构已经可以扩展为"交互模式": + +```vue + +``` + +### 7.2 对话消息中的可交互卡片 + +复用现有的 `msg.skillResultCard` 机制: + +```javascript +// 工作流引擎完成选题步骤后,在对话消息中自动插入可交互卡片 +messages.value.push({ + role: 'assistant', + content: '已生成 3 个选题候选,请选择:', + skillResult: { topicCandidates: [...] }, + skillResultCard: markRaw(TopicCandidateCard), // 可交互的 Vue 组件 + timestamp: new Date().toLocaleTimeString(), +}) +``` + +当用户点击卡片中的"选择这个"时: +1. 调用 `executeOfficialAccountWorkflowStep(stepKey, { topic: selectedTopic })` +2. 工作流自动推进到下一步 +3. 新的结果卡片出现在消息流中 + +### 7.3 数据流 + +``` +用户操作(对话 / 面板) + ↓ +调用专用 API(create / update / executeStep) + ↓ +后端工作流引擎执行 + ↓ +更新 action_records_json + task_artifact + ↓ +前端轮询 / SSE / WebSocket 刷新 + ↓ +更新 SpecialistPanel 状态 + ↓ +如有需要,向对话消息中插入结果卡片 +``` + +--- + +## 8. 总结 + +公众号创作专员与对话框的兼容,核心不在于"有没有独立页面",而在于**如何把结构化决策自然地嵌入对话工作流**。 + +**推荐路径:** +1. 先实现 SpecialistPanel 扩展(方案 A),让工作流可交互 +2. 再实现对话消息中嵌入卡片(方案 C 的部分),让用户不切面板也能操作 +3. 如果确实需要更大编辑空间,再补充完整独立工作台页面 + +**关键原则:** +- 对话引导,UI 执行 +- 决策点用卡片,编辑区用面板 +- 可见性层始终在线 +- 对话与 UI 双向同步 + +这不是"对话还是 UI"的二选一,而是**对话和 UI 各司其职的协作系统**。 diff --git a/docs/20_Global_Workbench_Evolution/目录说明.md b/docs/20_Global_Workbench_Evolution/目录说明.md new file mode 100644 index 0000000..159c1be --- /dev/null +++ b/docs/20_Global_Workbench_Evolution/目录说明.md @@ -0,0 +1,41 @@ +# 20_Global_Workbench_Evolution 目录说明 + +本目录专门讨论全球主流软件工作台形态的历史演进,不承担当前系统架构合同职责,也不直接替代 `02_Architecture` 中的正式架构定义。 + +本目录的作用有三项: + +1. 为 EAI 的交互与工作台设计提供跨时代参照系 +2. 把“对话式 AI 工作台”和传统 GUI、SaaS、协同工作台放在同一条历史线上比较 +3. 为后续 Workbench、专员工作流、技能运行面、xApp 承接面提供外部样本库 + +## 当前文件 + +- `GW01_全球主流软件工作台历史演进.md` + - 总时间线文档 + - 讨论从 CLI / GUI 到 SaaS / 协同工作台,再到 Copilot / Agent Workbench 的连续演进 + +- `GW02_对话式AI工作台与传统UI分界判断.md` + - 专题判断文档 + - 讨论"对话发起 + 结构化接管 + 工作流可见性"为什么会成为新一代 AI 工作台的主交互模式 + +- `GW03_专员工作交互形式分析.md` + - 专题分析文档 + - 对平台中所有专员(通用助手、公众号创作专员、合同审查专员、售前方案专员、客户跟进专员、简历筛选与招聘专员、汇报专员等)逐项分析其启动方式、决策方式、执行方式、反馈方式 + - 给出每个专员"对话 vs 结构化 UI"的比例判断和交互设计建议 + +- `GW04_公众号创作专员可用性改进分析.md` + - 专题分析文档 + - 分析公众号创作专员后端 100% 已实现、前端约 40% 的现状 + - 给出完整的前端工作流 UI 实现方案、交互设计、组件结构和工作量评估 + +- `GW05_公众号创作专员与对话框交互兼容分析.md` + - 专题分析文档 + - 分析公众号创作专员工作流 UI 与当前对话框架构的兼容关系 + - 对比三种方案:扩展 SpecialistPanel、独立工作台页面、对话内嵌卡片 + 侧栏扩展 + - 给出分阶段实施路径:先让 SpecialistPanel 可交互 → 再在对话消息中嵌入卡片 → 可选完整独立页 + +## 使用原则 + +1. 本目录优先记录跨产品、跨代际、跨品类的演进判断 +2. 当前 EAI 的正式架构结论,仍以 `02_Architecture` 中的正式文档为准 +3. 当研究判断足够稳定时,可回写到 `02_Architecture` 形成正式合同 diff --git a/docs/目录说明.md b/docs/目录说明.md index 422ea52..bcc7e69 100644 --- a/docs/目录说明.md +++ b/docs/目录说明.md @@ -15,6 +15,7 @@ - `06_Product_Lines`:商务资料、正式产品路线与历史 PRD - `08_Design_Rules`:设计规则与交互约定 - `09_Research`:研究判断、平台对标、项目比较与外部观察 +- `20_Global_Workbench_Evolution`:全球主流软件工作台形态演进研究,对话式 AI 工作台与传统 UI 分界判断,专员工作交互形式分析 ## 当前组织原则 @@ -31,3 +32,4 @@ - `05_Object_Catalog` 负责系统对象清单、对象详情与对象边界 - `06_Product_Lines` 只保留业务材料和正式产品路线,不再承担正式对象定义职责 - `09_Research` 保留研究判断,不直接承担现行架构定义职责 +- `20_Global_Workbench_Evolution` 负责跨代际工作台演进研究,为架构与交互判断提供长期外部参照 diff --git a/eai_agentplatform/backend-go/config/ai_config.json b/eai_agentplatform/backend-go/config/ai_config.json index fa2ca0d..15cf7ef 100644 --- a/eai_agentplatform/backend-go/config/ai_config.json +++ b/eai_agentplatform/backend-go/config/ai_config.json @@ -1 +1,172 @@ -{"agent_routes": {"embed_gen": "embed_route_openrouter_text_v3", "image_gen": "image_route_openrouter_gpt_image_2", "path_coach": "chat_route_openrouter_deepseek_v4_flash", "title_gen": "chat_route_openrouter_deepseek_v4_flash"}, "chat_routes": {"chat_route_ollama_qwen": {"description": "本地 Ollama Qwen2.5:7b", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "本地", "temperature": 0.7}, "chat_route_ollama_qwen_fast": {"description": "本地快速对话 (Ollama Qwen2.5:7b 轻量)", "endpoint": "/chat/completions", "max_tokens": 512, "model": "qwen2.5:7b", "provider": "ollama", "short_model_name": "Qwen", "short_route_name": "快聊", "temperature": 0.3}, "chat_route_openrouter_deepseek_v3": {"description": "DeepSeek V3.2 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v3.2", "provider": "openrouter", "short_model_name": "DSV3", "short_route_name": "均衡", "temperature": 0.6}, "chat_route_openrouter_deepseek_v4_flash": {"description": "DeepSeek V4 Flash (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "deepseek/deepseek-v4-flash", "provider": "openrouter", "short_model_name": "DS4F", "short_route_name": "默认", "temperature": 0.7}, "chat_route_openrouter_gpt_5": {"description": "GPT 5.4 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 8192, "model": "openai/gpt-5.4", "provider": "openrouter", "short_model_name": "GPT5", "short_route_name": "全能", "temperature": 0.7}, "chat_route_openrouter_grok_4": {"description": "Grok 4.20 (OpenRouter)", "endpoint": "/chat/completions", "max_tokens": 4096, "model": "x-ai/grok-4.20", "provider": "openrouter", "short_model_name": "Grok4", "short_route_name": "推理", "temperature": 0.6}}, "default_embed_route": "embed_route_openrouter_text_v3", "default_route": "chat_route_openrouter_deepseek_v4_flash", "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", "embed_routes": {"embed_route_ollama_bge_m3": {"description": "本地 Ollama bge-m3", "endpoint": "/embeddings", "model": "bge-m3", "provider": "ollama", "short_model_name": "BGE", "short_route_name": "本地"}, "embed_route_openrouter_text_v3": {"description": "OpenAI text-embedding-3-small (OpenRouter)", "endpoint": "/embeddings", "model": "openai/text-embedding-3-small", "provider": "openrouter", "short_model_name": "Emb3", "short_route_name": "默认"}}, "fallback_routes": {"chat_route_openrouter_deepseek_v3": ["chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_deepseek_v4_flash": ["chat_route_openrouter_deepseek_v3", "chat_route_openrouter_grok_4", "chat_route_ollama_qwen"], "chat_route_openrouter_grok_4": ["chat_route_ollama_qwen"], "embed_route_ollama_bge_m3": ["embed_route_openrouter_text_v3"], "embed_route_openrouter_text_v3": ["embed_route_ollama_bge_m3"]}, "image_routes": {"image_route_ollama_llava": {"description": "本地 Ollama LLaVA", "endpoint": "/chat/completions", "max_tokens": 2048, "model": "llava", "provider": "ollama", "short_model_name": "LLaVA", "short_route_name": "本地", "temperature": 0.7}, "image_route_openrouter_flux": {"description": "FLUX 1.1 Pro (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 1024, "model": "black-forest-labs/flux-1.1-pro", "provider": "openrouter", "short_model_name": "FLUX", "short_route_name": "绘图", "temperature": 1}, "image_route_openrouter_gpt_image_2": {"description": "GPT 5.4 Image 2 (OpenRouter)", "endpoint": "/images/generations", "max_tokens": 4096, "model": "openai/gpt-5.4-image-2", "provider": "openrouter", "short_model_name": "G图2", "short_route_name": "默认", "temperature": 1}}, "version": "2.1.0"} +{ + "agent_routes": { + "embed_gen": "embed_route_openrouter_text_v3", + "image_gen": "image_route_openrouter_gpt_image_2", + "path_coach": "chat_route_lmuai_deepseek_flash", + "title_gen": "chat_route_lmuai_deepseek_flash" + }, + "chat_routes": { + "chat_route_lmuai_deepseek_flash": { + "description": "LMUAI / DeepSeek Flash", + "endpoint": "/chat/completions", + "max_tokens": 4096, + "model": "deepseek-flash", + "provider": "lmuai", + "short_model_name": "DeepSeek Flash", + "short_route_name": "LMUAI", + "temperature": 0.7 + }, + "chat_route_siliconflow_deepseek_v4_flash": { + "description": "SiliconFlow / DeepSeek V4 Flash", + "endpoint": "/chat/completions", + "max_tokens": 4096, + "model": "deepseek-ai/DeepSeek-V4-Flash", + "provider": "siliconflow", + "short_model_name": "DeepSeek V4 Flash", + "short_route_name": "SiliconFlow", + "temperature": 0.7 + }, + "chat_route_ollama_qwen": { + "description": "本地对话 · llama.cpp / Qwen3.5 4B", + "endpoint": "/chat/completions", + "max_tokens": 2048, + "model": "Qwen3.5-4B-Q4_K_M.gguf", + "provider": "llamacpp", + "short_model_name": "Qwen3.5 4B", + "short_route_name": "本地对话", + "temperature": 0.7 + }, + "chat_route_ollama_qwen_fast": { + "description": "本地快聊 · llama.cpp / Qwen3.5 4B", + "endpoint": "/chat/completions", + "max_tokens": 512, + "model": "Qwen3.5-4B-Q4_K_M.gguf", + "provider": "llamacpp", + "short_model_name": "Qwen3.5 4B", + "short_route_name": "本地快聊", + "temperature": 0.3 + }, + "chat_route_openrouter_deepseek_v3": { + "description": "OpenRouter / DeepSeek V3.2", + "endpoint": "/chat/completions", + "max_tokens": 4096, + "model": "deepseek/deepseek-v3.2", + "provider": "openrouter", + "short_model_name": "DeepSeek V3.2", + "short_route_name": "OpenRouter", + "temperature": 0.6 + }, + "chat_route_openrouter_deepseek_v4_flash": { + "description": "OpenRouter / DeepSeek V4 Flash 0731", + "endpoint": "/chat/completions", + "max_tokens": 4096, + "model": "deepseek/deepseek-v4-flash-0731", + "provider": "openrouter", + "short_model_name": "DeepSeek V4 Flash 0731", + "short_route_name": "OpenRouter", + "temperature": 0.7 + }, + "chat_route_openrouter_gpt_5": { + "description": "OpenRouter / GPT 5.4", + "endpoint": "/chat/completions", + "max_tokens": 8192, + "model": "openai/gpt-5.4", + "provider": "openrouter", + "short_model_name": "GPT 5.4", + "short_route_name": "OpenRouter", + "temperature": 0.7 + }, + "chat_route_openrouter_grok_4": { + "description": "OpenRouter / Grok 4.20", + "endpoint": "/chat/completions", + "max_tokens": 4096, + "model": "x-ai/grok-4.20", + "provider": "openrouter", + "short_model_name": "Grok 4.20", + "short_route_name": "OpenRouter", + "temperature": 0.6 + } + }, + "default_embed_route": "embed_route_openrouter_text_v3", + "default_route": "chat_route_lmuai_deepseek_flash", + "description": "eai_agentplatform AI 路由配置 — 按场景分类定义多条路由,支持 provider/模型切换", + "embed_routes": { + "embed_route_ollama_bge_m3": { + "description": "本地向量 · Ollama / BGE-M3", + "endpoint": "/embeddings", + "model": "bge-m3", + "provider": "ollama", + "short_model_name": "BGE-M3", + "short_route_name": "本地向量" + }, + "embed_route_openrouter_text_v3": { + "description": "OpenRouter / text-embedding-3-small", + "endpoint": "/embeddings", + "model": "openai/text-embedding-3-small", + "provider": "openrouter", + "short_model_name": "text-embedding-3-small", + "short_route_name": "OpenRouter" + } + }, + "fallback_routes": { + "chat_route_lmuai_deepseek_flash": [ + "chat_route_ollama_qwen_fast", + "chat_route_ollama_qwen" + ], + "chat_route_siliconflow_deepseek_v4_flash": [ + "chat_route_ollama_qwen_fast", + "chat_route_ollama_qwen" + ], + "chat_route_openrouter_deepseek_v3": [ + "chat_route_openrouter_grok_4", + "chat_route_ollama_qwen" + ], + "chat_route_openrouter_deepseek_v4_flash": [ + "chat_route_openrouter_deepseek_v3", + "chat_route_openrouter_grok_4", + "chat_route_ollama_qwen" + ], + "chat_route_openrouter_grok_4": [ + "chat_route_ollama_qwen" + ], + "embed_route_ollama_bge_m3": [ + "embed_route_openrouter_text_v3" + ], + "embed_route_openrouter_text_v3": [ + "embed_route_ollama_bge_m3" + ] + }, + "image_routes": { + "image_route_ollama_llava": { + "description": "本地图像 · Ollama / LLaVA", + "endpoint": "/chat/completions", + "max_tokens": 2048, + "model": "llava", + "provider": "ollama", + "short_model_name": "LLaVA", + "short_route_name": "本地图像", + "temperature": 0.7 + }, + "image_route_openrouter_flux": { + "description": "OpenRouter / FLUX 1.1 Pro", + "endpoint": "/images/generations", + "max_tokens": 1024, + "model": "black-forest-labs/flux-1.1-pro", + "provider": "openrouter", + "short_model_name": "FLUX 1.1 Pro", + "short_route_name": "OpenRouter", + "temperature": 1 + }, + "image_route_openrouter_gpt_image_2": { + "description": "OpenRouter / GPT 5.4 Image 2", + "endpoint": "/images/generations", + "max_tokens": 4096, + "model": "openai/gpt-5.4-image-2", + "provider": "openrouter", + "short_model_name": "GPT 5.4 Image 2", + "short_route_name": "OpenRouter", + "temperature": 1 + } + }, + "version": "2.1.0" +} diff --git a/eai_agentplatform/backend-go/config/ai_secrets.example.json b/eai_agentplatform/backend-go/config/ai_secrets.example.json index 4f94f91..408ece8 100644 --- a/eai_agentplatform/backend-go/config/ai_secrets.example.json +++ b/eai_agentplatform/backend-go/config/ai_secrets.example.json @@ -1,6 +1,7 @@ { "VECTORENGINE_API_KEY": "sk-...", "OPENROUTER_API_KEY": "sk-or-...", + "SILICONFLOW_API_KEY": "sk-...", "VOLCES_API_KEY": "", "LMUAI_API_KEY": "sk-...", "ALIYUN_API_KEY": "sk-...", @@ -9,4 +10,4 @@ "ANTHROPIC_API_KEY": "", "OPENAI_API_KEY": "", "BRAVE_SEARCH_API_KEY": "BSA..." -} \ No newline at end of file +} diff --git a/eai_agentplatform/backend-go/deploy/eai_agentplatform.env b/eai_agentplatform/backend-go/deploy/eai_agentplatform.env index 08f4103..f2ec404 100644 --- a/eai_agentplatform/backend-go/deploy/eai_agentplatform.env +++ b/eai_agentplatform/backend-go/deploy/eai_agentplatform.env @@ -12,9 +12,9 @@ JWT_SECRET=__CHANGE_ME__GENERATE_48_CHARS_RANDOM__ JWT_EXPIRE_MINUTES=480 # 内网 LLM(OpenAI 兼容接口) -LLM_BASE_URL=http://127.0.0.1:11434/v1 -LLM_API_KEY=ollama -LLM_MODEL=qwen2.5:7b +LLM_BASE_URL=http://127.0.0.1:8080/v1 +LLM_API_KEY=local +LLM_MODEL=Qwen3.5-4B-Q4_K_M.gguf EMBED_MODEL=bge-m3 # 数据目录 diff --git a/eai_agentplatform/backend-go/internal/api/project.go b/eai_agentplatform/backend-go/internal/api/project.go index babd844..bea8b03 100644 --- a/eai_agentplatform/backend-go/internal/api/project.go +++ b/eai_agentplatform/backend-go/internal/api/project.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" + "eai_agentplatform/backend/internal/dal" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/model" specialistruntime "eai_agentplatform/backend/internal/specialists/runtime" @@ -89,6 +90,10 @@ func CreateProject(c *gin.Context) { web.Fail(c, web.NewNotFoundError(err.Error())) return } + if err := validateSkillKeys(req.SkillKeys); err != nil { + web.Fail(c, web.NewNotFoundError(err.Error())) + return + } instruction := "" if req.Instruction != nil { @@ -163,6 +168,10 @@ func UpdateProject(c *gin.Context) { project.SpecialistKeys = encodeKeys(req.SpecialistKeys) } if req.SkillKeys != nil { + if err := validateSkillKeys(req.SkillKeys); err != nil { + web.Fail(c, web.NewNotFoundError(err.Error())) + return + } project.SkillKeys = encodeKeys(req.SkillKeys) } if req.ConnectorKeys != nil { @@ -246,3 +255,17 @@ func validateSpecialistKeys(keys []string) error { } return nil } + +func validateSkillKeys(keys []string) error { + dao := dal.SkillDefinitionDAO{} + for _, key := range keys { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + continue + } + if _, found := dao.GetByKey(trimmed); !found { + return errors.New("技能不存在:" + trimmed) + } + } + return nil +} diff --git a/eai_agentplatform/backend-go/internal/api/router.go b/eai_agentplatform/backend-go/internal/api/router.go index ebce57c..4479158 100644 --- a/eai_agentplatform/backend-go/internal/api/router.go +++ b/eai_agentplatform/backend-go/internal/api/router.go @@ -11,8 +11,8 @@ import ( "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" + "eai_agentplatform/backend/internal/specialists/runtime" xappapi "eai_agentplatform/backend/internal/xapps/api" ) @@ -206,12 +206,15 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) { admin.DELETE("/positions/:id", DeletePosition) admin.POST("/specialists", specialistapi.CreateSpecialist) admin.PUT("/specialists/:id", specialistapi.UpdateSpecialist) + admin.GET("/specialists/:id/remove-plan", specialistapi.PreviewSpecialistRemovePlan) admin.DELETE("/specialists/:id", specialistapi.DeleteSpecialist) admin.POST("/skills", skillapi.CreateSkillDefinition) admin.PUT("/skills/:id", skillapi.UpdateSkillDefinition) + admin.GET("/skills/:id/remove-plan", skillapi.PreviewSkillRemovePlan) admin.DELETE("/skills/:id", skillapi.DeleteSkillDefinition) admin.POST("/xapps", xappapi.CreateXAppDefinition) admin.PUT("/xapps/:id", xappapi.UpdateXAppDefinition) + admin.GET("/xapps/:id/remove-plan", xappapi.PreviewXAppRemovePlan) admin.DELETE("/xapps/:id", xappapi.DeleteXAppDefinition) admin.POST("/actions", CreateActionDefinition) admin.PUT("/actions/:id", UpdateActionDefinition) diff --git a/eai_agentplatform/backend-go/internal/api/specialist_prompt.go b/eai_agentplatform/backend-go/internal/api/specialist_prompt.go index 88fa595..411d72f 100644 --- a/eai_agentplatform/backend-go/internal/api/specialist_prompt.go +++ b/eai_agentplatform/backend-go/internal/api/specialist_prompt.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - skillcore "eai_agentplatform/backend/internal/skills/core" + specialistcore "eai_agentplatform/backend/internal/specialists/core" specialistmodel "eai_agentplatform/backend/internal/specialists/model" ) @@ -63,7 +63,8 @@ func resolveSpecialist(req ChatMessageRequest) *specialistmodel.Specialist { // 专员为 nil 或什么都没配时返回空串,调用方不做任何拼接—— // 这样「没选专员」与「选了但没配说明书」都不改变原有行为。 func specialistPromptSection(s *specialistmodel.Specialist) string { - if s == nil { + ctx := specialistcore.PromptContextFor(s) + if ctx.Key == "" { return "" } @@ -71,27 +72,27 @@ func specialistPromptSection(s *specialistmodel.Specialist) string { // 身份 b.WriteString("【当前专员】\n") - label := strings.TrimSpace(s.Label) + label := ctx.Label if label == "" { - label = s.Key + label = ctx.Key } - fmt.Fprintf(&b, "%s(%s)\n", label, s.Key) - if summary := strings.TrimSpace(s.Summary); summary != "" { + fmt.Fprintf(&b, "%s(%s)\n", label, ctx.Key) + if summary := ctx.Summary; summary != "" { b.WriteString(summary + "\n") } - if workStatus := strings.TrimSpace(s.WorkStatus); workStatus != "" { + if workStatus := ctx.WorkStatus; workStatus != "" { b.WriteString("当前状态:" + workStatus + "\n") } // 可用技能 - if skills := skillcore.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 { + if len(ctx.AllowedSkills) > 0 { b.WriteString("\n【可用技能】\n") - b.WriteString(strings.Join(skills, "、")) + b.WriteString(strings.Join(ctx.AllowedSkills, "、")) b.WriteString("\n首个是主技能。只在这些技能范围内承诺能力,范围外的需求如实说做不了。\n") } // 岗位说明书 - if rule := strings.TrimSpace(s.RuleFileMarkdown); rule != "" { + if rule := ctx.RuleFileMarkdown; rule != "" { b.WriteString("\n【岗位说明书】\n") b.WriteString(rule) b.WriteString("\n") 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 6e16254..1af1d0c 100644 --- a/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go +++ b/eai_agentplatform/backend-go/internal/api/specialist_prompt_test.go @@ -133,7 +133,7 @@ func setupAPITestDB(t *testing.T) { 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: "wechat-official-account", Label: "公众号创作专员", State: "active", Tier: "generic", ObjectEntryRoute: "/apps/wechat-official-account"}, {Key: "retired-one", Label: "已下线专员", State: "inactive", Tier: "generic", ObjectEntryRoute: "/apps/retired"}, {Key: "general-assistant", Label: "通用助手", State: "system", Tier: "generic", ObjectEntryRoute: "/home"}, } { @@ -168,7 +168,7 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) { t.Fatalf("建任务失败: %v", err) } - got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "report-generation"}) + got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"}) if got == nil || got.Key != "contract-review" { t.Fatalf("任务上的专员应优先,实际 %v", got) } @@ -178,8 +178,8 @@ func TestResolveSpecialistTaskWinsOverRequest(t *testing.T) { func TestResolveSpecialistFallsBackToRequest(t *testing.T) { setupAPITestDB(t) - got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "report-generation"}) - if got == nil || got.Key != "report-generation" { + got := resolveSpecialist(ChatMessageRequest{SpecialistKey: "wechat-official-account"}) + if got == nil || got.Key != "wechat-official-account" { t.Fatalf("应回退到请求里的 specialist_key,实际 %v", got) } } @@ -228,8 +228,8 @@ func TestResolveSpecialistTaskWithoutSpecialistFallsBack(t *testing.T) { t.Fatalf("建任务失败: %v", err) } - got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "report-generation"}) - if got == nil || got.Key != "report-generation" { + got := resolveSpecialist(ChatMessageRequest{TaskID: task.ID, SpecialistKey: "wechat-official-account"}) + if got == nil || got.Key != "wechat-official-account" { t.Fatalf("任务专员失效时应回退到请求里的 key,实际 %v", got) } } diff --git a/eai_agentplatform/backend-go/internal/config/config.go b/eai_agentplatform/backend-go/internal/config/config.go index 355f1c2..47c92c7 100644 --- a/eai_agentplatform/backend-go/internal/config/config.go +++ b/eai_agentplatform/backend-go/internal/config/config.go @@ -58,9 +58,9 @@ func Load() *Config { JWTSecret: getenv("JWT_SECRET", "change-this-to-a-strong-secret-in-production"), JWTExpireMin: getenvInt("JWT_EXPIRE_MINUTES", 480), - LLMBaseURL: getenv("LLM_BASE_URL", "http://127.0.0.1:11434/v1"), - LLMAPIKey: getenv("LLM_API_KEY", "ollama"), - LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"), + LLMBaseURL: getenv("LLM_BASE_URL", "http://127.0.0.1:8080/v1"), + LLMAPIKey: getenv("LLM_API_KEY", "local"), + LLMModel: getenv("LLM_MODEL", "Qwen3.5-4B-Q4_K_M.gguf"), EmbedModel: getenv("EMBED_MODEL", "bge-m3"), AssetRootDir: assetRootDir, diff --git a/eai_agentplatform/backend-go/internal/config/json_loader.go b/eai_agentplatform/backend-go/internal/config/json_loader.go index 9790444..92fe5aa 100644 --- a/eai_agentplatform/backend-go/internal/config/json_loader.go +++ b/eai_agentplatform/backend-go/internal/config/json_loader.go @@ -63,6 +63,7 @@ type AIConfig struct { type AISecrets struct { VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"` OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"` + SILICONFLOW_API_KEY string `json:"SILICONFLOW_API_KEY,omitempty"` VOLCES_API_KEY string `json:"VOLCES_API_KEY,omitempty"` LMUAI_API_KEY string `json:"LMUAI_API_KEY,omitempty"` ALIYUN_API_KEY string `json:"ALIYUN_API_KEY,omitempty"` @@ -91,6 +92,7 @@ type PlatformConfig struct { var ProviderSecretKey = map[string]string{ "vectorengine": "VECTORENGINE_API_KEY", "openrouter": "OPENROUTER_API_KEY", + "siliconflow": "SILICONFLOW_API_KEY", "volces": "VOLCES_API_KEY", "lmuai": "LMUAI_API_KEY", "aliyun": "ALIYUN_API_KEY", @@ -104,7 +106,9 @@ var ProviderSecretKey = map[string]string{ // ProviderDefaultBaseURL provider 默认 base_url(当 secrets 未提供时) var ProviderDefaultBaseURL = map[string]string{ "ollama": "http://127.0.0.1:11434/v1", + "llamacpp": "http://127.0.0.1:8080/v1", "openrouter": "https://openrouter.ai/api/v1", + "siliconflow": "https://api.siliconflow.cn/v1", "openai": "https://api.openai.com/v1", "vectorengine": "https://api.vectorengine.ai/v1", "volces": "https://ark.cn-beijing.volces.com/api/v3", @@ -362,6 +366,8 @@ func getSecretByField(s *AISecrets, field string) string { return s.VECTORENGINE_API_KEY case "OPENROUTER_API_KEY": return s.OPENROUTER_API_KEY + case "SILICONFLOW_API_KEY": + return s.SILICONFLOW_API_KEY case "VOLCES_API_KEY": return s.VOLCES_API_KEY case "LMUAI_API_KEY": diff --git a/eai_agentplatform/backend-go/internal/dal/product.go b/eai_agentplatform/backend-go/internal/dal/product.go index 22d3d54..5a09d04 100644 --- a/eai_agentplatform/backend-go/internal/dal/product.go +++ b/eai_agentplatform/backend-go/internal/dal/product.go @@ -107,7 +107,7 @@ func (r ProductDAO) NamesByIDs(ids []uint) map[uint]string { // ProductsByStatus 按状态筛选产品列表。 func (r ProductDAO) ProductsByStatus(status string, filter map[string]string) []model.Product { q := r.Type(&model.Product{}) - if cat, ok := filter["category"]; ok { + if cat, ok := filter["category"]; ok && cat != "" { q = q.Where("category = ?", cat) } switch status { diff --git a/eai_agentplatform/backend-go/internal/objectrefs/runtime.go b/eai_agentplatform/backend-go/internal/objectrefs/runtime.go new file mode 100644 index 0000000..84b339f --- /dev/null +++ b/eai_agentplatform/backend-go/internal/objectrefs/runtime.go @@ -0,0 +1,209 @@ +package objectrefs + +import ( + "encoding/json" + "sort" + "strings" + + "eai_agentplatform/backend/internal/dal" + "eai_agentplatform/backend/internal/model" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" + xappmodel "eai_agentplatform/backend/internal/xapps/model" +) + +type SpecialistRuntimeRefs struct { + XAppKeys []string + ProjectIDs []uint + TaskCount int64 + TaskRunCount int64 + ArtifactCount int64 + AICallCount int64 +} + +type SkillRuntimeRefs struct { + SpecialistKeys []string + XAppKeys []string + ProjectIDs []uint +} + +type XAppRuntimeRefs struct { + FavoriteUserIDs []uint + RecentUserIDs []uint + CustomUserIDs []uint +} + +func ParseKeyList(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)) + seen := make(map[string]bool, len(keys)) + for _, key := range keys { + key = strings.TrimSpace(key) + if key == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, key) + } + return out +} + +func listProjectIDsByKeyField(field string, key string) []uint { + key = strings.TrimSpace(key) + if key == "" || dal.DB == nil { + return nil + } + var projects []model.Project + if err := dal.DB.Select("id, " + field).Find(&projects).Error; err != nil { + return nil + } + ids := make([]uint, 0) + for _, project := range projects { + var raw string + switch field { + case "specialist_keys": + raw = project.SpecialistKeys + case "skill_keys": + raw = project.SkillKeys + default: + continue + } + for _, item := range ParseKeyList(raw) { + if item == key { + ids = append(ids, project.ID) + break + } + } + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func listXAppKeysByField(field string, key string) []string { + key = strings.TrimSpace(key) + if key == "" || dal.DB == nil { + return nil + } + var items []xappmodel.XAppDefinition + if err := dal.DB.Select("key, "+field).Where(field+" = ?", key).Find(&items).Error; err != nil { + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + if trimmed := strings.TrimSpace(item.Key); trimmed != "" { + out = append(out, trimmed) + } + } + sort.Strings(out) + return out +} + +func listSpecialistKeysBindingSkill(skillKey string) []string { + skillKey = strings.TrimSpace(skillKey) + if skillKey == "" || dal.DB == nil { + return nil + } + var items []specialistmodel.Specialist + if err := dal.DB.Select("key, allowed_skills").Find(&items).Error; err != nil { + return nil + } + out := make([]string, 0) + for _, item := range items { + for _, key := range ParseKeyList(item.AllowedSkills) { + if key == skillKey { + out = append(out, strings.TrimSpace(item.Key)) + break + } + } + } + sort.Strings(out) + return out +} + +func CollectSpecialistRuntimeRefs(key string) SpecialistRuntimeRefs { + key = strings.TrimSpace(key) + refs := SpecialistRuntimeRefs{ + XAppKeys: listXAppKeysByField("specialist_key", key), + ProjectIDs: listProjectIDsByKeyField("specialist_keys", key), + } + if key == "" || dal.DB == nil { + return refs + } + dal.DB.Model(&model.TaskRecord{}).Where("specialist_key = ?", key).Count(&refs.TaskCount) + dal.DB.Model(&model.TaskRun{}).Where("specialist_key = ?", key).Count(&refs.TaskRunCount) + dal.DB.Model(&model.TaskArtifact{}).Where("specialist_key = ?", key).Count(&refs.ArtifactCount) + dal.DB.Model(&model.AiCallLog{}).Where("specialist_key = ?", key).Count(&refs.AICallCount) + return refs +} + +func CollectSkillRuntimeRefs(key string) SkillRuntimeRefs { + key = strings.TrimSpace(key) + return SkillRuntimeRefs{ + SpecialistKeys: listSpecialistKeysBindingSkill(key), + XAppKeys: listXAppKeysByField("skill_key", key), + ProjectIDs: listProjectIDsByKeyField("skill_keys", key), + } +} + +func listUserIDsByXAppCenterField(field string, key string) []uint { + key = strings.TrimSpace(key) + if key == "" || dal.DB == nil { + return nil + } + var rows []xappmodel.UserXAppCenter + if err := dal.DB.Select("user_id, favorite_keys, recent_keys, custom_xapps").Find(&rows).Error; err != nil { + return nil + } + userIDs := make([]uint, 0) + for _, row := range rows { + var matched bool + switch field { + case "favorite_keys": + for _, item := range ParseKeyList(row.FavoriteKeys) { + if item == key { + matched = true + break + } + } + case "recent_keys": + for _, item := range ParseKeyList(row.RecentKeys) { + if item == key { + matched = true + break + } + } + case "custom_xapps": + var items []struct { + Key string `json:"key"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(row.CustomXApps)), &items); err == nil { + for _, item := range items { + if strings.TrimSpace(item.Key) == key { + matched = true + break + } + } + } + } + if matched { + userIDs = append(userIDs, row.UserID) + } + } + sort.Slice(userIDs, func(i, j int) bool { return userIDs[i] < userIDs[j] }) + return userIDs +} + +func CollectXAppRuntimeRefs(key string) XAppRuntimeRefs { + key = strings.TrimSpace(key) + return XAppRuntimeRefs{ + FavoriteUserIDs: listUserIDsByXAppCenterField("favorite_keys", key), + RecentUserIDs: listUserIDsByXAppCenterField("recent_keys", key), + CustomUserIDs: listUserIDsByXAppCenterField("custom_xapps", key), + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go b/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go index 236f141..420a700 100644 --- a/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go +++ b/eai_agentplatform/backend-go/internal/skills/api/admin_handlers.go @@ -6,6 +6,8 @@ import ( "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/dal" + "eai_agentplatform/backend/internal/objectrefs" + skillcore "eai_agentplatform/backend/internal/skills/core" skillmodel "eai_agentplatform/backend/internal/skills/model" "eai_agentplatform/backend/internal/web" ) @@ -117,6 +119,10 @@ func DeleteSkillDefinition(c *gin.Context) { web.Fail(c, web.NewNotFoundError("技能定义不存在")) return } + if plan, ok := skillcore.BuildRemovePlanByKey(item.Key); ok && plan.Blocked { + web.Fail(c, web.NewConflictError("技能仍被专员、应用或项目引用,请先处理 remove-plan 中的关联项")) + return + } if !skillDefinitionDAO.Delete(&item) { web.Fail(c, web.NewBadRequest("删除技能定义失败")) return @@ -124,6 +130,52 @@ func DeleteSkillDefinition(c *gin.Context) { web.OK(c, gin.H{"id": id, "deleted": true}) } +// PreviewSkillRemovePlan GET /api/admin/skills/:id/remove-plan (admin) +func PreviewSkillRemovePlan(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + + item, found := skillDefinitionDAO.GetByID(id) + if !found { + web.Fail(c, web.NewNotFoundError("技能定义不存在")) + return + } + + if plan, ok := skillcore.BuildRemovePlanByKey(item.Key); ok { + web.OK(c, gin.H{ + "id": item.ID, + "key": item.Key, + "label": item.Label, + "source": item.Source, + "state": item.State, + "is_builtin": true, + "plan": plan, + }) + return + } + + refs := objectrefs.CollectSkillRuntimeRefs(item.Key) + web.OK(c, gin.H{ + "id": item.ID, + "key": item.Key, + "label": item.Label, + "source": item.Source, + "state": item.State, + "is_builtin": false, + "plan": gin.H{ + "key": item.Key, + "label": item.Label, + "definition_keys": []string{}, + "catalog_entries": []string{}, + "policy": "", + "runtime_refs": refs, + "blocked": false, + }, + }) +} + func parseID(c *gin.Context, name string) (uint, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil || id == 0 { diff --git a/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go index 7abd052..8d03ce6 100644 --- a/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go +++ b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills.go @@ -5,11 +5,15 @@ import ( "fmt" "sort" "strings" + + "eai_agentplatform/backend/internal/dal" + skillmodel "eai_agentplatform/backend/internal/skills/model" ) -var ValidSkillKeys = buildValidSkillKeys() +var ValidSkillKeys = buildBuiltinSkillKeys() +var allowedSkillKeyProvider = buildAllowedSkillKeys -func buildValidSkillKeys() map[string]bool { +func buildBuiltinSkillKeys() map[string]bool { keys := make(map[string]bool) for _, manifest := range builtinRegistry.Manifests() { if strings.TrimSpace(manifest.Key) == "" { @@ -21,8 +25,31 @@ func buildValidSkillKeys() map[string]bool { } func SortedValidSkillKeys() []string { - keys := make([]string, 0, len(ValidSkillKeys)) + return sortedSkillKeys(ValidSkillKeys) +} + +func buildAllowedSkillKeys() map[string]bool { + keys := make(map[string]bool, len(ValidSkillKeys)) for k := range ValidSkillKeys { + keys[k] = true + } + if dal.DB == nil { + return keys + } + if !dal.DB.Migrator().HasTable(&skillmodel.SkillDefinition{}) { + return keys + } + for _, item := range (dal.SkillDefinitionDAO{}).List("", nil) { + if key := strings.TrimSpace(item.Key); key != "" { + keys[key] = true + } + } + return keys +} + +func sortedSkillKeys(set map[string]bool) []string { + keys := make([]string, 0, len(set)) + for k := range set { keys = append(keys, k) } sort.Strings(keys) @@ -59,14 +86,16 @@ func MarshalAllowedSkills(keys []string) (string, error) { } func ValidateAllowedSkills(keys []string) error { + validKeys := allowedSkillKeyProvider() + sortedKeys := sortedSkillKeys(validKeys) 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 !validKeys[k] { + return fmt.Errorf("技能 key %q 非法,可选值:%s", k, strings.Join(sortedKeys, " / ")) } if seen[k] { return fmt.Errorf("技能 key %q 重复绑定", k) diff --git a/eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go index 7ead923..fe84217 100644 --- a/eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go +++ b/eai_agentplatform/backend-go/internal/skills/core/allowed_skills_test.go @@ -110,6 +110,11 @@ func TestMarshalAllowedSkills(t *testing.T) { } func TestValidateAllowedSkills(t *testing.T) { + originalProvider := allowedSkillKeyProvider + t.Cleanup(func() { + allowedSkillKeyProvider = originalProvider + }) + t.Run("合法列表通过", func(t *testing.T) { if err := ValidateAllowedSkills([]string{"contract-review", "batch-extract"}); err != nil { t.Errorf("不应报错: %v", err) @@ -138,6 +143,21 @@ func TestValidateAllowedSkills(t *testing.T) { t.Fatal("重复绑定应报错(首个为主技能的语义会变得不确定)") } }) + + t.Run("运行时技能定义可放宽内置白名单", func(t *testing.T) { + allowedSkillKeyProvider = func() map[string]bool { + return map[string]bool{ + "contract-review": true, + "runtime-skill": true, + } + } + if err := ValidateAllowedSkills([]string{"runtime-skill"}); err != nil { + t.Fatalf("运行时技能应允许绑定: %v", err) + } + if ValidSkillKeys["runtime-skill"] { + t.Fatal("内置技能清单不应被运行时定义污染") + } + }) } func readFrontendFile(t *testing.T, relPath string) []byte { diff --git a/eai_agentplatform/backend-go/internal/skills/core/ownership.go b/eai_agentplatform/backend-go/internal/skills/core/ownership.go new file mode 100644 index 0000000..dc91188 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/skills/core/ownership.go @@ -0,0 +1,38 @@ +package skillcore + +import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + +type OwnershipInfo struct { + Key string + Label string + DefinitionKeys []string + CatalogEntries []string + Policy skillcontracts.SharedReferencePolicy +} + +func OwnershipCatalog() map[string]OwnershipInfo { + items := make(map[string]OwnershipInfo) + for _, manifest := range builtinRegistry.Manifests() { + items[manifest.Key] = ownershipFromManifest(manifest) + } + return items +} + +func OwnershipByKey(key string) (OwnershipInfo, bool) { + manifest, ok := builtinRegistry.ByKey(key) + if !ok { + return OwnershipInfo{}, false + } + return ownershipFromManifest(manifest), true +} + +func ownershipFromManifest(manifest skillcontracts.Manifest) OwnershipInfo { + meta := manifest.UninstallMeta() + return OwnershipInfo{ + Key: manifest.Key, + Label: manifest.Label, + DefinitionKeys: meta.OwnedDefinitionKeys, + CatalogEntries: meta.OwnedCatalogEntries, + Policy: meta.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/skills/core/uninstall.go b/eai_agentplatform/backend-go/internal/skills/core/uninstall.go index 15593d8..524c943 100644 --- a/eai_agentplatform/backend-go/internal/skills/core/uninstall.go +++ b/eai_agentplatform/backend-go/internal/skills/core/uninstall.go @@ -1,6 +1,10 @@ package skillcore -import skillcontracts "eai_agentplatform/backend/internal/skills/contracts" +import ( + skillcontracts "eai_agentplatform/backend/internal/skills/contracts" + + "eai_agentplatform/backend/internal/objectrefs" +) type UninstallPreview struct { DefinitionKeys []string @@ -8,6 +12,16 @@ type UninstallPreview struct { Policy skillcontracts.SharedReferencePolicy } +type RemovePlan struct { + Key string + Label string + DefinitionKeys []string + CatalogEntries []string + Policy skillcontracts.SharedReferencePolicy + RuntimeRefs objectrefs.SkillRuntimeRefs + Blocked bool +} + func BuildUninstallPreview(manifest skillcontracts.Manifest) UninstallPreview { meta := manifest.UninstallMeta() return UninstallPreview{ @@ -16,3 +30,22 @@ func BuildUninstallPreview(manifest skillcontracts.Manifest) UninstallPreview { Policy: meta.ReferencePolicy, } } + +func BuildRemovePlanByKey(key string) (RemovePlan, bool) { + ownership, ok := OwnershipByKey(key) + if !ok { + return RemovePlan{}, false + } + refs := objectrefs.CollectSkillRuntimeRefs(key) + blocked := ownership.Policy == skillcontracts.SharedReferenceBlockUninstall && + (len(refs.SpecialistKeys) > 0 || len(refs.XAppKeys) > 0 || len(refs.ProjectIDs) > 0) + return RemovePlan{ + Key: ownership.Key, + Label: ownership.Label, + DefinitionKeys: ownership.DefinitionKeys, + CatalogEntries: ownership.CatalogEntries, + Policy: ownership.Policy, + RuntimeRefs: refs, + Blocked: blocked, + }, true +} diff --git a/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go b/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go index ad6527a..cc19b00 100644 --- a/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go +++ b/eai_agentplatform/backend-go/internal/specialists/api/admin_handlers.go @@ -2,10 +2,13 @@ package specialistapi import ( "strconv" + "strings" "github.com/gin-gonic/gin" "eai_agentplatform/backend/internal/dal" + "eai_agentplatform/backend/internal/objectrefs" + 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/web" @@ -150,10 +153,19 @@ func DeleteSpecialist(c *gin.Context) { if !ok { return } - if _, found := specialistDAO.GetByID(id); !found { + item, found := specialistDAO.GetByID(id) + if !found { web.Fail(c, web.NewNotFoundError("专员不存在")) return } + if strings.TrimSpace(item.Key) == "general-assistant" || strings.TrimSpace(item.State) == "system" { + web.Fail(c, web.NewConflictError("通用专员是系统保留专员,不能删除")) + return + } + if plan, ok := specialistcore.BuildRemovePlanByKey(item.Key); ok && plan.Blocked { + web.Fail(c, web.NewConflictError("专员仍被运行时对象引用,请先处理 remove-plan 中的关联项")) + return + } if !specialistDAO.Delete(id) { web.Fail(c, web.NewBadRequest("删除专员失败")) return @@ -161,6 +173,49 @@ func DeleteSpecialist(c *gin.Context) { web.OK(c, gin.H{"id": id, "deleted": true}) } +// PreviewSpecialistRemovePlan GET /api/admin/specialists/:id/remove-plan (admin) +func PreviewSpecialistRemovePlan(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + item, found := specialistDAO.GetByID(id) + if !found { + web.Fail(c, web.NewNotFoundError("专员不存在")) + return + } + + if plan, ok := specialistcore.BuildRemovePlanByKey(item.Key); ok { + web.OK(c, gin.H{ + "id": item.ID, + "key": item.Key, + "label": item.Label, + "state": item.State, + "is_builtin": true, + "plan": plan, + }) + return + } + + refs := objectrefs.CollectSpecialistRuntimeRefs(item.Key) + web.OK(c, gin.H{ + "id": item.ID, + "key": item.Key, + "label": item.Label, + "state": item.State, + "is_builtin": false, + "plan": gin.H{ + "key": item.Key, + "label": item.Label, + "definition_keys": []string{}, + "catalog_entries": []string{}, + "policy": "", + "runtime_refs": refs, + "blocked": false, + }, + }) +} + func parseID(c *gin.Context, name string) (uint, bool) { id, err := strconv.ParseUint(c.Param(name), 10, 64) if err != nil || id == 0 { diff --git a/eai_agentplatform/backend-go/internal/specialists/core/apply.go b/eai_agentplatform/backend-go/internal/specialists/core/apply.go index c9ded28..faec4fc 100644 --- a/eai_agentplatform/backend-go/internal/specialists/core/apply.go +++ b/eai_agentplatform/backend-go/internal/specialists/core/apply.go @@ -22,8 +22,8 @@ func RuleFileDrafts() map[string]string { 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...) + if skills := DefaultAllowedSkillsForKey(manifest.Key); len(skills) > 0 { + items[manifest.Key] = skills } } return items @@ -38,13 +38,14 @@ func ApplyRuleFiles(items []specialistmodel.Specialist) error { if manifest.RuleFileMarkdown != "" { items[i].RuleFileMarkdown = manifest.RuleFileMarkdown } - if len(manifest.AllowedSkills) == 0 { + skills := DefaultAllowedSkillsForKey(items[i].Key) + if len(skills) == 0 { continue } - if err := skillcore.ValidateAllowedSkills(manifest.AllowedSkills); err != nil { + if err := skillcore.ValidateAllowedSkills(skills); err != nil { return fmt.Errorf("专员 %s 的技能绑定非法: %w", items[i].Key, err) } - raw, err := skillcore.MarshalAllowedSkills(manifest.AllowedSkills) + raw, err := MarshalAllowedSkillBindingsFor(&items[i]) if err != nil { return fmt.Errorf("专员 %s 的技能绑定序列化失败: %w", items[i].Key, err) } diff --git a/eai_agentplatform/backend-go/internal/specialists/core/binding_provider.go b/eai_agentplatform/backend-go/internal/specialists/core/binding_provider.go new file mode 100644 index 0000000..6a9ee96 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/binding_provider.go @@ -0,0 +1,37 @@ +package specialistcore + +import ( + "strings" + + skillcore "eai_agentplatform/backend/internal/skills/core" + specialistmodel "eai_agentplatform/backend/internal/specialists/model" +) + +// DefaultAllowedSkillsForKey 返回包定义里的默认技能绑定。 +// +// 它表达的是 specialist 包默认 own 的绑定,不看运行时数据库。 +func DefaultAllowedSkillsForKey(key string) []string { + manifest, ok := builtinRegistry.ByKey(strings.TrimSpace(key)) + if !ok || len(manifest.AllowedSkills) == 0 { + return nil + } + return append([]string(nil), manifest.AllowedSkills...) +} + +// AllowedSkillBindingsFor 返回专员当前有效的技能绑定。 +// +// 运行时持久化层优先,包内 manifest 作为兜底默认值。 +func AllowedSkillBindingsFor(s *specialistmodel.Specialist) []string { + if s == nil { + return nil + } + if skills := skillcore.ParseAllowedSkills(s.AllowedSkills); len(skills) > 0 { + return skills + } + return DefaultAllowedSkillsForKey(s.Key) +} + +// MarshalAllowedSkillBindingsFor 返回专员当前有效技能绑定的持久化表示。 +func MarshalAllowedSkillBindingsFor(s *specialistmodel.Specialist) (string, error) { + return skillcore.MarshalAllowedSkills(AllowedSkillBindingsFor(s)) +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/binding_provider_test.go b/eai_agentplatform/backend-go/internal/specialists/core/binding_provider_test.go new file mode 100644 index 0000000..92f3c37 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/binding_provider_test.go @@ -0,0 +1,52 @@ +package specialistcore + +import ( + "reflect" + "testing" + + specialistmodel "eai_agentplatform/backend/internal/specialists/model" +) + +func TestDefaultAllowedSkillsForKey(t *testing.T) { + got := DefaultAllowedSkillsForKey("contract-review") + want := []string{"contract-review", "contract-brief", "batch-extract"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("默认技能绑定不对,实际 %v,期望 %v", got, want) + } +} + +func TestAllowedSkillBindingsForPrefersPersistedValues(t *testing.T) { + s := &specialistmodel.Specialist{ + Key: "contract-review", + AllowedSkills: `["runtime-skill","contract-review"]`, + } + + got := AllowedSkillBindingsFor(s) + want := []string{"runtime-skill", "contract-review"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("应优先使用数据库技能绑定,实际 %v,期望 %v", got, want) + } +} + +func TestAllowedSkillBindingsForFallsBackToManifest(t *testing.T) { + s := &specialistmodel.Specialist{Key: "contract-review"} + + got := AllowedSkillBindingsFor(s) + want := []string{"contract-review", "contract-brief", "batch-extract"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("数据库技能绑定为空时应回退 manifest,实际 %v,期望 %v", got, want) + } +} + +func TestMarshalAllowedSkillBindingsFor(t *testing.T) { + s := &specialistmodel.Specialist{Key: "contract-review"} + + got, err := MarshalAllowedSkillBindingsFor(s) + if err != nil { + t.Fatalf("序列化默认技能绑定失败: %v", err) + } + want := `["contract-review","contract-brief","batch-extract"]` + if got != want { + t.Fatalf("序列化结果不对,实际 %s,期望 %s", got, want) + } +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/ownership.go b/eai_agentplatform/backend-go/internal/specialists/core/ownership.go new file mode 100644 index 0000000..803bda5 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/ownership.go @@ -0,0 +1,38 @@ +package specialistcore + +import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" + +type OwnershipInfo struct { + Key string + Label string + DefinitionKeys []string + CatalogEntries []string + Policy specialistcontracts.SharedReferencePolicy +} + +func OwnershipCatalog() map[string]OwnershipInfo { + items := make(map[string]OwnershipInfo) + for _, manifest := range builtinRegistry.Manifests() { + items[manifest.Key] = ownershipFromManifest(manifest) + } + return items +} + +func OwnershipByKey(key string) (OwnershipInfo, bool) { + manifest, ok := builtinRegistry.ByKey(key) + if !ok { + return OwnershipInfo{}, false + } + return ownershipFromManifest(manifest), true +} + +func ownershipFromManifest(manifest specialistcontracts.Manifest) OwnershipInfo { + meta := manifest.UninstallMeta() + return OwnershipInfo{ + Key: manifest.Key, + Label: manifest.Label, + DefinitionKeys: meta.OwnedDefinitionKeys, + CatalogEntries: meta.OwnedCatalogEntries, + Policy: meta.ReferencePolicy, + } +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/prompt_provider.go b/eai_agentplatform/backend-go/internal/specialists/core/prompt_provider.go new file mode 100644 index 0000000..eafd09b --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/prompt_provider.go @@ -0,0 +1,52 @@ +package specialistcore + +import ( + "strings" + + specialistmodel "eai_agentplatform/backend/internal/specialists/model" +) + +// PromptContext 是平台层真正需要的专员提示词上下文。 +// +// 平台不必知道 rule_file_markdown / allowed_skills 落在哪个字段, +// 只消费这里收口后的结果。 +type PromptContext struct { + Key string + Label string + Summary string + WorkStatus string + AllowedSkills []string + RuleFileMarkdown string +} + +// PromptContextFor 返回专员当前有效的提示词上下文。 +// +// 运行时持久化层优先,包内 manifest 作为兜底默认值: +// 1. 管理员在线编辑后的岗位说明书 / 技能绑定以数据库为准 +// 2. 数据库为空时,再回退到内置专员 manifest +func PromptContextFor(s *specialistmodel.Specialist) PromptContext { + if s == nil { + return PromptContext{} + } + + ctx := PromptContext{ + Key: strings.TrimSpace(s.Key), + Label: strings.TrimSpace(s.Label), + Summary: strings.TrimSpace(s.Summary), + WorkStatus: strings.TrimSpace(s.WorkStatus), + AllowedSkills: AllowedSkillBindingsFor(s), + RuleFileMarkdown: strings.TrimSpace(s.RuleFileMarkdown), + } + + manifest, ok := builtinRegistry.ByKey(ctx.Key) + if !ok { + return ctx + } + if ctx.Label == "" { + ctx.Label = strings.TrimSpace(manifest.Label) + } + if ctx.RuleFileMarkdown == "" { + ctx.RuleFileMarkdown = strings.TrimSpace(manifest.RuleFileMarkdown) + } + return ctx +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/prompt_provider_test.go b/eai_agentplatform/backend-go/internal/specialists/core/prompt_provider_test.go new file mode 100644 index 0000000..1e1e2a9 --- /dev/null +++ b/eai_agentplatform/backend-go/internal/specialists/core/prompt_provider_test.go @@ -0,0 +1,58 @@ +package specialistcore + +import ( + "reflect" + "testing" + + specialistmodel "eai_agentplatform/backend/internal/specialists/model" +) + +func TestPromptContextForPrefersPersistedValues(t *testing.T) { + s := &specialistmodel.Specialist{ + Key: "contract-review", + Label: "合同审查专员(自定义)", + Summary: "线上编辑后的摘要", + WorkStatus: "2 份待复核", + AllowedSkills: `["runtime-skill","contract-review"]`, + RuleFileMarkdown: "# 自定义岗位说明书", + } + + got := PromptContextFor(s) + + if got.Label != "合同审查专员(自定义)" { + t.Fatalf("应优先使用数据库 label,实际 %q", got.Label) + } + if got.RuleFileMarkdown != "# 自定义岗位说明书" { + t.Fatalf("应优先使用数据库岗位说明书,实际 %q", got.RuleFileMarkdown) + } + wantSkills := []string{"runtime-skill", "contract-review"} + if !reflect.DeepEqual(got.AllowedSkills, wantSkills) { + t.Fatalf("应优先使用数据库技能绑定,实际 %v,期望 %v", got.AllowedSkills, wantSkills) + } +} + +func TestPromptContextForFallsBackToManifest(t *testing.T) { + s := &specialistmodel.Specialist{ + Key: "contract-review", + Label: "", + } + + got := PromptContextFor(s) + + if got.Label != "合同审查专员" { + t.Fatalf("数据库 label 为空时应回退 manifest,实际 %q", got.Label) + } + if got.RuleFileMarkdown == "" { + t.Fatal("数据库岗位说明书为空时应回退 manifest") + } + wantSkills := []string{"contract-review", "contract-brief", "batch-extract"} + if !reflect.DeepEqual(got.AllowedSkills, wantSkills) { + t.Fatalf("数据库技能绑定为空时应回退 manifest,实际 %v,期望 %v", got.AllowedSkills, wantSkills) + } +} + +func TestPromptContextForNil(t *testing.T) { + if got := PromptContextFor(nil); !reflect.DeepEqual(got, PromptContext{}) { + t.Fatalf("nil 专员应返回空上下文,实际 %#v", got) + } +} diff --git a/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go b/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go index 882e969..7178cff 100644 --- a/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go +++ b/eai_agentplatform/backend-go/internal/specialists/core/uninstall.go @@ -1,6 +1,9 @@ package specialistcore -import specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" +import ( + "eai_agentplatform/backend/internal/objectrefs" + specialistcontracts "eai_agentplatform/backend/internal/specialists/contracts" +) type UninstallPreview struct { DefinitionKeys []string @@ -8,6 +11,16 @@ type UninstallPreview struct { Policy specialistcontracts.SharedReferencePolicy } +type RemovePlan struct { + Key string + Label string + DefinitionKeys []string + CatalogEntries []string + Policy specialistcontracts.SharedReferencePolicy + RuntimeRefs objectrefs.SpecialistRuntimeRefs + Blocked bool +} + func BuildUninstallPreview(manifest specialistcontracts.Manifest) UninstallPreview { meta := manifest.UninstallMeta() return UninstallPreview{ @@ -16,3 +29,22 @@ func BuildUninstallPreview(manifest specialistcontracts.Manifest) UninstallPrevi Policy: meta.ReferencePolicy, } } + +func BuildRemovePlanByKey(key string) (RemovePlan, bool) { + ownership, ok := OwnershipByKey(key) + if !ok { + return RemovePlan{}, false + } + refs := objectrefs.CollectSpecialistRuntimeRefs(key) + blocked := ownership.Policy == specialistcontracts.SharedReferenceBlockUninstall && + (len(refs.XAppKeys) > 0 || len(refs.ProjectIDs) > 0 || refs.TaskCount > 0 || refs.TaskRunCount > 0 || refs.ArtifactCount > 0 || refs.AICallCount > 0) + return RemovePlan{ + Key: ownership.Key, + Label: ownership.Label, + DefinitionKeys: ownership.DefinitionKeys, + CatalogEntries: ownership.CatalogEntries, + Policy: ownership.Policy, + RuntimeRefs: refs, + Blocked: blocked, + }, true +} 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 deleted file mode 100644 index 87bbf4c..0000000 --- a/eai_agentplatform/backend-go/internal/specialists/packages/knowledge_operations/manifest.go +++ /dev/null @@ -1,30 +0,0 @@ -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/presentation_briefing/manifest.go b/eai_agentplatform/backend-go/internal/specialists/packages/presentation_briefing/manifest.go deleted file mode 100644 index 930549c..0000000 --- a/eai_agentplatform/backend-go/internal/specialists/packages/presentation_briefing/manifest.go +++ /dev/null @@ -1,33 +0,0 @@ -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", "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 deleted file mode 100644 index 256d79b..0000000 --- a/eai_agentplatform/backend-go/internal/specialists/packages/process_coordination/manifest.go +++ /dev/null @@ -1,30 +0,0 @@ -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"}, - 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 deleted file mode 100644 index 40002fc..0000000 --- a/eai_agentplatform/backend-go/internal/specialists/packages/report_generation/manifest.go +++ /dev/null @@ -1,30 +0,0 @@ -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 index 721d4a8..3ac3dd7 100644 --- a/eai_agentplatform/backend-go/internal/specialists/packages/resume_processor/manifest.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/resume_processor/manifest.go @@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr var Manifest = specialistcontracts.Manifest{ Key: "resume-processor", - Label: "招聘筛选专员", - RuleFileMarkdown: `# 招聘筛选专员 + Label: "简历筛选与招聘专员", + RuleFileMarkdown: `# 简历筛选与招聘专员 ## 你是谁 你负责从招聘入口整理到候选人筛选的闭环:邮件归档、候选人抽取、评分排序、跟进标记、面试安排。 diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go index 5ea158a..eb4eefb 100644 --- a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_article_service.go @@ -194,7 +194,7 @@ func resetOfficialAccountArticle(article *officialaccountmodel.OfficialAccountAr func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) []officialAccountTopicCandidate { domainLabel := officialAccountBusinessDomainLabel(form.BusinessDomain) - candidates := make([]officialAccountTopicCandidate, 0, 4) + candidates := make([]officialAccountTopicCandidate, 0, 3) for _, item := range hotspots { topic := deriveOfficialAccountTopic(form.Keyword, item.Title) if topic == "" { @@ -206,7 +206,7 @@ func buildOfficialAccountTopicCandidatesFromHotspots(form officialAccountForm, h Reason: firstNonEmpty(item.Summary, "来自公开"+domainLabel+"资讯源,已按业务域相关性筛选。"), Heat: firstNonEmpty(item.HeatLabel, "中高"), }) - if len(candidates) >= 4 { + if len(candidates) >= 3 { break } } @@ -273,7 +273,7 @@ func marshalOfficialAccountHotspots(hotspots []officialaccountmodel.OfficialAcco func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []officialaccountmodel.OfficialAccountHotspot) string { cfg := getOfficialAccountBusinessDomainConfig(form.BusinessDomain) lines := []string{ - fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 4 个值得写的热点选题。", cfg.PromptLabel), + fmt.Sprintf("你是公众号选题策划编辑,请基于下面的%s热点,为公众号生成 3 个值得写的热点选题。", cfg.PromptLabel), "要求:", fmt.Sprintf("1. 主题必须适合%s方向的公众号写作;", cfg.PromptLabel), "2. 要能写成公众号文章,不是简单复述新闻;", @@ -284,6 +284,7 @@ func buildOfficialAccountHotspotPrompt(form officialAccountForm, hotspots []offi fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")), fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")), fmt.Sprintf("语气:%s", firstNonEmpty(form.Tone, "专业但好懂")), + fmt.Sprintf("额外要求:%s", firstNonEmpty(form.Requirements, "无")), "热点列表:", } for idx, item := range hotspots { @@ -302,6 +303,7 @@ func buildOfficialAccountTopicEvalPrompt(form officialAccountForm, topic string, fmt.Sprintf("选题:%s", topic), fmt.Sprintf("业务域:%s", cfg.Label), fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)), + fmt.Sprintf("额外要求:%s", firstNonEmpty(form.Requirements, "无")), "参考热点:", } for idx, item := range hotspots { @@ -327,6 +329,7 @@ func buildOfficialAccountTopicBatchEvalPrompt(form officialAccountForm, candidat fmt.Sprintf("关键词:%s", firstNonEmpty(form.Keyword, cfg.DefaultKeyword)), fmt.Sprintf("面向人群:%s", firstNonEmpty(form.Audience, "公众号读者")), fmt.Sprintf("内容目标:%s", firstNonEmpty(form.Goal, "输出一篇可发布的公众号文章")), + fmt.Sprintf("额外要求:%s", firstNonEmpty(form.Requirements, "无")), "待评估选题:", } for idx, item := range candidates { diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go index d37c99c..becabba 100644 --- a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_delivery.go @@ -573,7 +573,7 @@ func ExportOfficialAccountDocument(c *gin.Context) { return } if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员")) + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员")) return } diff --git a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go index 1f6701c..9017a5f 100644 --- a/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go +++ b/eai_agentplatform/backend-go/internal/specialists/packages/wechat_official_account/api/official_account_workflow.go @@ -125,6 +125,11 @@ type officialAccountTopicCandidate struct { Heat string `json:"heat"` } +type officialAccountTitleChoice struct { + Title string `json:"title"` + Positioning string `json:"positioning"` +} + type officialAccountImagePrompt struct { Key string `json:"key"` Section string `json:"section"` @@ -186,7 +191,7 @@ func CreateOfficialAccountTask(c *gin.Context) { // 任务挂在已下线的专员上,历史记录仍要解释得通。 specialist, found := specialistDAO.GetByKey(officialAccountSpecialistKey) if !found { - web.Fail(c, web.NewNotFoundError("公众号专员尚未配置")) + web.Fail(c, web.NewNotFoundError("公众号创作专员尚未配置")) return } @@ -230,7 +235,7 @@ func GetOfficialAccountWorkflow(c *gin.Context) { return } if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员")) + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员")) return } respondOfficialAccountWorkflow(c, task) @@ -243,7 +248,7 @@ func UpdateOfficialAccountTask(c *gin.Context) { return } if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员")) + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员")) return } @@ -393,7 +398,7 @@ func ExecuteOfficialAccountWorkflowStep(c *gin.Context) { return } if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员")) + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员")) return } @@ -524,7 +529,7 @@ func RegenerateOfficialAccountImage(c *gin.Context) { return } if task.SpecialistKey != officialAccountSpecialistKey { - web.Fail(c, web.NewNotFoundError("当前事项不属于公众号专员")) + web.Fail(c, web.NewNotFoundError("当前事项不属于公众号创作专员")) return } @@ -1072,11 +1077,15 @@ func runOfficialAccountStep(stepKey string, workflow *officialAccountWorkflowSta } func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, article *officialaccountmodel.OfficialAccountArticle, user *model.User, now time.Time) officialAccountStepExecution { - hotspots, hotspotLogs, _ := ensureOfficialAccountHotspots(workflow.Form, false) + hotspots, hotspotLogs, hotspotErr := ensureOfficialAccountHotspots(workflow.Form, false) hotspots = sortOfficialAccountHotspots(hotspots) + fallbackUsed := hotspotErr != nil || len(hotspots) == 0 candidates := buildOfficialAccountTopicCandidatesFromHotspots(workflow.Form, hotspots) - _ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates) - evaluatedCount := evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates) + evaluatedCount := 0 + if !fallbackUsed { + _ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates) + evaluatedCount = evaluateOfficialAccountTopics(workflow.Form, hotspots, user, &candidates) + } if workflow.Shared.SelectedTopic == "" && len(candidates) > 0 { workflow.Shared.SelectedTopic = candidates[0].Topic } @@ -1090,20 +1099,30 @@ func executeOfficialAccountTopicStep(workflow *officialAccountWorkflowState, art "candidates": candidates, "selected_topic": workflow.Shared.SelectedTopic, "hotspots": hotspots, + "fallback_used": fallbackUsed, + } + summary := fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic) + logs := append([]string{ + fmt.Sprintf("%s 已基于关键词「%s」生成候选选题", now.Format("15:04:05"), workflow.Form.Keyword), + fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount), + fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTopic), + }, hotspotLogs...) + if fallbackUsed { + summary = fmt.Sprintf("热点源暂不可用,已回退为通用选题建议;当前推荐题目为「%s」。", workflow.Shared.SelectedTopic) + output["hotspot_error"] = errorMessage(hotspotErr) + logs = append(logs, + fmt.Sprintf("%s 未获取到可用热点,已回退为通用选题模板。", now.Format("15:04:05")), + ) } return officialAccountStepExecution{ - Summary: fmt.Sprintf("已完成热点选题推荐,当前推荐题目为「%s」。", workflow.Shared.SelectedTopic), + Summary: summary, TaskStatus: taskStatusInProgress, ArtifactTitle: "公众号选题建议", ArtifactType: "topics", ArtifactStatus: artifactStatusDraft, ArtifactContent: buildTopicArtifactContent(candidates, workflow.Shared.SelectedTopic), Output: output, - Logs: append([]string{ - fmt.Sprintf("%s 已基于关键词「%s」生成候选选题", now.Format("15:04:05"), workflow.Form.Keyword), - fmt.Sprintf("%s 已完成 %d 个选题的热度与可写性评判", now.Format("15:04:05"), evaluatedCount), - fmt.Sprintf("%s 默认选中「%s」进入下一步", now.Format("15:04:05"), workflow.Shared.SelectedTopic), - }, hotspotLogs...), + Logs: logs, } } @@ -1122,7 +1141,7 @@ func executeOfficialAccountTitleStep(workflow *officialAccountWorkflowState, art } output := map[string]any{ - "titles": titles, + "titles": buildOfficialAccountTitleChoices(titles), "selected_title": workflow.Shared.SelectedTitle, "selected_topic": workflow.Shared.SelectedTopic, } @@ -1227,12 +1246,6 @@ func buildOfficialAccountTopicCandidates(form officialAccountForm) []officialAcc Reason: "更贴近业务落地,适合输出步骤化内容。", Heat: "中高", }, - { - Topic: fmt.Sprintf("%s这件事,外行和内行到底差在哪?", keyword), - Angle: "认知对比", - Reason: "利于写出观点型文章,适合做公众号风格表达。", - Heat: "中", - }, } } @@ -1242,8 +1255,6 @@ func buildOfficialAccountTitles(form officialAccountForm, topic string) []string fmt.Sprintf("%s最近又火了,但大多数人根本没看懂", topic), fmt.Sprintf("别急着谈%s,先把这 3 件事看明白", keyword), fmt.Sprintf("%s最容易踩的坑,我一次讲透", keyword), - fmt.Sprintf("为什么说%s不是不能做,而是你做错了顺序", keyword), - fmt.Sprintf("关于%s,外行最爱问的 5 个问题", keyword), } } @@ -1251,11 +1262,13 @@ func buildOfficialAccountOutline(form officialAccountForm, title string) string keyword := firstNonEmpty(form.Keyword, "这个主题") outlineStyle := normalizeOfficialAccountOutlineStyle(form.OutlineStyle) outlineWords := normalizeOfficialAccountOutlineWords(form.OutlineWords) + requirements := firstNonEmpty(form.Requirements, "无") return strings.TrimSpace(fmt.Sprintf( - "# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n", + "# 标题\n%s\n\n> 提纲风格:%s | 目标字数:约 %d 字\n> 额外要求:%s\n\n## 一、为什么现在值得聊%s\n- 当前背景和触发原因\n- 为什么读者现在会关心\n\n## 二、大多数人最容易误解的地方\n- 常见认知偏差 1\n- 常见认知偏差 2\n- 常见认知偏差 3\n\n## 三、真正可落地的做法\n- 第一步:先明确目标\n- 第二步:先从小范围验证\n- 第三步:沉淀方法和复用模板\n\n## 四、最后给读者的建议\n- 什么情况应该立刻开始\n- 什么情况应该先补前置条件\n- 一句话总结\n", title, outlineStyle, outlineWords, + requirements, keyword, )) } @@ -1265,10 +1278,12 @@ func buildOfficialAccountContent(form officialAccountForm, title, outline string audience := firstNonEmpty(form.Audience, "公众号读者") tone := firstNonEmpty(form.Tone, "专业但好懂") targetWords := normalizeOfficialAccountContentTargetWords(form.ContentTargetWords) + requirements := firstNonEmpty(form.Requirements, "无") return strings.TrimSpace(fmt.Sprintf( - "# %s\n\n> 正文字数目标:约 %d 字\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n", + "# %s\n\n> 正文字数目标:约 %d 字\n> 额外要求:%s\n\n很多%s最近都在关注“%s”,但真正把这件事讲清楚的人并不多。今天这篇文章,我想用尽量%s的方式,把这个问题讲透。\n\n## 为什么现在值得聊\n\n第一,是这个话题本身已经进入更多人的工作和决策场景;第二,是不少人已经开始行动,但做法还比较碎;第三,大家更需要的不是空泛观点,而是能拿去用的方法。\n\n## 最容易踩的坑\n\n第一个坑,是一上来就追求大而全,结果什么都想做,最后什么都落不下去。\n\n第二个坑,是只看表面热度,不看自己真实业务是否适配。\n\n第三个坑,是做完一次就结束,没有形成可复用的内容资产和方法模板。\n\n## 真正可落地的做法\n\n先明确目标:你是要做传播、做转化,还是做认知教育?目标不同,内容结构完全不同。\n\n再做小范围验证:不要一开始就上最重的版本,先验证选题、标题和读者反馈。\n\n最后沉淀模板:把好用的标题结构、提纲结构和正文风格保留下来,后面才能越做越快。\n\n## 最后想说\n\n如果你也在做%s,别急着一次把所有事情做完。先抓住一个明确问题,把它讲明白、讲透、讲到读者愿意继续往下看,这篇文章就已经成功了一半。\n\n这篇内容可以继续细化成你自己的案例版、行业版、客户版。后面只要沿着这个骨架迭代,文章生产就会越来越稳。\n", firstNonEmpty(title, keyword), targetWords, + requirements, audience, keyword, tone, @@ -1289,11 +1304,12 @@ func tryFillOfficialAccountTopicsWithAI(form officialAccountForm, hotspots []off func tryFillOfficialAccountTitlesWithAI(form officialAccountForm, topic string, user *model.User, target *[]string) bool { var items []string prompt := fmt.Sprintf( - "请基于选题“%s”为公众号生成 5 个标题候选,关键词是“%s”,面向%s,语气%s。只返回 JSON 数组字符串,不要输出 markdown。", + "请基于选题“%s”为公众号生成 3 个标题候选,分别覆盖偏点击、偏稳重、偏平衡三种取向。关键词是“%s”,面向%s,语气%s,额外要求:%s。只返回 JSON 数组字符串,不要输出 markdown。", topic, form.Keyword, form.Audience, form.Tone, + firstNonEmpty(form.Requirements, "无"), ) if !generateOfficialAccountJSON(prompt, user, &items) || len(items) == 0 { return false @@ -1304,24 +1320,26 @@ func tryFillOfficialAccountTitlesWithAI(form officialAccountForm, topic string, func tryGenerateOfficialAccountOutlineWithAI(form officialAccountForm, title string, user *model.User) (string, bool) { prompt := fmt.Sprintf( - "请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。", + "请基于公众号标题“%s”生成一份适合中文公众号的文章提纲,输出 markdown。要求有 4-6 个二级部分,每部分带 2-4 个要点,语气%s,受众%s,提纲风格%s,目标提纲字数约%d字。额外要求:%s", title, form.Tone, form.Audience, normalizeOfficialAccountOutlineStyle(form.OutlineStyle), normalizeOfficialAccountOutlineWords(form.OutlineWords), + firstNonEmpty(form.Requirements, "无"), ) return generateOfficialAccountText(prompt, user) } func tryGenerateOfficialAccountContentWithAI(form officialAccountForm, title, outline string, user *model.User) (string, bool) { prompt := fmt.Sprintf( - "请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。", + "请根据以下公众号文章信息输出一篇可发布的中文正文草稿。\n标题:%s\n受众:%s\n目标:%s\n语气:%s\n提纲:\n%s\n额外要求:%s\n要求:约%d字,语言自然,不要编造成数据,不要输出多余解释。", title, form.Audience, form.Goal, form.Tone, outline, + firstNonEmpty(form.Requirements, "无"), normalizeOfficialAccountContentTargetWords(form.ContentTargetWords), ) return generateOfficialAccountText(prompt, user) @@ -1519,8 +1537,8 @@ func buildTopicArtifactContent(candidates []officialAccountTopicCandidate, selec func buildTitleArtifactContent(titles []string, selected string) string { lines := []string{"# 标题候选", ""} - for idx, item := range titles { - lines = append(lines, fmt.Sprintf("%d. %s", idx+1, item)) + for idx, item := range buildOfficialAccountTitleChoices(titles) { + lines = append(lines, fmt.Sprintf("%d. [%s] %s", idx+1, item.Positioning, item.Title)) } lines = append(lines, "", fmt.Sprintf("当前默认标题:%s", selected)) return strings.Join(lines, "\n") @@ -1531,6 +1549,9 @@ func buildOfficialAccountSourceRefs(workflow officialAccountWorkflowState, stepK "关键词:" + firstNonEmpty(workflow.Form.Keyword, "待补充"), "受众:" + firstNonEmpty(workflow.Form.Audience, "公众号读者"), } + if workflow.Form.Requirements != "" { + refs = append(refs, "写作要求:"+workflow.Form.Requirements) + } if workflow.Shared.SelectedTopic != "" { refs = append(refs, "选题:"+workflow.Shared.SelectedTopic) } @@ -1543,6 +1564,22 @@ func buildOfficialAccountSourceRefs(workflow officialAccountWorkflowState, stepK return refs } +func buildOfficialAccountTitleChoices(titles []string) []officialAccountTitleChoice { + labels := []string{"偏点击", "偏稳重", "偏平衡"} + choices := make([]officialAccountTitleChoice, 0, len(titles)) + for idx, item := range titles { + positioning := "候选" + if idx < len(labels) { + positioning = labels[idx] + } + choices = append(choices, officialAccountTitleChoice{ + Title: item, + Positioning: positioning, + }) + } + return choices +} + func cloneOutputMap(source map[string]any) map[string]any { result := map[string]any{} for key, value := range source { 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 index 7391d1e..9904a9f 100644 --- 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 @@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr var Manifest = specialistcontracts.Manifest{ Key: "wechat-official-account", - Label: "公众号专员", - RuleFileMarkdown: `# 公众号专员 + Label: "公众号创作专员", + RuleFileMarkdown: `# 公众号创作专员 ## 你是谁 你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。 diff --git a/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go b/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go index c62c730..324e94f 100644 --- a/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go +++ b/eai_agentplatform/backend-go/internal/specialists/seeding/seed_specialists.go @@ -38,81 +38,9 @@ func SeedSpecialists(db *gorm.DB) error { State: "system", SortOrder: 0, }, - { - 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: "公众号专员", + Label: "公众号创作专员", Tier: "generic", SpecialistMode: "dw", ObjectEntryRoute: "/apps/wechat-official-account", @@ -208,7 +136,7 @@ func SeedSpecialists(db *gorm.DB) error { }, { Key: "resume-processor", - Label: "招聘筛选专员", + Label: "简历筛选与招聘专员", Tier: "industry", SpecialistMode: "dw", ObjectEntryRoute: "/apps/resume-processor", @@ -230,30 +158,6 @@ func SeedSpecialists(db *gorm.DB) error { State: "active", SortOrder: 80, }, - { - 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 { @@ -327,23 +231,6 @@ func SeedSpecialists(db *gorm.DB) error { } } - if err := deleteSpecialists(db, []string{ - "training-delivery", - }); err != nil { - return err - } - - if err := retireSpecialists(db, []string{ - "knowledge-operations", - "process-coordination", - "report-generation", - "presentation-briefing", - "hr-email-sorter", - "logistics-fulfillment", - }); err != nil { - return err - } - specialistcore.LogRuleFileCoverage(db) log.Println("[OK] 专员目录种子已导入") return nil @@ -366,20 +253,3 @@ func applySpecialistCodes(items []specialistmodel.Specialist) []specialistmodel. } return items } - -func deleteSpecialists(db *gorm.DB, keys []string) error { - if len(keys) == 0 { - return nil - } - return db.Where("key IN ?", keys).Delete(&specialistmodel.Specialist{}).Error -} - -func retireSpecialists(db *gorm.DB, keys []string) error { - if len(keys) == 0 { - return nil - } - return db.Model(&specialistmodel.Specialist{}). - Where("key IN ?", keys). - Where("state = ?", "active"). - Update("state", "retired").Error -} diff --git a/eai_agentplatform/backend-go/internal/store/seed.go b/eai_agentplatform/backend-go/internal/store/seed.go index bb2523f..d6b9084 100644 --- a/eai_agentplatform/backend-go/internal/store/seed.go +++ b/eai_agentplatform/backend-go/internal/store/seed.go @@ -66,9 +66,9 @@ func SeedDefaults() error { {"password_min_length", "6", "密码最小长度"}, {"session_timeout_minutes", "480", "会话超时分钟数(不活动自动退出)"}, // 7.2.4 AI 配置 - {"llm_base_url", "http://localhost:11434/v1", "LLM 服务地址"}, - {"llm_api_key", "sk-xxx", "LLM API Key(敏感信息,建议写入 ai_secrets.json)"}, - {"llm_model", "qwen2.5:7b", "LLM 模型名"}, + {"llm_base_url", "http://localhost:8080/v1", "LLM 服务地址"}, + {"llm_api_key", "local", "LLM API Key(本地服务可留占位值,远程服务建议写入 ai_secrets.json)"}, + {"llm_model", "Qwen3.5-4B-Q4_K_M.gguf", "LLM 模型名"}, {"embed_model", "bge-m3", "Embedding 模型名"}, {"llm_max_tokens", "2048", "LLM 最大输出 Token 数"}, {"llm_temperature", "0.7", "LLM 温度参数(0.0~1.0)"}, @@ -91,6 +91,18 @@ func SeedDefaults() error { if err := seedCoursesFromJSON(); err != nil { return err } + if err := seedProductsFromJSON(); err != nil { + return err + } + if err := seedCompanyIntroContent(); err != nil { + return err + } + if err := seedQuestionsFromJSON(); err != nil { + return err + } + if err := seedDefaultExamPapers(); err != nil { + return err + } if err := seedTrainingMedia(); err != nil { return err } @@ -135,6 +147,40 @@ type courseSeedFile struct { Data []courseSeedItem `json:"data"` } +type productSeedFile struct { + Data []productSeedItem `json:"data"` +} + +type productSeedItem struct { + Code string `json:"code"` + Name string `json:"name"` + Category string `json:"category"` + Tags string `json:"tags"` + Description string `json:"description"` + Pricing string `json:"pricing"` + CommissionRecommend string `json:"commission_recommend"` + CommissionNegotiate string `json:"commission_negotiate"` + PublicCourseBonus string `json:"public_course_bonus"` + VersionRisk string `json:"version_risk"` + ReportRules string `json:"report_rules"` + Status string `json:"status"` +} + +type questionSeedFile struct { + Data []questionSeedItem `json:"data"` +} + +type questionSeedItem struct { + Domain string `json:"domain"` + CourseID *uint `json:"course_id"` + Type string `json:"type"` + Stem string `json:"stem"` + Options []model.QuestionOption `json:"options"` + Answer []string `json:"answer"` + Explanation string `json:"explanation"` + Status string `json:"status"` +} + type courseSeedItem struct { Code string `json:"code"` Name string `json:"name"` @@ -197,6 +243,172 @@ func seedCoursesFromJSON() error { return nil } +func seedProductsFromJSON() error { + var count int64 + DB.Model(&model.Product{}).Count(&count) + if count > 0 { + return nil + } + + cfg := config.Load() + path := filepath.Join(cfg.TrainingMaterialsDir, "work", "products.json") + if _, err := os.Stat(path); err != nil { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var payload productSeedFile + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + + for _, item := range payload.Data { + if item.Code == "" || item.Name == "" || item.Category == "" { + continue + } + status := item.Status + if strings.TrimSpace(status) == "" { + status = "active" + } + product := model.Product{ + Code: item.Code, + Name: item.Name, + Category: item.Category, + Tags: item.Tags, + Description: item.Description, + Pricing: item.Pricing, + CommissionRecommend: item.CommissionRecommend, + CommissionNegotiate: item.CommissionNegotiate, + PublicCourseBonus: item.PublicCourseBonus, + VersionRisk: item.VersionRisk, + ReportRules: item.ReportRules, + Status: status, + } + if err := DB.Create(&product).Error; err != nil { + return err + } + } + + log.Println("[OK] 产品知识种子已导入") + return nil +} + +func seedCompanyIntroContent() error { + var cfg model.SystemConfig + if err := DB.Where("config_key = ?", "company_intro").First(&cfg).Error; err != nil { + return nil + } + if strings.TrimSpace(cfg.ConfigValue) != "" { + return nil + } + cfg.ConfigValue = strings.Join([]string{ + "博昇,聚焦企业增长与 AI 落地的综合服务商。", + "", + "我们围绕企业从资本运作到资质认定,再到 AI 转型的完整成长链路,提供四大板块服务:", + "1. 资本咨询:股权规划、融资顾问、IPO 辅导,帮助企业走通资本路径。", + "2. 资质认定辅导:高新技术企业、专精特新、专精特新小巨人的申报陪跑。", + "3. AI 咨询与实施:AI 公开课、落地诊断、软件授权与定制开发。", + "4. 企业级 AI 工具与平台:按行业匹配知识库、办公智能体、工业/医疗 AI、内容生成与电商运营方案。", + "", + "本培训平台面向新员工入职培训与内部持续学习,统一承接公司认知、产品知识、销售方法与考试验收。", + }, "\n") + return DB.Save(&cfg).Error +} + +func seedQuestionsFromJSON() error { + var count int64 + DB.Model(&model.Question{}).Count(&count) + if count > 0 { + return nil + } + + cfg := config.Load() + path := filepath.Join(cfg.TrainingMaterialsDir, "work", "questions.json") + if _, err := os.Stat(path); err != nil { + return nil + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + var payload questionSeedFile + if err := json.Unmarshal(data, &payload); err != nil { + return err + } + + for _, item := range payload.Data { + if item.Domain == "" || item.Type == "" || item.Stem == "" || len(item.Answer) == 0 { + continue + } + status := item.Status + if strings.TrimSpace(status) == "" { + status = "active" + } + question := model.Question{ + Domain: item.Domain, + CourseID: item.CourseID, + Type: item.Type, + Stem: item.Stem, + Options: mustJSON(item.Options), + Answer: mustJSON(item.Answer), + Explanation: item.Explanation, + Status: status, + } + if err := DB.Create(&question).Error; err != nil { + return err + } + } + + log.Println("[OK] 考试题库种子已导入") + return nil +} + +func seedDefaultExamPapers() error { + var count int64 + DB.Model(&model.ExamPaper{}).Count(&count) + if count > 0 { + return nil + } + + papers := []model.ExamPaper{ + { + Name: "内部培训综合自测", + Type: "self_test", + Domain: "company,product,sales", + QuestionCount: 20, + TotalScore: 100, + PassScore: 60, + DurationMinutes: 30, + Randomize: true, + Status: "active", + }, + { + Name: "内部培训综合考试", + Type: "formal", + Domain: "company,product,sales", + QuestionCount: 30, + TotalScore: 100, + PassScore: 80, + DurationMinutes: 45, + Randomize: true, + Status: "active", + }, + } + for _, paper := range papers { + if err := DB.Create(&paper).Error; err != nil { + return err + } + } + log.Println("[OK] 默认考试配置已导入") + return nil +} + type mediaSeed struct { filename string sourcePath string @@ -345,8 +557,8 @@ func seedSkillDefinitions() error { "opening_prompt": "直接告诉我目标,或者把材料贴给我,我来帮你起步。", "starter_prompts": []string{"帮我拆解这个任务,并列出下一步", "把这段产品介绍整理成正式文案", "先帮我判断这个问题该找哪个专员或技能"}, }), - State: "active", - SortOrder: 10, + State: "active", + SortOrder: 10, }, { Key: "document-translate", @@ -373,8 +585,8 @@ func seedSkillDefinitions() error { "opening_prompt": "把原文贴给我,并说明目标语言、受众和语气要求;如果有术语表,也一起给我。", "starter_prompts": []string{"把这段产品说明翻译成英文,保持专业语气", "把这封邮件翻译成日文,语气礼貌一点", "把这份中文文档整理成英文摘要"}, }), - State: "active", - SortOrder: 20, + State: "active", + SortOrder: 20, }, { Key: "copy-proofreading", @@ -401,8 +613,8 @@ func seedSkillDefinitions() error { "opening_prompt": "把文案贴给我,并告诉我这段内容要发给谁、希望保留什么语气。", "starter_prompts": []string{"检查这段销售话术里的错别字和语病", "把这段产品介绍润得更正式一点", "帮我校对这封对外邮件,保留原意"}, }), - State: "active", - SortOrder: 30, + State: "active", + SortOrder: 30, }, { Key: "audio-transcribe", @@ -429,8 +641,8 @@ func seedSkillDefinitions() error { "opening_prompt": "把音频内容发给我,或者告诉我语言、场景和你最终想拿到什么结果。", "starter_prompts": []string{"把这段会议录音转成文字,并分段整理", "先转写这段采访,再提取重点", "把语音内容整理成可发群里的纪要"}, }), - State: "active", - SortOrder: 40, + State: "active", + SortOrder: 40, }, { Key: "batch-extract", @@ -457,8 +669,8 @@ func seedSkillDefinitions() error { "opening_prompt": "告诉我想抽哪些字段、输出成什么格式、哪些情况需要标记为异常。", "starter_prompts": []string{"从这批简历里提取姓名、岗位和年限", "把这些合同里的付款条款统一抽出来", "帮我批量提取文章标题、作者和发布时间"}, }), - State: "active", - SortOrder: 50, + State: "active", + SortOrder: 50, }, { Key: "contract-review", @@ -485,8 +697,8 @@ func seedSkillDefinitions() error { "opening_prompt": "把合同发给我,并告诉我这次重点想看哪些条款,或者你站在哪一方立场。", "starter_prompts": []string{"审查这份采购合同的风险点", "重点看赔偿责任和终止条款", "帮我整理一版待人工确认的风险清单"}, }), - State: "active", - SortOrder: 60, + State: "active", + SortOrder: 60, }, { Key: "report-generation", @@ -513,8 +725,8 @@ func seedSkillDefinitions() error { "opening_prompt": "把材料给我,并告诉我报告类型、受众、周期和你最想突出哪些重点。", "starter_prompts": []string{"把今天的工作记录整理成日报", "根据这些节点生成项目周报", "帮我把这次交付过程整理成复盘说明"}, }), - State: "active", - SortOrder: 70, + State: "active", + SortOrder: 70, }, }) @@ -894,9 +1106,9 @@ func seedXAppDefinitions() error { Color: "#4f46e5", IconText: "P", CoverTone: "linear-gradient(135deg, #4338ca 0%, #818cf8 100%)", - Summary: "直接进入汇报 PPT 应用,默认挂上汇报专员,快速拿到页面结构、讲稿备注和演示骨架。", - Description: "适合经营汇报、方案汇报、培训课件和项目复盘。打开就进入“汇报专员 + PPT 技能”的样板链路。", - SpecialistKey: "presentation-briefing", + Summary: "直接进入汇报 PPT 应用,快速拿到页面结构、讲稿备注和演示骨架。", + Description: "适合经营汇报、方案汇报、培训课件和项目复盘。", + SpecialistKey: "", SkillKey: "ppt-generation", DefaultPrompt: "帮我做一版 8 页管理层汇报 PPT", PromptsJSON: mustJSON([]string{"帮我做一版 8 页管理层汇报 PPT", "按汇报对象输出页面大纲和讲稿备注", "把这批材料压成一版培训课件骨架"}), diff --git a/eai_agentplatform/backend-go/internal/xapps/api/handlers.go b/eai_agentplatform/backend-go/internal/xapps/api/handlers.go index efd122c..c96d9cc 100644 --- a/eai_agentplatform/backend-go/internal/xapps/api/handlers.go +++ b/eai_agentplatform/backend-go/internal/xapps/api/handlers.go @@ -13,6 +13,7 @@ import ( "eai_agentplatform/backend/internal/jsonutil" "eai_agentplatform/backend/internal/middleware" "eai_agentplatform/backend/internal/web" + xappcore "eai_agentplatform/backend/internal/xapps/core" xappdefs "eai_agentplatform/backend/internal/xapps/model" ) @@ -132,6 +133,16 @@ func validateDefinitionReq(req *definitionReq) *web.AppError { if !jsonutil.ValidateStringArrayJSON(req.TagsJSON) { return web.NewBadRequest("tags_json 必须是字符串数组") } + if req.SpecialistKey != "" { + if _, found := (dal.SpecialistDAO{}).GetByKey(req.SpecialistKey); !found { + return web.NewBadRequest("specialist_key 指向的专员不存在") + } + } + if req.SkillKey != "" { + if _, found := (dal.SkillDefinitionDAO{}).GetByKey(req.SkillKey); !found { + return web.NewBadRequest("skill_key 指向的技能不存在") + } + } return nil } @@ -270,6 +281,10 @@ func DeleteXAppDefinition(c *gin.Context) { web.Fail(c, web.NewNotFoundError("应用定义不存在")) return } + if plan := xappcore.BuildRemovePlan(item); plan.Blocked { + web.Fail(c, web.NewConflictError("应用仍被运行时对象引用,请先处理 remove-plan 中的关联项")) + return + } if !xAppDefinitionDAO.Delete(&item) { web.Fail(c, web.NewBadRequest("删除应用定义失败")) return @@ -277,6 +292,28 @@ func DeleteXAppDefinition(c *gin.Context) { web.OK(c, gin.H{"id": id, "deleted": true}) } +func PreviewXAppRemovePlan(c *gin.Context) { + id, ok := parseID(c, "id") + if !ok { + return + } + item, found := xAppDefinitionDAO.GetByID(id) + if !found { + web.Fail(c, web.NewNotFoundError("应用定义不存在")) + return + } + plan := xappcore.BuildRemovePlan(item) + web.OK(c, gin.H{ + "id": item.ID, + "key": item.Key, + "label": item.Label, + "source": item.Source, + "state": item.State, + "is_builtin": false, + "plan": plan, + }) +} + const ( maxFavoriteXAppCount = 24 maxRecentXAppCount = 8 diff --git a/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go b/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go index 7f4e6fc..8e4d3f2 100644 --- a/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go +++ b/eai_agentplatform/backend-go/internal/xapps/core/uninstall.go @@ -1,11 +1,26 @@ package xappcore +import ( + "eai_agentplatform/backend/internal/objectrefs" + xappmodel "eai_agentplatform/backend/internal/xapps/model" +) + type UninstallPreview struct { DefinitionKeys []string CatalogEntries []string Policy SharedReferencePolicy } +type RemovePlan struct { + Key string + Label string + DefinitionKeys []string + CatalogEntries []string + Policy SharedReferencePolicy + RuntimeRefs objectrefs.XAppRuntimeRefs + Blocked bool +} + func BuildUninstallPreview(manifest Manifest) UninstallPreview { meta := manifest.UninstallMeta() return UninstallPreview{ @@ -14,3 +29,21 @@ func BuildUninstallPreview(manifest Manifest) UninstallPreview { Policy: meta.ReferencePolicy, } } + +func BuildRemovePlan(item xappmodel.XAppDefinition) RemovePlan { + definitionKeys := []string{} + catalogEntries := []string{} + if item.Key != "" { + definitionKeys = []string{"xapp." + item.Key + ".definition"} + catalogEntries = []string{"catalog.xapp." + item.Key} + } + return RemovePlan{ + Key: item.Key, + Label: item.Label, + DefinitionKeys: definitionKeys, + CatalogEntries: catalogEntries, + Policy: "", + RuntimeRefs: objectrefs.CollectXAppRuntimeRefs(item.Key), + Blocked: false, + } +} diff --git a/eai_agentplatform/frontend/src/components/SectionTabs.vue b/eai_agentplatform/frontend/src/components/SectionTabs.vue index d039d9e..5910fed 100644 --- a/eai_agentplatform/frontend/src/components/SectionTabs.vue +++ b/eai_agentplatform/frontend/src/components/SectionTabs.vue @@ -5,26 +5,26 @@
@@ -40,6 +40,7 @@ const props = defineProps({ groups: { type: Array, default: () => [] }, // 当前路由落在哪个组 groupKey: { type: String, default: '' }, + sectionLabel: { type: String, default: '' }, }) const route = useRoute() @@ -67,6 +68,13 @@ function openGroup(group) { if (target) router.push(target.pageRoute) } +function formatIndexedLabel(label, index) { + const sectionIndex = Number(String(props.sectionLabel || '').match(/^(\d+)\./)?.[1] || 0) + const normalizedLabel = String(label || '').replace(/^\d+(?:\.\d+)?\s+/, '') + if (!sectionIndex) return normalizedLabel + return `${sectionIndex}.${index + 1} ${normalizedLabel}` +} + function isCurrent(path) { return route.path === path || route.path.startsWith(`${path}/`) } diff --git a/eai_agentplatform/frontend/src/components/chat/ChatLayout.vue b/eai_agentplatform/frontend/src/components/chat/ChatLayout.vue index 4622969..7802921 100644 --- a/eai_agentplatform/frontend/src/components/chat/ChatLayout.vue +++ b/eai_agentplatform/frontend/src/components/chat/ChatLayout.vue @@ -22,11 +22,11 @@