refactor: 专员目录收敛为 6 个,重整 AI 路由与系统管理页

一次性提交当天全部改动(110 个文件)。

**未按 TOP_CODING_RULES.md G14.5「一次提交只装一件事」拆分** —— 用户明确要求
单一提交,此处如实记录,不静默忽略该冲突。

提交前验证:后端 go build / go vet / go test ./... 全绿,前端 npm run build
exit 0,/api/health 与管理员 login 均返回 200。

- 专员目录收敛为 6 个:下线 knowledge-operations / process-coordination /
  presentation-briefing / report-generation 四个专员(前后端 manifest 与 seed
  同步删除),新增 general-assistant。专员的归属关系(拥有哪些技能定义、
  哪些目录项、提示词与绑定从哪来)改由 specialists/core 的 ownership.go、
  prompt_provider.go、binding_provider.go 统一提供,seed 与 admin_handlers
  随之内置化,卸载路径统一走 uninstall.go。
- 技能:补齐 text-to-speech 的前端 manifest(后端包在 HEAD 已存在),
  skillcore/ownership.go 提供与专员对称的归属查询。
- AI 路由:ai_config.json 由 OpenRouter/Ollama 切到 LMUAI / SiliconFlow /
  llama.cpp 本地路由,ai_secrets.example.json 与部署 env 样例同步新增
  SILICONFLOW_API_KEY。
- 系统管理页重整:新增 AiAdminPage、OrganizationManagementPage,删除
  AdminOverviewPage、CompanyConfigPage,SystemConfigPage 精简,nav / router /
  config/workbench.js 同步调整。依 G05.5,开发阶段直接收口到新结构,不留旧路由。
- 新增 internal/objectrefs:统一统计对象(专员 / 技能 / xapp)的运行时引用
  (被多少 xapp、项目、任务引用),供管理页做删除前的影响面判断。
- 公众号创作专员:新增 OfficialAccountSpecialistPanel,workflow 与投递链路调整。
- XApp:考试 / 培训 Shell 扩展,XAppDirectoryPage 与 xappDefinition 同步。
- 文档:新增 GW01–GW05 工作台演进系列与 AR12 对话驱动与结构化交互架构;
  同步 AR05 / SY17 / SY23 / SY25 / PL04;TOP_CODING_RULES.md 增补 G05.5。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-23 09:01:28 +08:00
co-authored by Claude Code
parent 7fc2903827
commit 593323a934
110 changed files with 8295 additions and 1715 deletions
+1
View File
@@ -175,6 +175,7 @@
- 文件扩展名白名单校验(仅允许 ppt/pptx/pdf/doc/docx/mp4/png/jpg/jpeg)
- 文件名重命名为存储 UUID,杜绝路径穿越
- 上传目录对静态预览只读,禁止直接执行
5. **开发阶段路由调整直接收口到新结构**:在开发阶段,信息架构或导航结构调整时,默认**不需要保留老路由**。除非用户明确要求兼容、灰度、外链保持可用,否则应直接删除旧路由与旧入口,避免同时维护新旧两套路由造成漂移与误判。
---
@@ -164,7 +164,7 @@
| [我的待办] [我的业务流] [最近交付物] [高风险事项] |
+--------------------------------------------------------------------------------------------------+
| +-------------------------+ +-------------------------+ +-------------------------+ |
| | 公众号专员 | | 售前方案专员 | | 合同审查专员 | |
| | 公众号创作专员 | | 售前方案专员 | | 合同审查专员 | |
| | 通用专员 / 内容运营 | | 行业专员 / 售前 | | 行业专员 / 法务 | |
| | 3 个待办 / 1 篇待发布 | | 2 个待审批 / 1 个高风险 | | 5 个进行中 | |
| | [进入应用] | | [进入应用] | | [进入应用] | |
@@ -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 未下载
@@ -263,7 +263,7 @@ skill 是能力片段,不是岗位。
错误示例:
- `客户跟进专员`
- `招聘筛选专员`
- `简历筛选与招聘专员`
问题:
@@ -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. 对象生命周期
@@ -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 承接面的共同交互依据。
+2
View File
@@ -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 与阶段性记录索引 |
+2 -2
View File
@@ -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 | 继续观察是否需要补独立招聘连接器 |
## 已退出当前目录但保留历史意义的对象
@@ -103,9 +103,9 @@
- `知识运营专员`
- `流程推进专员`
- `报告生成专员`
- `公众号专员`
- `公众号创作专员`
- `客户跟进专员`
- `招聘筛选专员`
- `简历筛选与招聘专员`
### 4.2 已有执行层基础
@@ -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/
@@ -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/
@@ -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/`
@@ -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(每章节一页,带 `<!-- PAGE N: title -->` 分隔)│
│ 前端需做:展示预览 → 提供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
<template>
<div class="workbench">
<!-- 左侧:步骤导航 -->
<WorkflowStepNav
:steps="workflow.steps"
:currentStep="workflow.currentStep"
@selectStep="handleSelectStep"
/>
<!-- 中间:当前步骤交互 -->
<div class="step-content">
<!-- Step 1: 选题 -->
<TopicCandidateCard
v-if="currentStep === 'topic_recommendation'"
:candidates="workflow.shared.topicCandidates"
@select="handleSelectTopic"
/>
<!-- Step 2: 标题 -->
<TitleCandidateCard
v-if="currentStep === 'title_generation'"
:candidates="workflow.shared.titleCandidates"
@select="handleSelectTitle"
/>
<!-- Step 3: 提纲 -->
<OutlineEditor
v-if="currentStep === 'outline_generation'"
:content="workflow.shared.outline"
@confirm="handleConfirmOutline"
/>
<!-- Step 4: 正文 -->
<ContentEditor
v-if="currentStep === 'content_creation'"
:content="workflow.shared.content"
@confirm="handleConfirmContent"
/>
<!-- Step 5: 配图提示词 -->
<ImagePromptList
v-if="currentStep === 'image_prompt_generation'"
:prompts="workflow.shared.imagePrompts"
@update="handleUpdatePrompts"
/>
<!-- Step 6: 图片生成 -->
<ImageGallery
v-if="currentStep === 'image_generation'"
:images="workflow.shared.imageRefs"
@regenerate="handleRegenerateImage"
/>
<!-- Step 7: 预览 -->
<PreviewPanel
v-if="currentStep === 'preview_export'"
:markdown="workflow.shared.previewMarkdown"
:html="workflow.shared.previewHTML"
/>
</div>
<!-- 底部面板 -->
<BottomPanel :data="bottomPanel" />
</div>
</template>
```
### 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 前端工程师可以独立完成。
@@ -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
<template>
<div class="specialist-panel">
<!-- 现有 tab:工作流 / 产物 / 预览 -->
<div class="sp-tabs">
<button class="sp-tab" :class="{ active: tab === 'flow' }" @click="tab = 'flow'">
工作流 <span class="sp-badge">{{ doneCount }}/{{ steps.length }}</span>
</button>
<button class="sp-tab" :class="{ active: tab === 'arts' }" @click="tab = 'arts'">
产物 <span class="sp-badge">{{ artifacts.length }}</span>
</button>
<button class="sp-tab" v-if="showInteractiveTab" :class="{ active: tab === 'interactive' }" @click="tab = 'interactive'">
操作
</button>
<button class="sp-fold" @click="emit('collapse')">✕</button>
</div>
<div class="sp-body">
<!-- 现有:只读工作流 -->
<div v-if="tab === 'flow'">...</div>
<!-- 新增:交互式步骤 -->
<div v-if="tab === 'interactive'">
<OfficialAccountStepInteractive
v-if="currentSpecialistKey === 'wechat-official-account'"
:step="currentStep"
:workflow-state="workflowState"
@select="handleStepSelect"
@confirm="handleStepConfirm"
/>
<div v-else class="sp-empty">选择专员或任务以操作</div>
</div>
<!-- 现有:产物 -->
<div v-if="tab === 'arts'">...</div>
</div>
</div>
</template>
```
### 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 各司其职的协作系统**。
@@ -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` 形成正式合同
+2
View File
@@ -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` 负责跨代际工作台演进研究,为架构与交互判断提供长期外部参照
@@ -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"
}
@@ -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-...",
@@ -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
# 数据目录
@@ -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
}
@@ -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)
@@ -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")
@@ -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)
}
}
@@ -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,
@@ -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":
@@ -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 {
@@ -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),
}
}
@@ -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 {
@@ -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)
@@ -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 {
@@ -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,
}
}
@@ -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
}
@@ -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 {
@@ -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)
}
@@ -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))
}
@@ -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)
}
}
@@ -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,
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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,
}
@@ -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,
}
@@ -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,
}
@@ -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,
}
@@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr
var Manifest = specialistcontracts.Manifest{
Key: "resume-processor",
Label: "招聘筛选专员",
RuleFileMarkdown: `# 招聘筛选专员
Label: "简历筛选与招聘专员",
RuleFileMarkdown: `# 简历筛选与招聘专员
## 你是谁
你负责从招聘入口整理到候选人筛选的闭环:邮件归档、候选人抽取、评分排序、跟进标记、面试安排。
@@ -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 {
@@ -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
}
@@ -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)
evaluatedCount := 0
if !fallbackUsed {
_ = tryFillOfficialAccountTopicsWithAI(workflow.Form, hotspots, user, &candidates)
evaluatedCount := evaluateOfficialAccountTopics(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 {
@@ -4,8 +4,8 @@ import specialistcontracts "eai_agentplatform/backend/internal/specialists/contr
var Manifest = specialistcontracts.Manifest{
Key: "wechat-official-account",
Label: "公众号专员",
RuleFileMarkdown: `# 公众号专员
Label: "公众号创作专员",
RuleFileMarkdown: `# 公众号创作专员
## 你是谁
你负责公众号内容从选题到成稿:热点选题、标题、提纲、正文。
@@ -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
}
@@ -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
@@ -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", "按汇报对象输出页面大纲和讲稿备注", "把这批材料压成一版培训课件骨架"}),
@@ -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
@@ -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,
}
}
@@ -5,26 +5,26 @@
<div v-if="groups.length || tabItems.length > 1" class="section-tabs">
<nav v-if="groups.length" class="section-tabs-row section-tabs-groups" aria-label="分组">
<button
v-for="group in groups"
v-for="(group, index) in groups"
:key="group.key"
class="section-tab"
:class="{ active: group.key === groupKey }"
@click="openGroup(group)"
>
{{ group.label }}
{{ formatIndexedLabel(group.label, index) }}
</button>
</nav>
<nav v-if="tabItems.length > 1" class="section-tabs-row" aria-label="页面">
<button
v-for="item in tabItems"
v-for="(item, index) in tabItems"
:key="item.pageRoute"
class="section-tab"
:class="{ active: isCurrent(item.pageRoute) }"
:title="item.caption || item.label"
@click="router.push(item.pageRoute)"
>
{{ item.label }}
{{ formatIndexedLabel(item.label, index) }}
</button>
</nav>
</div>
@@ -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}/`)
}
@@ -22,11 +22,11 @@
</div>
<div class="nav-item" @click="$emit('navChange', 'expert')">
<span class="nav-icon">🧠</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">专员·技能·APP·连接器</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">专员·技能·xApp·连接器</span>
</div>
<div class="nav-item" @click="$emit('navChange', 'apps')">
<span class="nav-icon">🧩</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">长程APP</span>
<span class="nav-label" :class="{ 'hide': sidebarCollapsed }">长程 xApp</span>
</div>
<div class="nav-item" @click="$emit('navChange', 'library')">
<span class="nav-icon">📚</span>
@@ -78,7 +78,7 @@ const visible = computed(() => Boolean(kind.value))
const label = computed(() => {
if (kind.value === 'specialist') {
if (isGeneralAssistant.value) return '通用助手'
if (isGeneralAssistant.value) return '通用专员'
return currentSpecialist.value?.label || specialistEntry.value?.label || '已选专员'
}
return skillEntry.value?.label || ''
@@ -88,7 +88,7 @@ const badge = computed(() => (kind.value === 'specialist' ? '专' : '技'))
const hint = computed(() => {
if (kind.value === 'specialist') {
if (isGeneralAssistant.value) return '当前专员:通用助手'
if (isGeneralAssistant.value) return '当前专员:通用专员'
return specialistEntry.value ? `当前专员:${label.value} · 点开它的工作台` : `当前专员:${label.value}`
}
return skillEntry.value ? `当前技能:${label.value}` : label.value
@@ -67,7 +67,7 @@ onMounted(() => {
}
.llm-route-select {
width: 92px;
width: 176px;
border: none;
outline: none;
background: transparent;
@@ -76,4 +76,8 @@ onMounted(() => {
color: #303133;
cursor: pointer;
}
.llm-route-picker.compact .llm-route-select {
width: 156px;
}
</style>
@@ -121,7 +121,6 @@ import { ElMessageBox } from 'element-plus'
import {
ArrowRight, Avatar, Check, Connection, EditPen, MagicStick, Plus, Search, Setting, UploadFilled,
} from '@element-plus/icons-vue'
import { getStaticSkillByKey } from '@/skills/core/builtin'
import { listConnectors } from '@/connectors/api/connector'
import { useAuthStore } from '@/store/auth'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
@@ -179,19 +178,6 @@ function routable(path) {
}
}
const generalAssistantItem = computed(() => {
const item = skillCatalog.getByKey('smart-assistant') || getStaticSkillByKey('smart-assistant')
if (!item) return null
return {
key: GENERAL_ASSISTANT_KEY,
label: item.label,
caption: item.caption,
display_code: '专00',
eailogic_code: 'EAI-S-00',
objectEntryRoute: item.objectEntryRoute,
}
})
const entries = computed(() => {
const list = []
if (props.modes.length) {
@@ -211,10 +197,7 @@ const entries = computed(() => {
type: 'resource',
searchPlaceholder: '搜索专员',
empty: '暂无可用专员',
items: [
...(generalAssistantItem.value ? [generalAssistantItem.value] : []),
...specialistCatalog.specialists
.filter((specialist) => routable(specialist.objectEntryRoute))
items: specialistCatalog.specialists
// key 要在切换时用:选了专员就等于把当前任务改挂到它名下。
.map((specialist) => ({
key: specialist.key,
@@ -224,7 +207,6 @@ const entries = computed(() => {
eailogic_code: specialist.eailogic_code,
objectEntryRoute: specialist.objectEntryRoute,
})),
],
manage: { label: '专员市场', pageRoute: '/market' },
})
}
@@ -11,7 +11,11 @@
靠推断(见 utils/specialistFlow.js 的 markFlowState),并且推断出来的那一步
在界面上要标出来,不能让它冒充实测进度。
-->
<div class="specialist-panel">
<OfficialAccountSpecialistPanel
v-if="isOfficialAccountSpecialist"
@collapse="emit('collapse')"
/>
<div v-else class="specialist-panel">
<div class="sp-tabs">
<button
class="sp-tab"
@@ -158,6 +162,7 @@ import { storeToRefs } from 'pinia'
import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { artifactsOfStep, buildActionRecords, buildResultRecords, markFlowState } from '@/specialists/runtime/specialistFlow'
import OfficialAccountSpecialistPanel from '@/components/chat/OfficialAccountSpecialistPanel.vue'
const emit = defineEmits(['collapse'])
@@ -176,6 +181,7 @@ const {
const tab = ref('flow')
const specialist = computed(() => currentSpecialist.value)
const isOfficialAccountSpecialist = computed(() => String(currentSpecialistKey.value || '').trim() === 'wechat-official-account')
const skill = computed(() =>
skillCatalog.getByKey(currentFocusedObjectKey.value)
)
@@ -88,7 +88,7 @@ const props = defineProps({
},
emptyText: {
type: String,
default: '你可以一边操作当前 APP,一边在这里让 AI 帮你分析、解释和推进。',
default: '你可以一边操作当前 xApp,一边在这里让 AI 帮你分析、解释和推进。',
},
starterPrompts: {
type: Array,
+16 -19
View File
@@ -2,7 +2,7 @@
//
// 侧边栏只承载一级导航(navSections 里的每一项),内容区顶部那排 tab 直接读每个一级项
// 自己的 items。之前「专员·技能·APP·连接器」做成了 groups 套 items 的两层结构,
// 现在把它收平:4 个 tab(专员 / 技能 / APP / 连接器)都只是“浏览配置”的目录页入口,
// 现在把它收平:5 个 tab(专员 / 技能 / xApp / 连接器 / 工坊)都只是“浏览配置”的目录页入口,
// 不再在这一层承担“进入可执行页面”的职责。
import { Avatar, CirclePlus, Collection, Grid, More, Share } from '@element-plus/icons-vue'
import {
@@ -30,25 +30,26 @@ export const navSections = [
},
{
key: 'object-catalog',
label: '3. 专员·技能·APP·连接器',
label: '3. 专员·技能·xApp·连接器',
icon: Avatar,
// 这一项现在只负责“看目录 / 看配置”,不直接跳进使用页。
// 内容区顶部用一排 tab 在 4 张卡片页之间切换。
// 内容区顶部用一排 tab 在目录页、工坊之间切换。
items: [
{ label: '专员', pageRoute: '/catalog/specialists' },
{ label: '技能', pageRoute: '/catalog/skills' },
{ label: 'APP', pageRoute: '/catalog/xapps' },
{ label: '连接器', pageRoute: '/catalog/connectors' },
{ label: '3.1 专员', pageRoute: '/catalog/specialists' },
{ label: '3.2 技能', pageRoute: '/catalog/skills' },
{ label: '3.3 xApp', pageRoute: '/catalog/xapps' },
{ label: '3.4 连接器', pageRoute: '/catalog/connectors' },
{ label: '3.5 工坊', pageRoute: '/studio', adminOnly: true },
],
},
{
key: 'xapps',
label: '4. APP',
label: '4. xApp',
icon: Grid,
items: [
{ label: 'APP目录', pageRoute: XAPP_DIRECTORY_ROUTE },
{ label: '内部培训APP', pageRoute: INTERNAL_TRAINING_XAPP_ROOT },
{ label: '内部考试APP', pageRoute: INTERNAL_EXAM_XAPP_ROOT },
{ label: 'xApp目录', pageRoute: XAPP_DIRECTORY_ROUTE },
{ label: '内部培训 xApp', pageRoute: INTERNAL_TRAINING_XAPP_ROOT },
{ label: '内部考试 xApp', pageRoute: INTERNAL_EXAM_XAPP_ROOT },
],
},
{
@@ -69,14 +70,10 @@ export const navSections = [
label: '6. 后台管理',
icon: More,
items: [
{ label: '治理总览', pageRoute: '/admin', adminOnly: true },
{ label: '工坊', pageRoute: '/studio', adminOnly: true },
{ label: '用户管理', pageRoute: '/system/users', adminOnly: true },
{ label: '部门管理', pageRoute: '/system/departments', adminOnly: true },
{ label: '岗位管理', pageRoute: '/system/positions', adminOnly: true },
{ label: '公司配置', pageRoute: '/system/company-config', adminOnly: true },
{ label: '系统配置', pageRoute: '/system/config', adminOnly: true },
{ label: 'AI 用量', pageRoute: '/system/ai-usage', adminOnly: true },
{ label: '6.1 用户管理', pageRoute: '/system/users', adminOnly: true },
{ label: '6.2 组织与公司配置', pageRoute: '/system/organization', adminOnly: true },
{ label: '6.3 系统配置', pageRoute: '/system/config', adminOnly: true },
{ label: '6.4 AI后台管理', pageRoute: '/system/ai-admin', adminOnly: true },
],
},
]
@@ -6,10 +6,8 @@
// 模板只存 key(专员 key / 技能 key),名字展示时再去查 —— 专员名以后改了,
// 模板里不会留下一份对不上的旧名字。
import {
DataAnalysis,
Document,
EditPen,
Notebook,
Promotion,
Reading,
} from '@element-plus/icons-vue'
@@ -23,12 +21,28 @@ export function normalizeSkillKey(value) {
return String(value || '').trim()
}
export function normalizeSpecialistKey(value) {
return String(value || '').trim()
}
export function normalizeSkillKeys(values = []) {
return [...new Set((values || []).map((item) => normalizeSkillKey(item)).filter(Boolean))]
}
export function normalizeSpecialistKeys(values = []) {
return [...new Set((values || []).map((item) => normalizeSpecialistKey(item)).filter(Boolean))]
}
function createProjectTemplate(template) {
return {
...template,
specialistKeys: normalizeSpecialistKeys(template?.specialistKeys),
skillKeys: normalizeSkillKeys(template?.skillKeys),
}
}
export const projectTemplates = [
{
createProjectTemplate({
id: 'contract-review',
title: '合同审查',
desc: '逐条比对条款、标注风险,产出修订建议',
@@ -37,8 +51,8 @@ export const projectTemplates = [
'本项目的输出以合同审查口径为准:始终站在甲方立场逐条比对条款,标出偏离标准模板的地方,并按「高风险 / 需确认 / 可接受」三档给结论。引用条款时写明第几条,不要转述。',
specialistKeys: ['contract-review'],
skillKeys: ['contract-review'],
},
{
}),
createProjectTemplate({
id: 'solution-proposal',
title: '售前方案',
desc: '从客户需求到方案与报价的完整链路',
@@ -47,8 +61,8 @@ export const projectTemplates = [
'本项目面向售前场景:每个方案都要先复述客户原始诉求,再给出方案,最后列出报价与交付周期。涉及产品能力时只能引用资料库里已有的产品资料,不确定的地方标「待确认」而不是编。',
specialistKeys: ['solution-proposal'],
skillKeys: ['report-generation'],
},
{
}),
createProjectTemplate({
id: 'internal-training',
title: '培训交付',
desc: '课件准备、现场记录到交付归档',
@@ -57,8 +71,8 @@ export const projectTemplates = [
'本项目按培训交付流程走:课前确认学员与大纲,课中记录要点与答疑,课后 24 小时内出交付包。所有产出用统一模板,标题格式「〈客户〉-〈课程〉-〈环节〉」。',
specialistKeys: [],
skillKeys: ['document-translate', 'audio-transcribe'],
},
{
}),
createProjectTemplate({
id: 'content-operations',
title: '内容运营',
desc: '选题、写稿、校对到发布复盘',
@@ -67,43 +81,21 @@ export const projectTemplates = [
'本项目的内容一律先出提纲再成稿,成稿后必须过一遍校对。语气保持克制,不堆形容词,不写没有出处的数据。每篇发布后补一条复盘:阅读表现、读者反馈、下次要改什么。',
specialistKeys: ['wechat-official-account'],
skillKeys: ['copy-proofreading'],
},
{
id: 'knowledge-operations',
title: '知识运营',
desc: '素材归集、字段提取与知识库沉淀',
icon: Notebook,
prompt:
'本项目负责把零散素材整理成可检索的知识:统一术语表,同一实体在全文用同一个叫法;每条结论都要能追回到原始素材。提取字段时先用批量提取跑一遍,再人工复核异常项。',
specialistKeys: ['knowledge-operations'],
skillKeys: ['batch-extract'],
},
{
id: 'report-generation',
title: '报告生成',
desc: '经营数据、周报月报与专项分析',
icon: DataAnalysis,
prompt:
'本项目的报告统一结构:结论在前、论据在后、附录放明细。数字必须带口径和时间范围,同比环比要写清楚对比基准。没有数据支撑的判断放在「观察」一节,不要混进结论。',
specialistKeys: ['report-generation'],
skillKeys: ['report-generation', 'document-translate'],
},
{
id: 'presentation-briefing',
title: '汇报 PPT',
desc: '面向管理层、客户和培训场景的演示稿交付',
icon: Promotion,
prompt:
'本项目按汇报交付口径推进:先确认汇报对象、场景、页数和主线,再生成页面结构、每页要点和讲稿备注。所有数字、时间和范围必须带口径;没有依据的地方明确标“待补”,不要为了好看自行补齐。',
specialistKeys: ['presentation-briefing'],
skillKeys: ['ppt-generation', 'mind-map'],
},
}),
]
export function findProjectTemplate(id) {
return projectTemplates.find((template) => template.id === id) || null
}
export function listProjectTemplateObjectRefs() {
return projectTemplates.map((template) => ({
templateId: template.id,
specialistKeys: [...template.specialistKeys],
skillKeys: [...template.skillKeys],
}))
}
// 新建项目弹窗里那个「项目模板」下拉的默认项。选中它 = 不套模板,
// 指令和三个对象列表都由用户自己填。
//
@@ -161,141 +161,6 @@ const workspaceDemoData = {
},
},
},
'knowledge-operations': {
title: '知识运营专员',
caseTitle: 'Q3 规则库与对象字典整理',
subtitle: '围绕资料整理、对象映射、证据抽取和规则同步的知识运营工作台。',
cases: [
{ id: 'ko-01', title: 'Q3 规则库与对象字典整理', owner: '知识管理员', priority: 'P1', due: '今天 18:00', summary: '合同、客户、课程三类对象映射需要统一口径', status: '对象映射', contextPatch: { outputs: ['对象字典 v3', '规则口径清单'], artifacts: ['对象映射表.xlsx'], risks: ['客户对象字段存在重名'], events: ['09:20 完成对象字段比对'] } },
{ id: 'ko-02', title: '售前资料证据抽取', owner: '知识运营', priority: 'P2', due: '明天 12:00', summary: '把售前方案与会议纪要沉淀成可引用证据片段', status: '证据整理', contextPatch: { outputs: ['Evidence 索引'], artifacts: ['证据索引.json'], risks: ['两个 PDF 页码缺失'], events: ['10:10 抽取证据片段 42 条'] } },
{ id: 'ko-03', title: '培训规则同步检查', owner: '教研运营', priority: 'P2', due: '周五', summary: '考试规则与课程标签需要同步到知识底座', status: '规则同步', contextPatch: { outputs: ['规则同步摘要'], artifacts: ['规则变更说明.md'], risks: ['旧版规则仍在被引用'], events: ['08:45 拉取考试规则版本'] } },
],
tabs: ['进度', '时间线', '对象字典', '回放'],
stageSummary: [
{ name: '资料盘点', status: 'completed' },
{ name: '对象映射', status: 'active' },
{ name: '证据抽取', status: 'active' },
{ name: '规则同步', status: 'waiting' },
{ name: '发布归档', status: 'pending' },
],
taskCards: [
{ title: '对象映射', status: '进行中', detail: '合同、客户、课程三个对象族正在统一字段口径', tone: 'primary' },
{ title: '证据抽取', status: '已完成', detail: '新增 42 条可引用证据片段并完成哈希入库', tone: 'success' },
{ title: '规则同步', status: '待确认', detail: '等待培训负责人确认考试规则新版本', tone: 'warning' },
],
focusPanels: [
{ title: '当前关注', content: '对象字典要成为所有专员共享的稳定语义层,避免字段漂移。' },
{ title: '知识焦点', content: '优先补齐售前、合同、培训三类高频 Evidence 与 PolicyRule。' },
{ title: '下一步', content: '完成规则同步后,进入知识台发布与版本归档。' },
],
quickViews: ['对象字典', '证据索引', '规则变更', '待确认映射'],
relatedItems: ['合同对象字典', '售前资料池', '考试规则版本'],
context: {
outputs: ['对象字典 v3', 'Evidence 索引', '规则同步摘要'],
artifacts: ['对象映射表.xlsx', '证据索引.json', '规则变更说明.md'],
evidence: ['e-9201 售前纪要', 'e-9202 合同模板条款', 'e-9203 课程规则文档'],
risks: ['客户对象字段存在重名', '两个 PDF 页码缺失', '旧版规则仍在被引用'],
checkpoints: ['知识管理员确认', '培训负责人规则审批'],
relatedObjects: ['ObjectDictionary: Contract/Customer/Course', 'PolicyRule: 考试规则', 'EvidenceSet: 售前资料'],
events: ['09:20 完成对象字段比对', '10:10 抽取证据片段 42 条', '10:40 生成规则同步摘要'],
bottomPanel: {
timeline: ['08:50 初始化知识运营任务', '09:20 更新对象字典', '10:10 完成证据抽取'],
replay: ['回放节点 1: ScanAssets', '回放节点 2: SyncObjects', '回放节点 3: BuildEvidence'],
trace: ['plugin.knowledge.scanAssets()', 'plugin.object.syncDictionary()', 'plugin.evidence.buildIndex()'],
logs: ['[INFO] object dictionary synced', '[INFO] 42 evidence entries created', '[WARN] rule version pending approval'],
},
},
},
'process-coordination': {
title: '流程推进专员',
caseTitle: '本周跨部门 checkpoint 推进',
subtitle: '围绕跨部门任务推进、阻塞升级、人工确认与截止期跟踪的流程协同工作台。',
cases: [
{ id: 'pc-01', title: '本周跨部门 checkpoint 推进', owner: '项目运营', priority: 'P1', due: '今天 17:30', summary: '售前、法务、交付三个事项存在 checkpoint 等待确认', status: '阻塞清理', contextPatch: { outputs: ['催办摘要'], artifacts: ['checkpoint-brief.md'], risks: ['两个确认超时'], events: ['09:10 自动生成催办列表'] } },
{ id: 'pc-02', title: '法务审批链超时事项', owner: '流程运营', priority: 'P2', due: '今天 15:00', summary: '需要升级 3 个超时审批并同步责任人', status: '人工催办', contextPatch: { outputs: ['审批升级清单'], artifacts: ['审批升级.xlsx'], risks: ['法务负责人未读提醒'], events: ['10:00 推送升级通知'] } },
{ id: 'pc-03', title: '培训交付返工跟进', owner: '项目助理', priority: 'P2', due: '周三', summary: '两项交付包返工需要重新排期并通知相关专员', status: '重新排期', contextPatch: { outputs: ['返工排期建议'], artifacts: ['返工计划.md'], risks: ['返工将影响周报时间'], events: ['08:35 重新计算依赖链'] } },
],
tabs: ['进度', '时间线', '阻塞点', '回放'],
stageSummary: [
{ name: '任务聚合', status: 'completed' },
{ name: '阻塞识别', status: 'completed' },
{ name: '催办升级', status: 'active' },
{ name: '人工确认', status: 'waiting' },
{ name: '闭环归档', status: 'pending' },
],
taskCards: [
{ title: '阻塞识别', status: '已完成', detail: '共识别 6 个待处理 checkpoint,2 个超时事项', tone: 'success' },
{ title: '催办升级', status: '进行中', detail: '已向法务、售前、交付三类责任人发送升级通知', tone: 'primary' },
{ title: '人工确认', status: '等待中', detail: '等待责任人逐项确认下一步动作与时间承诺', tone: 'warning' },
],
focusPanels: [
{ title: '当前关注', content: '流程推进专员要把跨专员的阻塞和 checkpoint 聚合成统一可见面板。' },
{ title: '推进焦点', content: '优先清理超时事项,再同步返工排期与责任人。' },
{ title: '下一步', content: '完成责任人确认后自动回写到对应专员事项。' },
],
quickViews: ['阻塞清单', '超时事项', '责任人分布', '返工影响'],
relatedItems: ['售前方案评审', '合同审查确认点', '培训返工计划'],
context: {
outputs: ['催办摘要', '审批升级清单', '返工排期建议'],
artifacts: ['checkpoint-brief.md', '审批升级.xlsx', '返工计划.md'],
evidence: ['e-9301 checkpoint 记录', 'e-9302 审批日志', 'e-9303 项目返工单'],
risks: ['两个确认超时', '法务负责人未读提醒', '返工将影响周报时间'],
checkpoints: ['项目运营确认升级', '责任人逐项确认'],
relatedObjects: ['CheckpointQueue', 'TaskDependency', 'EscalationPolicy'],
events: ['09:10 自动生成催办列表', '10:00 推送升级通知', '10:35 更新返工排期'],
bottomPanel: {
timeline: ['09:00 汇总跨专员事项', '09:10 生成催办清单', '10:00 发送升级通知'],
replay: ['回放节点 1: AggregateTasks', '回放节点 2: DetectBlocking', '回放节点 3: EscalateOwners'],
trace: ['plugin.flow.aggregateTasks()', 'plugin.flow.detectBlocking()', 'connector.wecom.sendEscalation()'],
logs: ['[INFO] 6 checkpoints aggregated', '[WARN] 2 overdue confirmations', '[INFO] escalation notices sent'],
},
},
},
'report-generation': {
title: '报告生成专员',
caseTitle: '周度经营与交付复盘报告',
subtitle: '围绕日报、周报、复盘和交付说明自动生成的报告编制工作台。',
cases: [
{ id: 'rg-01', title: '周度经营与交付复盘报告', owner: '运营分析师', priority: 'P1', due: '今天 19:00', summary: '需要汇总售前、法务、培训、履约四类事项的结果输出', status: '报告汇总', contextPatch: { outputs: ['周报摘要'], artifacts: ['周度复盘报告.pdf'], risks: ['履约数据仍在同步'], events: ['09:50 生成周报大纲'] } },
{ id: 'rg-02', title: '训练营日报自动生成', owner: '班主任', priority: 'P2', due: '今天 20:30', summary: '根据课程、考试、互动情况生成班级日报', status: '日报生成', contextPatch: { outputs: ['班级日报'], artifacts: ['日报.md'], risks: ['两名学员互动记录缺失'], events: ['10:20 拉取课程互动数据'] } },
{ id: 'rg-03', title: '客户交付说明包', owner: '客户成功', priority: 'P2', due: '明天', summary: '需要把本周主要交付物和风险说明整理成客户可读版本', status: '客户说明', contextPatch: { outputs: ['客户说明摘要'], artifacts: ['客户交付说明.pptx'], risks: ['一个风险项尚未关闭'], events: ['08:55 生成客户说明初稿'] } },
],
tabs: ['进度', '时间线', '报告版本', '回放'],
stageSummary: [
{ name: '数据汇总', status: 'completed' },
{ name: '结构生成', status: 'completed' },
{ name: '内容编制', status: 'active' },
{ name: '风险校验', status: 'waiting' },
{ name: '导出归档', status: 'pending' },
],
taskCards: [
{ title: '数据汇总', status: '已完成', detail: '已聚合售前、法务、培训、履约四类输出与产物', tone: 'success' },
{ title: '内容编制', status: '进行中', detail: '正在生成经营摘要、交付亮点和风险说明章节', tone: 'primary' },
{ title: '风险校验', status: '待处理', detail: '等待履约专员同步最后一个异常处理结果', tone: 'warning' },
],
focusPanels: [
{ title: '当前关注', content: '报告生成专员要负责把多专员输出收拢成可交付的统一文档。' },
{ title: '报告焦点', content: '不仅生成文本,还要生成面向管理层与客户的不同版本。' },
{ title: '下一步', content: '完成风险校验后自动导出 PDF / PPT / Markdown 三种格式。' },
],
quickViews: ['报告版本', '数据来源', '待确认风险', '导出记录'],
relatedItems: ['售前方案 v3', '训练营周报', '客户状态报告'],
context: {
outputs: ['周报摘要', '班级日报', '客户说明摘要'],
artifacts: ['周度复盘报告.pdf', '日报.md', '客户交付说明.pptx'],
evidence: ['e-9401 售前方案', 'e-9402 培训周报', 'e-9403 履约状态说明'],
risks: ['履约数据仍在同步', '两名学员互动记录缺失', '一个风险项尚未关闭'],
checkpoints: ['运营分析师审阅', '客户成功确认外发版本'],
relatedObjects: ['ReportTemplate: WeeklyReview', 'ArtifactBundle: CustomerPack', 'EvidenceSet: WeeklySummary'],
events: ['09:50 生成周报大纲', '10:20 拉取课程互动数据', '10:40 导出客户说明初稿'],
bottomPanel: {
timeline: ['09:20 汇总多专员产物', '09:50 生成周报大纲', '10:40 输出客户说明初稿'],
replay: ['回放节点 1: CollectArtifacts', '回放节点 2: DraftReport', '回放节点 3: ExportFormats'],
trace: ['plugin.report.collectArtifacts()', 'plugin.report.generateDraft()', 'plugin.report.exportBundle()'],
logs: ['[INFO] report sources collected', '[INFO] weekly report drafted', '[WARN] logistics data still syncing'],
},
},
},
'customer-followup': {
title: '客户跟进专员',
caseTitle: '本周客户跟进与会议预约',
@@ -346,7 +211,7 @@ const workspaceDemoData = {
},
},
'resume-processor': {
title: '招聘筛选专员',
title: '简历筛选与招聘专员',
caseTitle: '招聘入口归档、候选人筛选与面试安排',
subtitle: '先收拢招聘邮箱和候选人资料,再按岗位要求完成筛选、排序并推进面试安排。',
cases: [
@@ -390,6 +255,59 @@ const workspaceDemoData = {
},
}
const workspaceObjectRefs = {
'contract-review': {
specialistKeys: ['contract-review'],
skillKeys: ['contract-review', 'contract-brief', 'batch-extract'],
xappKeys: [],
},
'solution-proposal': {
specialistKeys: ['solution-proposal'],
skillKeys: ['ppt-generation', 'mind-map'],
xappKeys: [],
},
'internal-training': {
specialistKeys: [],
skillKeys: ['document-translate', 'audio-transcribe'],
xappKeys: ['internal-training'],
},
'customer-followup': {
specialistKeys: ['customer-followup'],
skillKeys: ['progress-report', 'report-generation'],
xappKeys: [],
},
'resume-processor': {
specialistKeys: ['resume-processor'],
skillKeys: ['ocr-understanding', 'batch-extract', 'table-cleanup', 'interview-summary'],
xappKeys: [],
},
'/knowledge-hub': {
specialistKeys: [],
skillKeys: [],
xappKeys: ['knowledge-hub'],
},
'/knowledge-management': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
'/market': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
'/studio': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
'/console': {
specialistKeys: [],
skillKeys: [],
xappKeys: [],
},
}
const genericSurfaceContext = {
'/knowledge-hub': {
outputs: ['知识问答结果', '上传文档摘要', '会话沉淀结果'],
@@ -503,3 +421,22 @@ export function getWorkspaceDescriptor(path, caseId = '') {
void caseId
return null
}
export function getWorkspaceObjectRefs(workspaceKey) {
const refs = workspaceObjectRefs[workspaceKey]
if (!refs) {
return { specialistKeys: [], skillKeys: [], xappKeys: [] }
}
return {
specialistKeys: [...refs.specialistKeys],
skillKeys: [...refs.skillKeys],
xappKeys: [...refs.xappKeys],
}
}
export function listWorkspaceObjectRefs() {
return Object.keys(workspaceObjectRefs).map((workspaceKey) => ({
workspaceKey,
...getWorkspaceObjectRefs(workspaceKey),
}))
}
@@ -269,6 +269,7 @@
:items="activeSection?.items || []"
:groups="activeSection?.groups || []"
:group-key="activeGroup?.key || ''"
:section-label="activeSection?.label || ''"
/>
<div v-if="shouldShowTaskTitlebar && currentTaskDisplayTitle" class="wb-task-titlebar" :title="currentTaskDisplayTitle">
<div class="wb-task-titlebar-text">{{ currentTaskDisplayTitle }}</div>
@@ -812,7 +813,7 @@ function resolveSurfaceChatContext(path) {
title: '培训协作',
subtitle: '边看课程,边让 AI 帮你总结、提炼和辅导。',
placeholder: '例如:帮我总结这门课程的重点,并给出陪练建议',
emptyText: '进入培训 APP 后,右侧会一直保留 AI 对话栏,方便你边学边问。',
emptyText: '进入培训 xApp 后,右侧会一直保留 AI 对话栏,方便你边学边问。',
starterPrompts: [
'帮我总结这门课程的 3 个重点',
'把这节课整理成销售陪练提纲',
@@ -851,7 +852,7 @@ function resolveSurfaceChatContext(path) {
title: '考试协作',
subtitle: '边看考试页,边让 AI 帮你解释规则、复盘和出练习建议。',
placeholder: '例如:根据当前考试情况帮我做一版复盘建议',
emptyText: '考试 APP 里也会保留 AI 对话栏,方便把练习、自测和复盘连起来。',
emptyText: '考试 xApp 里也会保留 AI 对话栏,方便把练习、自测和复盘连起来。',
starterPrompts: [
'根据当前错题情况给我复盘建议',
'帮我生成一版考前冲刺清单',
@@ -25,7 +25,6 @@ const routes = [
{ path: 'xapps', name: 'XAppDirectoryPage', component: () => import('@/xapps/views/XAppDirectoryPage.vue') },
{ path: 'overview', redirect: '/projects' },
{ path: 'my/settings', name: 'MySettingsPage', component: () => import('@/views/me/MySettingsPage.vue') },
{ path: 'admin', name: 'AdminOverviewPage', component: () => import('@/views/system/AdminOverviewPage.vue'), meta: { requiresAdmin: true } },
{ path: 'studio', name: 'StudioPage', component: () => import('@/views/workbench/StudioPage.vue'), meta: { requiresAdmin: true } },
{ path: 'console', name: 'ConsolePage', component: () => import('@/views/workbench/ConsolePage.vue'), meta: { requiresAdmin: true } },
{ path: 'catalog/specialists', name: 'ObjectCatalogSpecialistsPage', component: () => import('@/specialists/views/SpecialistCatalogPage.vue') },
@@ -41,7 +40,6 @@ const routes = [
{
path: INTERNAL_TRAINING_XAPP_ROOT.slice(1),
component: () => import('@/xapps/views/TrainingXAppShell.vue'),
redirect: INTERNAL_TRAINING_MODULE_ROUTES.courses,
children: [
{ path: 'courses', name: 'TrainingCourseListPage', component: () => import('@/views/salesTrain/CourseListPage.vue') },
{ path: 'courses/:id', name: 'TrainingCourseDetailPage', component: () => import('@/views/salesTrain/CourseDetailPage.vue') },
@@ -53,7 +51,6 @@ const routes = [
{
path: INTERNAL_EXAM_XAPP_ROOT.slice(1),
component: () => import('@/xapps/views/ExamXAppShell.vue'),
redirect: INTERNAL_EXAM_MODULE_ROUTES.selfTest,
children: [
{ path: 'self-test', name: 'ExamSelfTestPage', component: () => import('@/views/exam/ExamSelfTestPage.vue') },
{ path: 'formal', name: 'ExamFormalPage', component: () => import('@/views/exam/ExamFormalPage.vue') },
@@ -69,12 +66,10 @@ const routes = [
{ path: 'knowledge/audit', name: 'KnowledgeMaterialAuditPage', component: () => import('@/views/knowledge/KnowledgeMaterialAuditPage.vue') },
{ path: 'knowledge/questions', name: 'KnowledgeQuestionBankPage', component: () => import('@/views/knowledge/KnowledgeQuestionBankPage.vue') },
{ path: 'knowledge/exam-records', name: 'KnowledgeExamRecordsPage', component: () => import('@/views/knowledge/KnowledgeExamRecordsPage.vue') },
{ path: 'system/positions', name: 'PositionManagementPage', component: () => import('@/views/system/PositionManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/departments', name: 'DepartmentManagementPage', component: () => import('@/views/system/DepartmentManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/organization', name: 'OrganizationManagementPage', component: () => import('@/views/system/OrganizationManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/users', name: 'UserManagementPage', component: () => import('@/views/system/UserManagementPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/company-config', name: 'CompanyConfigPage', component: () => import('@/views/system/CompanyConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/config', name: 'SystemConfigPage', component: () => import('@/views/system/SystemConfigPage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/ai-usage', name: 'AiUsagePage', component: () => import('@/views/system/AiUsagePage.vue'), meta: { requiresAdmin: true } },
{ path: 'system/ai-admin', name: 'AiAdminPage', component: () => import('@/views/system/AiAdminPage.vue'), meta: { requiresAdmin: true } },
],
},
]
@@ -7,3 +7,19 @@ export function listSkills(params) {
export function getSkillByKey(key) {
return http.get(`/skills/by-key/${key}`)
}
export function createSkillDefinition(data) {
return http.post('/admin/skills', data)
}
export function updateSkillDefinition(id, data) {
return http.put(`/admin/skills/${id}`, data)
}
export function deleteSkillDefinition(id) {
return http.delete(`/admin/skills/${id}`)
}
export function getSkillRemovePlan(id) {
return http.get(`/admin/skills/${id}/remove-plan`)
}
@@ -17,13 +17,17 @@ import progressReportManifest from '@/skills/packages/progress-report/manifest'
import contractBriefManifest from '@/skills/packages/contract-brief/manifest'
import interviewSummaryManifest from '@/skills/packages/interview-summary/manifest'
import policyRewriteManifest from '@/skills/packages/policy-rewrite/manifest'
import textToSpeechManifest from '@/skills/packages/text-to-speech/manifest'
// Register office skill execution builders (side-effect import).
// This centralizes all package→execution imports into one place.
import '@/skills/packages/officeExecutions'
export const builtinSkillManifests = [
const hiddenSkillManifests = [
smartAssistantManifest,
]
export const builtinSkillManifests = [
documentTranslateManifest,
copyProofreadingManifest,
audioTranscribeManifest,
@@ -40,9 +44,11 @@ export const builtinSkillManifests = [
contractBriefManifest,
interviewSummaryManifest,
policyRewriteManifest,
textToSpeechManifest,
]
const builtinSkillRegistry = createRegistry(builtinSkillManifests)
const allSkillRegistry = createRegistry([...hiddenSkillManifests, ...builtinSkillManifests])
// 通用助手的模式。快速模式直出回答,深思模式开启 thinking 多步规划。
export const assistantModes = [
@@ -60,5 +66,6 @@ export const staticSkillCatalog = withObjectCodes(
)
export function getStaticSkillByKey(key) {
return staticSkillCatalog.find((item) => item.key === key) || null
return withObjectCodes(allSkillRegistry.manifests(), { kind: 'skill' })
.find((item) => item.key === key) || null
}
@@ -8,7 +8,7 @@ export default createSkillManifest({
key: 'batch-extract',
label: '批量字段提取 → Excel',
caption: '上百份文件的关键信息,自动汇表。',
description: '上百份文件的关键信息,自动汇表。 操作:在「智能助手」中选择目标知识库 → 用自然语言告诉 AI 需要哪些字段 → AI 自动遍历每份文件,逐项提取,保存为 Excel',
description: '上百份文件的关键信息,自动汇表。 操作:在「通用专员」中选择目标知识库 → 用自然语言告诉 AI 需要哪些字段 → AI 自动遍历每份文件,逐项提取,保存为 Excel',
color: '#39b07a',
skillTags: ['字段抽取', '批量处理', '结构化输出', '异常回看'],
workflowSchema: [
@@ -9,7 +9,7 @@ export default createOfficeSkillManifest({
artifactType: 'presentation',
fileFormat: 'pptx',
color: '#4f46e5',
description: '从资料到演示文稿,一步到位。 操作:在「智能助手」中上传资料或录音 → 说"基于这份资料生成一份 8 页 PPT" → 下载 .pptx 文件,每页标题清晰、要点完整',
description: '从资料到演示文稿,一步到位。 操作:在「通用专员」中上传资料或录音 → 说"基于这份资料生成一份 8 页 PPT" → 下载 .pptx 文件,每页标题清晰、要点完整',
defaultPrompt: '为季度经营复盘生成一版 8 页 PPT 大纲',
resultTitle: '演示文稿骨架',
summary: '已按主题、受众和页数要求整理出演示逻辑,并给出可继续细化的文稿骨架。',
@@ -8,7 +8,7 @@ export default createSkillManifest({
key: 'report-generation',
label: '一键生成报告',
caption: '聊完天,PDF/Word 报告就写好了。',
description: '聊完天,PDF/Word 报告就写好了。 操作:在「智能助手」中上传资料或讨论要点 → 说"帮我做成 PDF 报告"或"整理成 Word" → AI 自动生成结构化内容,排版为正式文档,一键下载',
description: '聊完天,PDF/Word 报告就写好了。 操作:在「通用专员」中上传资料或讨论要点 → 说"帮我做成 PDF 报告"或"整理成 Word" → AI 自动生成结构化内容,排版为正式文档,一键下载',
color: '#9c27b0',
skillTags: ['日报周报', '复盘总结', '章节规划', '管理摘要'],
workflowSchema: [
@@ -6,9 +6,9 @@ import {
export default createSkillManifest({
key: 'smart-assistant',
label: '智能助手',
label: '通用专员',
caption: '说句话,复杂任务自动完成。',
description: '说句话,复杂任务自动完成。 操作:打开「智能助手」,用自然语言描述目标 → 可拖入多份文件、关联知识库 → 助手自动拆解为「计划→执行→验证→交付」,全程可见可控',
description: '说句话,复杂任务自动完成。 操作:打开「通用专员」,用自然语言描述目标 → 可拖入多份文件、关联知识库 → 专员自动拆解为「计划→执行→验证→交付」,全程可见可控',
color: '#409eff',
skillTags: ['任务澄清', '方案拆解', '对象分派', '交付起步'],
workflowSchema: [
@@ -57,7 +57,7 @@ export default createSkillManifest({
greeting: '博昇AI数字员工,您说,我做',
tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料',
openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。',
relationshipToUser: '你的通用协作搭档',
relationshipToUser: '你的通用协作专员',
starterPrompts: [
'帮我拆解这个任务,并列出下一步',
'把这段产品介绍整理成正式文案',
@@ -0,0 +1,52 @@
import {
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
export default createSkillManifest({
id: 'eai.text-to-speech',
code: 'EAI-T-215',
key: 'text-to-speech',
label: '文字转语音',
caption: '把文本合成为可播放、可下载的语音结果。',
description: '把文本合成为可播放、可下载的语音结果。 操作:输入要朗读的文字,可选音色和语速 → 系统生成 MP3 音频并回挂到任务产物里',
color: '#0ea5e9',
skillTags: ['语音合成', '音频产物', '朗读稿'],
workflowSchema: [
createSkillWorkflowStep({
title: '接收文本与参数',
detail: '先确认要朗读的文本内容,以及音色、语速等可选参数。',
expectedOutput: '可执行的合成输入',
}),
createSkillWorkflowStep({
title: '生成语音文件',
detail: '调用本地 TTS 引擎把文本合成为 MP3 音频。',
expectedOutput: '可播放的音频文件',
}),
createSkillWorkflowStep({
title: '回挂任务产物',
detail: '把音频结果写回当前任务,方便继续播放、下载和留档。',
expectedOutput: '任务音频产物',
}),
],
artifactSchema: [
createSkillArtifact({
title: '语音合成结果',
detail: '生成后的 MP3 音频,可播放、下载和继续引用。',
artifactType: 'audio',
fileFormat: 'mp3',
}),
],
interactionCard: {
greeting: '把要朗读的文字给我,我来生成语音文件。',
tagline: '支持把文本快速合成为 MP3 音频。',
openingPrompt: '把文本贴给我,也可以顺手告诉我想要的音色和语速。',
relationshipToUser: '你的语音合成技能',
starterPrompts: [
'把这段产品介绍转成普通话讲解音频',
'把这段通知合成为语音,语速稍微慢一点',
'帮我生成一版可直接播放的朗读音频',
],
},
})
@@ -149,7 +149,9 @@ export const useSkillCatalogStore = defineStore('skillCatalog', () => {
try {
const res = await listSkills()
skills.value = Array.isArray(res?.data)
? res.data.map((item) => normalizeSkill(item))
? res.data
.map((item) => normalizeSkill(item))
.filter((item) => item.key !== 'smart-assistant')
: []
loaded.value = true
} catch (error) {
@@ -160,7 +162,8 @@ export const useSkillCatalogStore = defineStore('skillCatalog', () => {
}
function getByKey(key) {
return skillMap.value[String(key || '').trim()] || null
const normalized = String(key || '').trim()
return skillMap.value[normalized] || getStaticSkillByKey(normalized) || null
}
return {
@@ -13,13 +13,17 @@ export function getSpecialistByKey(key) {
}
export function createSpecialist(data) {
return http.post('/specialists', data)
return http.post('/admin/specialists', data)
}
export function updateSpecialist(id, data) {
return http.put(`/specialists/${id}`, data)
return http.put(`/admin/specialists/${id}`, data)
}
export function deleteSpecialist(id) {
return http.delete(`/specialists/${id}`)
return http.delete(`/admin/specialists/${id}`)
}
export function getSpecialistRemovePlan(id) {
return http.get(`/admin/specialists/${id}/remove-plan`)
}
@@ -1,5 +1,6 @@
import { withObjectCodes } from '@/utils/objectCode'
import { createRegistry } from '@/specialists/core/registry'
import generalAssistantManifest from '@/specialists/packages/general-assistant/manifest'
import contractReviewManifest from '@/specialists/packages/contract-review/manifest'
import solutionProposalManifest from '@/specialists/packages/solution-proposal/manifest'
import wechatOfficialAccountManifest from '@/specialists/packages/wechat-official-account/manifest'
@@ -7,6 +8,7 @@ import customerFollowupManifest from '@/specialists/packages/customer-followup/m
import resumeProcessorManifest from '@/specialists/packages/resume-processor/manifest'
const builtinSpecialistRegistry = createRegistry([
generalAssistantManifest,
contractReviewManifest,
solutionProposalManifest,
wechatOfficialAccountManifest,
@@ -0,0 +1,26 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'general-assistant',
label: '通用专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '说句话,复杂任务自动完成。',
status: '默认值守',
risk: '按当前任务推进',
color: '#409eff',
stage: '默认值守',
progress: 100,
interactionCard: {
greeting: '博昇AI数字员工,您说,我做',
tagline: '今天帮你做些什么?描述你的任务,或先挂载技能与材料',
openingPrompt: '直接告诉我目标,或者把材料贴给我,我来帮你起步。',
relationshipToUser: '你的通用协作专员',
starterPrompts: [
'帮我拆解这个任务,并列出下一步',
'把这段产品介绍整理成正式文案',
'先帮我判断这个问题该找哪个专员或技能',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'knowledge-operations',
label: '知识运营专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '对象字典、证据索引与规则同步',
status: '4 个待整理',
risk: '1 个映射待确认',
color: '#8e6cf2',
stage: '证据整理',
progress: 63,
interactionCard: {
greeting: '我是知识运营专员,可以帮你整理对象字典、证据索引和规则映射。',
tagline: '把散的知识资产收成可查、可用、可治理的结构。',
openingPrompt: '告诉我这次要整理哪类知识资产,我先帮你搭索引。',
relationshipToUser: '你的知识治理协作搭档',
starterPrompts: [
'帮我整理这一批知识条目和标签',
'把这些规则映射成对象字典',
'先盘点一下这组资料的证据索引结构',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'presentation-briefing',
label: '汇报专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '经营汇报、方案汇报与培训课件统一出稿',
status: '2 个待成稿',
risk: '1 个页数待压缩',
color: '#4f46e5',
stage: '演示编排',
progress: 68,
interactionCard: {
greeting: '我是汇报专员,可以先判断汇报对象和页数,再把材料压成能讲、能交、能继续细化的演示骨架。',
tagline: '把散材料收成一版结构清楚、讲得出去的汇报 PPT。',
openingPrompt: '告诉我汇报对象、场景、目标和页数,我先帮你规划页面结构。',
relationshipToUser: '你的汇报交付协作搭档',
starterPrompts: [
'帮我做一版 8 页管理层汇报 PPT',
'把这份方案压成可讲的客户汇报结构',
'根据这批材料生成培训课件骨架和讲稿备注',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'process-coordination',
label: '流程推进专员',
tier: 'generic',
specialistMode: 'adw',
marketTag: '可升级',
summary: '跨部门催办、checkpoint 跟进与阻塞升级',
status: '6 个待跟进',
risk: '2 个阻塞',
color: '#14b8a6',
stage: '阻塞清理',
progress: 57,
interactionCard: {
greeting: '我是流程推进专员,可以帮你梳理 checkpoint、跟进阻塞并推动事项落地。',
tagline: '把多方协作里的待办、阻塞和升级路径理清楚。',
openingPrompt: '把当前事项、参与方和卡点告诉我,我先帮你排推进节奏。',
relationshipToUser: '你的流程推进协作搭档',
starterPrompts: [
'帮我梳理这个项目的推进 checkpoint',
'把当前阻塞整理成催办清单',
'根据这些会议纪要生成后续跟进事项',
],
},
})
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'report-generation',
label: '报告生成专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '可升级',
summary: '日报、周报、复盘和交付说明自动生成',
status: '3 个待输出',
risk: '0 个异常',
color: '#ec4899',
stage: '报告汇总',
progress: 76,
interactionCard: {
greeting: '我是报告生成专员,可以把过程材料整理成日报、周报、复盘和交付说明。',
tagline: '把分散信息压成结构完整、可直接提交的报告。',
openingPrompt: '把素材贴给我,或者告诉我报告对象、周期和格式要求。',
relationshipToUser: '你的报告整理协作搭档',
starterPrompts: [
'把今天的工作整理成日报',
'根据这些记录生成项目周报',
'帮我输出一版交付复盘说明',
],
},
})
@@ -2,7 +2,7 @@ import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'resume-processor',
label: '招聘筛选专员',
label: '简历筛选与招聘专员',
tier: 'industry',
specialistMode: 'dw',
marketTag: '试用',
@@ -13,10 +13,10 @@ export default createSpecialistManifest({
stage: '候选人筛选',
progress: 0,
interactionCard: {
greeting: '我是招聘筛选专员,可以先收拢招聘邮件与候选人资料,再完成筛选、排序和后续面试推进。',
greeting: '我是简历筛选与招聘专员,可以先收拢招聘邮件与候选人资料,再完成筛选、排序和后续面试推进。',
tagline: '把招聘入口整理、候选人筛选和后续推进收成一条闭环。',
openingPrompt: '告诉我岗位要求、招聘邮箱情况和筛选标准,我先帮你做一轮归档与初筛。',
relationshipToUser: '你的招聘筛选协作搭档',
relationshipToUser: '你的简历筛选与招聘协作搭档',
starterPrompts: [
'先把今天收到的招聘邮件和附件归档成候选人清单',
'根据这个 JD 帮我筛选一批候选人',
@@ -2,7 +2,7 @@ import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'wechat-official-account',
label: '公众号专员',
label: '公众号创作专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
@@ -13,7 +13,7 @@ export default createSpecialistManifest({
stage: '正文创作',
progress: 0,
interactionCard: {
greeting: '我是公众号专员,可以帮你把选题、提纲、正文和发布准备串起来。',
greeting: '我是公众号创作专员,可以帮你把选题、提纲、正文和发布准备串起来。',
tagline: '把内容创作流程从想法推进到可发布。',
openingPrompt: '告诉我主题、受众和语气,我先帮你起草内容结构。',
relationshipToUser: '你的内容创作协作搭档',
@@ -2,6 +2,7 @@ import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { listSpecialists } from '@/specialists/api/specialist'
import { staticSpecialistCatalog } from '@/specialists/core/builtin'
import { GENERAL_ASSISTANT_KEY } from '@/store/taskRuntime'
function safeParseObject(value, fallback = {}) {
if (value && typeof value === 'object' && !Array.isArray(value)) return value
@@ -36,10 +37,64 @@ function normalizeSpecialist(item) {
}
}
function applySystemSpecialistConvention(item, fallback = null) {
if (fallback?.label) {
item = {
...item,
label: fallback.label,
summary: item.summary || fallback.summary || '',
tier: item.tier || fallback.tier || '',
specialistMode: item.specialistMode || fallback.specialistMode || '',
objectEntryRoute: item.objectEntryRoute || fallback.objectEntryRoute || '',
marketTag: item.marketTag || fallback.marketTag || '',
interactionCard:
Object.keys(item.interactionCard || {}).length
? item.interactionCard
: (fallback.interactionCard || {}),
}
}
if (item.key !== GENERAL_ASSISTANT_KEY) return item
return {
...item,
label: '通用专员',
summary: item.summary || '说句话,复杂任务自动完成。',
tier: item.tier || 'generic',
specialistMode: item.specialistMode || 'dw',
objectEntryRoute: item.objectEntryRoute || '/home',
marketTag: item.marketTag || '已安装',
}
}
function mergeSpecialists(remoteItems = []) {
const fallbackMap = new Map(
staticSpecialistCatalog.map((item) => {
const normalized = applySystemSpecialistConvention(normalizeSpecialist(item))
return [normalized.key, normalized]
})
)
const merged = remoteItems.map((item) => {
const normalized = normalizeSpecialist(item)
const fallback = fallbackMap.get(normalized.key) || {}
return applySystemSpecialistConvention({
...fallback,
...normalized,
interactionCard:
Object.keys(normalized.interactionCard || {}).length
? normalized.interactionCard
: (fallback.interactionCard || {}),
}, fallback)
})
const seen = new Set(merged.map((item) => item.key))
const fallbacks = [...fallbackMap.values()].filter((item) => !seen.has(item.key))
return [...merged, ...fallbacks]
}
export const useSpecialistCatalogStore = defineStore('specialistCatalog', () => {
const loaded = ref(false)
const loading = ref(false)
const specialists = ref(staticSpecialistCatalog.map((item) => normalizeSpecialist(item)))
const specialists = ref(mergeSpecialists())
const specialistMap = computed(() =>
Object.fromEntries(specialists.value.map((item) => [item.key, item]))
@@ -51,13 +106,11 @@ export const useSpecialistCatalogStore = defineStore('specialistCatalog', () =>
loading.value = true
try {
const res = await listSpecialists()
specialists.value = Array.isArray(res?.data)
? res.data.map((item) => normalizeSpecialist(item))
: []
specialists.value = Array.isArray(res?.data) ? mergeSpecialists(res.data) : mergeSpecialists()
loaded.value = true
} catch (error) {
console.warn('load specialist catalog failed', error)
specialists.value = staticSpecialistCatalog.map((item) => normalizeSpecialist(item))
specialists.value = mergeSpecialists()
loaded.value = true
} finally {
loading.value = false
@@ -13,7 +13,7 @@ function readPersisted() {
}
}
const HOME_TAB = { pageRoute: '/home', label: '通用助手', closable: false }
const HOME_TAB = { pageRoute: '/home', label: '通用专员', closable: false }
// 本地存储里可能还残留旧版胶囊:以前每个具体执行页都会各占一个 tab。
// 现在执行面只保留 /home,这里在读取时统一折回一级项,避免升级后看到一排陈年胶囊。
@@ -1,40 +1,95 @@
const GENERIC_ROUTE_NAMES = new Set([
'默认',
'均衡',
'全能',
'推理',
'本地',
'快聊',
'绘图',
'OpenRouter',
'OpenAI',
'Ollama',
])
function normalizeProviderLabel(provider) {
const value = String(provider || '').trim().toLowerCase()
if (!value) return ''
if (value === 'ollama') return 'Ollama'
if (value === 'llamacpp') return 'llama.cpp'
if (value === 'lmuai') return 'LMUAI'
if (value === 'siliconflow') return 'SiliconFlow'
if (value === 'openrouter') return 'OpenRouter'
if (value === 'openai') return 'OpenAI'
return provider
}
function normalizeModelLabel(model) {
return String(model || '')
.trim()
.split('/')
.pop()
.replace(/text-embedding-3-small/gi, 'text-embedding-3-small')
.replace(/deepseek-v4-flash-0731/gi, 'DeepSeek V4 Flash 0731')
.replace(/deepseek-v4-flash/gi, 'DeepSeek V4 Flash')
.replace(/deepseek-v3.2/gi, 'DeepSeek V3.2')
.replace(/deepseek-flash/gi, 'DeepSeek Flash')
.replace(/gpt-5.4-image-2/gi, 'GPT 5.4 Image 2')
.replace(/gpt-5.4/gi, 'GPT 5.4')
.replace(/grok-4.20/gi, 'Grok 4.20')
.replace(/qwen3\.5-4b-q4_k_m\.gguf/gi, 'Qwen3.5 4B')
.replace(/qwen2.5:7b/gi, 'Qwen2.5 7B')
.replace(/bge-m3/gi, 'BGE-M3')
.replace(/llava/gi, 'LLaVA')
.replace(/flux-1.1-pro/gi, 'FLUX 1.1 Pro')
.trim()
}
function routeUsageLabel(aiRoute) {
const routeId = String(aiRoute?.ai_route_id || '').trim()
if (routeId === 'chat_route_ollama_qwen') return '本地对话'
if (routeId === 'chat_route_ollama_qwen_fast') return '本地快聊'
if (routeId === 'embed_route_ollama_bge_m3') return '本地向量'
const shortRouteName = String(aiRoute?.short_route_name || '').trim()
if (!shortRouteName || GENERIC_ROUTE_NAMES.has(shortRouteName)) return ''
return shortRouteName
}
function routeModelLabel(aiRoute) {
const fromModel = normalizeModelLabel(aiRoute?.model)
if (fromModel) return fromModel
return normalizeModelLabel(aiRoute?.short_model_name)
}
export function formatAiRouteShortLabel(aiRoute) {
if (!aiRoute) return '未设置'
if (aiRoute.ai_route_id === 'chat_route_auto' || aiRoute.ai_route_id === 'embed_route_auto') {
return '自动'
}
const shortRouteName = (aiRoute.short_route_name || '').trim()
const shortModelName = (aiRoute.short_model_name || '').trim()
if (shortRouteName && shortModelName) {
return `${shortRouteName}/${shortModelName}`
}
if (shortModelName) return shortModelName
if (shortRouteName) return shortRouteName
const model = (aiRoute.model || '').split('/').pop() || ''
const modelShort = model
.replace(/text-embedding-3-small/gi, 'Emb3')
.replace(/deepseek-v4-flash/gi, 'DS4F')
.replace(/deepseek-v3.2/gi, 'DSV3')
.replace(/gpt-5.4-image-2/gi, 'G图2')
.replace(/gpt-5.4/gi, 'GPT5')
.replace(/grok-4.20/gi, 'Grok4')
.replace(/qwen2.5:7b/gi, 'Qwen')
.replace(/bge-m3/gi, 'BGE')
.trim()
return modelShort || aiRoute.description || aiRoute.ai_route_id || '未设置'
const usage = routeUsageLabel(aiRoute)
const model = routeModelLabel(aiRoute)
if (usage && model) return `${usage} · ${model}`
if (model) return model
if (usage) return usage
return aiRoute.description || aiRoute.ai_route_id || '未设置'
}
export function formatAiRouteTitle(aiRoute) {
if (!aiRoute) return '未设置'
const provider = normalizeProviderLabel(aiRoute.provider)
const model = routeModelLabel(aiRoute)
if (aiRoute.ai_route_id === 'chat_route_auto' || aiRoute.ai_route_id === 'embed_route_auto') {
const detail = [provider, model].filter(Boolean).join(' / ')
const resolved = aiRoute.resolved_ai_route_id || ''
const detail = aiRoute.description || resolved || '自动选择当前最优可用模型'
return `自动 · ${detail}`
const suffix = detail || resolved || '自动选择当前最优可用模型'
return `自动 · 当前 ${suffix}`
}
const shortLabel = formatAiRouteShortLabel(aiRoute)
const detail = aiRoute.description || [aiRoute.provider, aiRoute.model].filter(Boolean).join(' / ') || aiRoute.ai_route_id
return shortLabel === detail ? detail : `${shortLabel} · ${detail}`
const usage = routeUsageLabel(aiRoute)
if (usage && provider && model) return `${usage} · ${provider} / ${model}`
if (usage && model) return `${usage} · ${model}`
if (provider && model) return `${model} · ${provider}`
return aiRoute.description || aiRoute.ai_route_id || '未设置'
}
@@ -60,6 +60,79 @@
/>
</div>
</el-card>
<el-dialog
v-model="quizVisible"
:title="result ? '考试结果' : (currentPaper?.name || '开始考试')"
width="820px"
top="4vh"
destroy-on-close
>
<template v-if="result">
<div class="result-summary">
<el-tag :type="result.passed ? 'success' : 'danger'" size="large">
{{ result.score }} / {{ result.total_score }} 分
</el-tag>
<div class="result-meta">
{{ result.passed ? '已通过' : '未通过' }},及格线 {{ result.pass_score }} 分,答对 {{ result.correct_count }} 题,答错 {{ result.wrong_count }} 题
</div>
</div>
<el-divider content-position="left">答题解析</el-divider>
<div v-for="(q, idx) in result.detail || []" :key="q.question_id || idx" class="question-item">
<div class="question-title">
{{ idx + 1 }}. {{ q.stem }}
<el-tag size="small" :type="q.is_correct ? 'success' : 'danger'">
{{ q.is_correct ? '正确' : '错误' }}
</el-tag>
</div>
<div class="detail-line">你的答案:{{ formatUserAnswer(q.user_answer) }}</div>
<div class="detail-line">正确答案:{{ formatAnswer(q.correct_answer) }}</div>
<div v-if="q.explanation" class="detail-line explain">解析:{{ q.explanation }}</div>
</div>
</template>
<template v-else>
<div class="paper-meta">
共 {{ questions.length }} 题,正式考试提交后将记录成绩。
</div>
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
<div class="question-title">{{ idx + 1 }}. {{ q.stem }}</div>
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="question-options">
<el-radio v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-radio>
</el-radio-group>
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="question-options">
<el-checkbox v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-checkbox>
</el-checkbox-group>
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="question-options">
<el-radio label="true">正确</el-radio>
<el-radio label="false">错误</el-radio>
</el-radio-group>
<el-input
v-else
v-model="answers[q.id]"
type="textarea"
:rows="4"
placeholder="请输入答案"
/>
</div>
</template>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">{{ result ? '关闭' : '取消' }}</el-button>
<el-button v-if="!result" type="primary" :loading="submitting" @click="submit">提交试卷</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -93,7 +166,7 @@ async function load() {
const exams = res.data || []
items.value = exams
.filter((p) => p.type === 'formal')
.slice((page.value - 1) * size.value, (page.value.value * size.value))
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = exams.filter((p) => p.type === 'formal').length
} catch (e) {
items.value = []
@@ -118,6 +191,7 @@ async function startExam(p) {
try {
const res = await examStart(p.id)
const d = res.data
result.value = null
sessionId.value = d.session_id
currentPaper.value = { name: d.exam_name }
questions.value = d.questions || []
@@ -150,6 +224,11 @@ async function submit() {
}
}
function closeDialog() {
quizVisible.value = false
result.value = null
}
function formatAnswer(correct) {
if (!correct) return '—'
if (correct.length === 1 && (correct[0] === 'true' || correct[0] === 'false')) {
@@ -158,6 +237,13 @@ function formatAnswer(correct) {
return correct.join('、')
}
function formatUserAnswer(ans) {
if (ans == null || ans === '') return '未作答'
if (Array.isArray(ans)) return ans.join('、')
if (ans === 'true' || ans === 'false') return ans === 'true' ? '正确' : '错误'
return String(ans)
}
onMounted(load)
</script>
@@ -175,4 +261,39 @@ onMounted(load)
display: flex;
justify-content: flex-start;
}
.paper-meta,
.result-summary {
margin-bottom: 12px;
}
.result-meta {
margin-top: 8px;
color: #606266;
}
.question-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.question-item:last-child {
border-bottom: none;
}
.question-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin-bottom: 10px;
}
.question-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-line {
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.detail-line.explain {
color: #909399;
}
</style>
@@ -110,7 +110,7 @@ async function load() {
const records = res.data || []
items.value = records
.filter((r) => !filters.examName || (r.exam_name || '').includes(filters.examName))
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = records.length
} catch (e) {
items.value = []
@@ -60,6 +60,79 @@
/>
</div>
</el-card>
<el-dialog
v-model="quizVisible"
:title="result ? '练习结果' : (currentPaper?.name || '开始练习')"
width="820px"
top="4vh"
destroy-on-close
>
<template v-if="result">
<div class="result-summary">
<el-tag :type="result.passed ? 'success' : 'danger'" size="large">
{{ result.score }} / {{ result.total_score }} 分
</el-tag>
<div class="result-meta">
{{ result.passed ? '已通过' : '未通过' }},答对 {{ result.correct_count }} 题,答错 {{ result.wrong_count }} 题
</div>
</div>
<el-divider content-position="left">答题解析</el-divider>
<div v-for="(q, idx) in result.detail || []" :key="q.question_id || idx" class="question-item">
<div class="question-title">
{{ idx + 1 }}. {{ q.stem }}
<el-tag size="small" :type="q.is_correct ? 'success' : 'danger'">
{{ q.is_correct ? '正确' : '错误' }}
</el-tag>
</div>
<div class="detail-line">你的答案:{{ formatUserAnswer(q.user_answer) }}</div>
<div class="detail-line">正确答案:{{ formatAnswer(q.correct_answer) }}</div>
<div v-if="q.explanation" class="detail-line explain">解析:{{ q.explanation }}</div>
</div>
</template>
<template v-else>
<div class="paper-meta">
共 {{ questions.length }} 题,请完成后提交。
</div>
<div v-for="(q, idx) in questions" :key="q.id" class="question-item">
<div class="question-title">{{ idx + 1 }}. {{ q.stem }}</div>
<el-radio-group v-if="q.type === 'single'" v-model="answers[q.id]" class="question-options">
<el-radio v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-radio>
</el-radio-group>
<el-checkbox-group v-else-if="q.type === 'multiple'" v-model="answers[q.id]" class="question-options">
<el-checkbox v-for="opt in q.options || []" :key="opt.key" :label="opt.key">
{{ opt.key }}. {{ opt.text }}
</el-checkbox>
</el-checkbox-group>
<el-radio-group v-else-if="q.type === 'judge'" v-model="answers[q.id]" class="question-options">
<el-radio label="true">正确</el-radio>
<el-radio label="false">错误</el-radio>
</el-radio-group>
<el-input
v-else
v-model="answers[q.id]"
type="textarea"
:rows="4"
placeholder="请输入答案"
/>
</div>
</template>
<template #footer>
<div class="dialog-footer">
<el-button @click="closeDialog">{{ result ? '关闭' : '取消' }}</el-button>
<el-button v-if="!result" type="primary" :loading="submitting" @click="submit">提交答案</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
@@ -93,7 +166,7 @@ async function load() {
const exams = res.data || []
items.value = exams
.filter((p) => p.type === 'self_test')
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = exams.filter((p) => p.type === 'self_test').length
} catch (e) {
items.value = []
@@ -118,6 +191,7 @@ async function startExam(p) {
try {
const res = await examStart(p.id)
const d = res.data
result.value = null
sessionId.value = d.session_id
currentPaper.value = { name: d.exam_name }
questions.value = d.questions || []
@@ -150,6 +224,11 @@ async function submit() {
}
}
function closeDialog() {
quizVisible.value = false
result.value = null
}
function formatAnswer(correct) {
if (!correct) return '—'
if (correct.length === 1 && (correct[0] === 'true' || correct[0] === 'false')) {
@@ -158,6 +237,13 @@ function formatAnswer(correct) {
return correct.join('、')
}
function formatUserAnswer(ans) {
if (ans == null || ans === '') return '未作答'
if (Array.isArray(ans)) return ans.join('、')
if (ans === 'true' || ans === 'false') return ans === 'true' ? '正确' : '错误'
return String(ans)
}
onMounted(load)
</script>
@@ -175,4 +261,39 @@ onMounted(load)
display: flex;
justify-content: flex-start;
}
.paper-meta,
.result-summary {
margin-bottom: 12px;
}
.result-meta {
margin-top: 8px;
color: #606266;
}
.question-item {
padding: 12px 0;
border-bottom: 1px solid #f0f0f0;
}
.question-item:last-child {
border-bottom: none;
}
.question-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin-bottom: 10px;
}
.question-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.detail-line {
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.detail-line.explain {
color: #909399;
}
</style>
@@ -96,9 +96,9 @@ async function load() {
if (filters.department) {
items.value = allItems
.filter((r) => r.department === filters.department)
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
} else {
items.value = allItems.slice((page.value - 1) * size.value, page.value.value * size.value)
items.value = allItems.slice((page.value - 1) * size.value, page.value * size.value)
}
myRank.value = d.my_rank || 0
total.value = d.total || allItems.length
@@ -116,7 +116,7 @@ async function load() {
const records = res.data || []
items.value = records
.filter((c) => !filters.examName || (c.exam_name || '').includes(filters.examName))
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = records.length
} catch (e) {
items.value = []
@@ -112,7 +112,7 @@ async function load() {
if (filters.resolved !== null && m.resolved !== filters.resolved) return false
return true
})
.slice((page.value - 1) * size.value, page.value.value * size.value)
.slice((page.value - 1) * size.value, page.value * size.value)
total.value = records.length
} catch (e) {
items.value = []
@@ -3,7 +3,7 @@
<div class="page-header">
<div class="page-kicker">我的设置</div>
<h1>账号、身份与个人偏好</h1>
<p>这里收口个人级设置,不再把 APP 内部页面混进全局导航入口。</p>
<p>这里收口个人级设置,不再把 xApp 内部页面混进全局导航入口。</p>
</div>
<el-tabs v-model="activeTab" class="settings-tabs">
@@ -31,7 +31,7 @@
<el-descriptions-item label="入职批次">{{ identityInfo.hireBatch }}</el-descriptions-item>
<el-descriptions-item label="身份说明">{{ identityInfo.identityHint }}</el-descriptions-item>
</el-descriptions>
<div class="panel-note">组织身份用于项目、知识库、长程 APP 的权限与默认上下文判断。</div>
<div class="panel-note">组织身份用于项目、知识库、长程 xApp 的权限与默认上下文判断。</div>
</el-card>
</el-tab-pane>
@@ -42,7 +42,7 @@
<el-select v-model="preferences.defaultEntry" style="width: 280px;">
<el-option label="新建任务" value="/home" />
<el-option label="项目" value="/projects" />
<el-option label="APP目录" value="/xapps" />
<el-option label="xApp目录" value="/xapps" />
</el-select>
</el-form-item>
<el-form-item label="主题风格">
@@ -1,504 +0,0 @@
<template>
<div class="admin-overview-page">
<section class="hero-card">
<div class="hero-eyebrow">Governance Center</div>
<h1>后台管理与任务治理</h1>
</section>
<section class="meta-row">
<span v-for="item in compactStats" :key="item.label" class="meta-pill">
<span class="meta-pill-label">{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</span>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-head">
<div class="panel-title">待处理任务</div>
<div class="panel-caption">把现在最该动手的事项放到前面。</div>
</div>
<div class="focus-list">
<button
v-for="item in focusItems"
:key="item.label"
class="focus-row"
type="button"
@click="router.push(item.pageRoute)"
>
<div>
<div class="metric-title">{{ item.label }}</div>
<div class="metric-desc">{{ item.desc }}</div>
</div>
<div class="focus-right">
<strong>{{ item.value }}</strong>
<span>打开</span>
</div>
</button>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div class="panel-title">治理入口</div>
<div class="panel-caption">按任务语义进入,不再记后台菜单。</div>
</div>
<div class="entry-list">
<button
v-for="domain in governanceDomains"
:key="domain.key"
class="entry-row"
type="button"
@click="router.push(domain.pageRoute)"
>
<div>
<div class="metric-title">{{ domain.title }}</div>
<div class="metric-desc">{{ domain.subtitle }}</div>
</div>
<span class="entry-count">{{ domain.count }}</span>
</button>
</div>
</article>
</section>
<section class="main-grid secondary-grid">
<article class="panel">
<div class="panel-head">
<div class="panel-title">风险提醒</div>
<div class="panel-caption">只保留需要你尽快关注的几项。</div>
</div>
<div class="mini-group">
<div class="mini-group-title">部门</div>
<div v-if="riskDepartments.length" class="metric-list compact-list">
<div v-for="item in riskDepartments" :key="item.department" class="metric-row">
<div>
<div class="metric-title">{{ item.department }}</div>
<div class="metric-desc">{{ item.employee_count }} 人 · 已读 {{ item.company_rate }}%</div>
</div>
<el-tag
size="small"
:type="item.pass_rate >= 80 ? 'success' : item.pass_rate >= 60 ? 'warning' : 'danger'"
effect="plain"
>
{{ item.pass_rate }}%
</el-tag>
</div>
</div>
<div v-else class="empty-tip">暂无部门风险项</div>
</div>
<div class="mini-group">
<div class="mini-group-title">岗位</div>
<div v-if="riskPositions.length" class="metric-list compact-list">
<div v-for="item in riskPositions" :key="item.position_id" class="metric-row">
<div>
<div class="metric-title">{{ item.position_name }}</div>
<div class="metric-desc">{{ item.employee_count }} 人 · 参考 {{ item.taken_count }} 次</div>
</div>
<el-tag
size="small"
:type="item.pass_rate >= 80 ? 'success' : item.pass_rate >= 60 ? 'warning' : 'danger'"
effect="plain"
>
{{ item.pass_rate }}%
</el-tag>
</div>
</div>
<div v-else class="empty-tip">暂无岗位风险项</div>
</div>
</article>
<article class="panel">
<div class="panel-head">
<div class="panel-title">运行状态</div>
<div class="panel-caption">一眼看清系统是否在正常跑。</div>
</div>
<div class="status-pills">
<span class="status-pill">连接器 {{ liveConnectorCount }}</span>
<span class="status-pill">AI 用户 {{ aiUsageSummary.userCount }}</span>
<span class="status-pill">题目 {{ dashboard.question_count }}</span>
<span class="status-pill">考试 {{ dashboard.formal_paper_count }}</span>
</div>
<div v-if="connectorRows.length" class="connector-list compact-list">
<div v-for="item in connectorRows" :key="item.name" class="connector-row">
<div>
<div class="metric-title">{{ item.name }}</div>
<div class="metric-desc">{{ item.desc }}</div>
</div>
<el-tag size="small" :type="item.type" effect="plain">{{ item.status }}</el-tag>
</div>
</div>
<div v-else class="empty-tip">暂无连接器状态</div>
</article>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { getDashboard } from '@/api/system'
import { departmentStats } from '@/api/department'
import { examStatsByPosition } from '@/api/position'
import { getAiUsageUsers } from '@/api/ai'
import { listConnectors } from '@/connectors/api/connector'
const router = useRouter()
const dashboard = ref({
employee_count: 0,
admin_count: 0,
product_count: 0,
course_count: 0,
question_count: 0,
formal_paper_count: 0,
pending_material_count: 0,
recent_new_users: 0,
})
const departmentRows = ref([])
const positionRows = ref([])
const connectorRows = ref([])
const aiUserRows = ref([])
const totalUsers = computed(() => dashboard.value.employee_count + dashboard.value.admin_count)
const liveConnectorCount = computed(() => connectorRows.value.filter((item) => item.live).length)
const aiUsageSummary = computed(() => {
const summary = aiUserRows.value.reduce(
(acc, item) => {
acc.totalCalls += Number(item.total_calls || 0)
acc.totalCredits += Number(item.total_credits || 0)
return acc
},
{ totalCalls: 0, totalCredits: 0 }
)
return {
...summary,
userCount: aiUserRows.value.length,
}
})
const compactStats = computed(() => [
{ label: '成员', value: totalUsers.value },
{ label: '待审批', value: dashboard.value.pending_material_count },
{ label: '连接器', value: liveConnectorCount.value },
{ label: 'AI 调用', value: aiUsageSummary.value.totalCalls },
])
const governanceDomains = computed(() => [
{
key: 'org',
title: '组织治理',
subtitle: '用户、部门、岗位与身份',
count: `${totalUsers.value} 人`,
pageRoute: '/system/users',
},
{
key: 'knowledge',
title: '知识治理',
subtitle: '空间、资料、审批与切片',
count: `${dashboard.value.pending_material_count} 项`,
pageRoute: '/knowledge-management',
},
{
key: 'object',
title: '对象治理',
subtitle: '专员、技能、APP 与连接器',
count: `${liveConnectorCount.value} 个`,
pageRoute: '/console',
},
{
key: 'run',
title: '任务与运行',
subtitle: '考试、学习、AI 用量与异常',
count: `${aiUsageSummary.value.totalCalls} 次`,
pageRoute: '/system/ai-usage',
},
{
key: 'system',
title: '系统治理',
subtitle: '配置、路由、密钥与存储',
count: `${dashboard.value.question_count} 项`,
pageRoute: '/system/config',
},
])
const focusItems = computed(() => [
{
label: '知识审批',
desc: '素材审批与知识源入库待确认',
value: dashboard.value.pending_material_count,
pageRoute: '/knowledge-management',
},
{
label: '用户设岗',
desc: '为成员补齐岗位与组织身份任务',
value: totalUsers.value,
pageRoute: '/system/users',
},
{
label: 'AI 配置',
desc: '检查默认对话路由与密钥状态',
value: aiUsageSummary.value.userCount,
pageRoute: '/system/config',
},
{
label: '连接器健康',
desc: '检查连接器接入数量与控制台运行状态',
value: liveConnectorCount.value,
pageRoute: '/console',
},
])
const riskDepartments = computed(() =>
[...departmentRows.value]
.sort((a, b) => Number(a.pass_rate || 0) - Number(b.pass_rate || 0))
.slice(0, 3)
)
const riskPositions = computed(() =>
[...positionRows.value]
.sort((a, b) => Number(a.pass_rate || 0) - Number(b.pass_rate || 0))
.slice(0, 3)
)
onMounted(async () => {
try {
const [dashboardRes, deptRes, positionRes, aiUsageRes, connectorRes] = await Promise.all([
getDashboard().catch(() => ({ data: {} })),
departmentStats().catch(() => ({ data: { items: [] } })),
examStatsByPosition().catch(() => ({ data: { items: [] } })),
getAiUsageUsers().catch(() => ({ data: { users: [] } })),
listConnectors().catch(() => ({ data: [] })),
])
dashboard.value = { ...dashboard.value, ...(dashboardRes.data || {}) }
departmentRows.value = deptRes.data?.items || []
positionRows.value = positionRes.data?.items || []
aiUserRows.value = aiUsageRes.data?.users || []
connectorRows.value = (connectorRes.data || []).slice(0, 4).map((item) => ({
name: item.label || item.key,
desc: buildConnectorDesc(item),
status: item.mode === 'live' ? '已接入' : '演示中',
type: item.mode === 'live' ? 'success' : 'warning',
live: item.mode === 'live',
}))
} catch (error) {
console.warn('load admin overview failed', error)
}
})
function buildConnectorDesc(item) {
const objectCount = Array.isArray(item.objects) ? item.objects.length : 0
const directionLabel = item.direction === 'output' ? '输出连接器' : '输入连接器'
return `${directionLabel} · ${objectCount} 个对象`
}
</script>
<style scoped>
.admin-overview-page {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px 24px;
}
.hero-card {
padding-bottom: 2px;
}
.hero-card,
.panel,
.meta-pill,
.status-pill {
background: #fff;
}
.hero-eyebrow,
.panel-caption,
.meta-pill-label {
font-size: 11px;
color: #8d98a5;
}
.hero-card h1 {
margin: 4px 0 6px;
font-size: 24px;
color: #172533;
}
.hero-card p {
max-width: 72ch;
margin: 0;
font-size: 13px;
line-height: 1.65;
color: #667689;
}
.hero-actions {
display: flex;
gap: 10px;
margin-top: 12px;
}
.meta-row,
.status-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.meta-pill,
.status-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid #e7ebf0;
border-radius: 999px;
color: #5d6f82;
font-size: 12px;
}
.meta-pill strong {
color: #172533;
font-size: 12px;
}
.main-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.panel {
border: 1px solid #e7ebf0;
border-radius: 14px;
padding: 14px 16px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.panel-title {
font-size: 15px;
font-weight: 700;
color: #172533;
}
.focus-list,
.entry-list,
.metric-list,
.connector-list {
display: flex;
flex-direction: column;
margin-top: 14px;
}
.focus-row,
.entry-row,
.metric-row,
.connector-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-bottom: 1px solid #edf1f5;
}
.focus-row:last-child,
.entry-row:last-child,
.metric-row:last-child,
.connector-row:last-child {
border-bottom: none;
padding-bottom: 0;
}
.focus-row,
.entry-row {
appearance: none;
width: 100%;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
padding-top: 12px;
}
.metric-title {
font-size: 13px;
font-weight: 600;
color: #172533;
}
.metric-desc {
margin-top: 2px;
font-size: 12px;
color: #6c7b8b;
}
.focus-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
color: #315fc2;
}
.focus-right strong {
font-size: 18px;
line-height: 1;
}
.focus-right span,
.entry-count {
font-size: 12px;
}
.entry-count {
flex-shrink: 0;
color: #5d6f82;
}
.mini-group + .mini-group {
margin-top: 14px;
}
.mini-group-title {
margin-bottom: 6px;
font-size: 12px;
font-weight: 600;
color: #7b8896;
}
.compact-list {
margin-top: 0;
}
.empty-tip {
font-size: 12px;
color: #9aa6b2;
}
@media (max-width: 1360px) {
.main-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 1080px) {
.admin-overview-page {
padding: 14px 16px 20px;
}
}
</style>
@@ -0,0 +1,513 @@
<template>
<div class="governance-page ai-admin-page">
<section class="governance-hero">
<div class="governance-hero-main">
<h1>6.4 AI后台管理</h1>
</div>
<div class="governance-hero-side">
<div class="governance-hero-side-label">总调用</div>
<strong>{{ usageSummary.total_calls }}</strong>
<span>当前筛选时间窗</span>
</div>
</section>
<section class="governance-section-panel">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="6.4.1 AI路由配置" name="routes">
<div v-loading="routeLoading">
<div class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">对话路由</span>
<strong>{{ aiChatRoutes.length }}</strong>
<small>可用于 Agent 与对话场景</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">Embedding 路由</span>
<strong>{{ aiEmbedRoutes.length }}</strong>
<small>可用于检索与向量任务</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">Provider 数</span>
<strong>{{ providerCount }}</strong>
<small>已纳入密钥状态管理</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">默认对话路由</span>
<strong>{{ aiConfig.default_route || '未设置' }}</strong>
<small>保存后热生效</small>
</article>
</div>
<el-alert
title="AI 路由与密钥通过 ai_config.json / ai_secrets.json 文件管理,保存后热生效,无需重启。下拉映射优先于 JSON 编辑器中的对应字段。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<div class="route-selector-section">
<div class="section-label">Agent → 路由映射</div>
<el-form label-width="150px" class="cfg-form">
<el-form-item label="PathCoach 对话">
<el-select v-model="aiConfig.agent_routes.path_coach" placeholder="选择对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="快捷动作">
<el-select v-model="aiConfig.agent_routes.title_gen" placeholder="选择对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="Embedding">
<el-select v-model="aiConfig.agent_routes.embed_gen" placeholder="选择 Embedding 路由" style="width: 100%">
<el-option
v-for="aiRoute in aiEmbedRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="默认对话路由">
<el-select v-model="aiConfig.default_route" placeholder="选择默认对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<el-form-item label="默认 Embedding">
<el-select v-model="aiConfig.default_embed_route" placeholder="选择默认 Embedding 路由" style="width: 100%">
<el-option
v-for="aiRoute in aiEmbedRoutes"
:key="aiRoute.ai_route_id"
:label="formatAiRouteLabel(aiRoute)"
:value="aiRoute.ai_route_id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<div class="route-selector-section">
<div class="section-label">Provider 密钥状态</div>
<div class="secret-grid">
<div v-for="(ok, provider) in secrets" :key="provider" class="secret-item">
<el-tag :type="ok ? 'success' : 'info'" size="small" effect="plain">{{ ok ? '已配置' : '未配置' }}</el-tag>
<span class="secret-name">{{ provider }}</span>
</div>
</div>
<div class="cfg-desc">密钥明文不回显;配置见 config/ai_secrets.json(仅示例文件入库,真实密钥不入库)</div>
</div>
<el-collapse class="json-collapse">
<el-collapse-item title="原始 JSON 编辑器(ai_config.json)" name="json">
<el-input v-model="aiConfigText" type="textarea" :rows="18" spellcheck="false" class="json-editor" />
</el-collapse-item>
</el-collapse>
<div class="save-bar">
<el-button :loading="reloading" @click="reloadAi">热重载</el-button>
<el-button type="primary" :loading="savingAi" @click="saveAi">保存 AI 配置</el-button>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="6.4.2 AI用量分析" name="usage">
<el-card shadow="never" class="filter-card">
<div class="filter-row">
<span class="filter-label">时间窗</span>
<el-radio-group v-model="days" @change="onDaysChange">
<el-radio-button :value="30">近 30 天</el-radio-button>
<el-radio-button :value="90">近 90 天</el-radio-button>
<el-radio-button :value="365">近 365 天</el-radio-button>
</el-radio-group>
<span class="filter-label">分组</span>
<el-radio-group v-model="groupBy" @change="loadUsage">
<el-radio-button value="month">按月</el-radio-button>
<el-radio-button value="usage_kind">按类型</el-radio-button>
<el-radio-button value="provider">按 Provider</el-radio-button>
</el-radio-group>
</div>
</el-card>
<div class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">总调用</span>
<strong>{{ usageSummary.total_calls }}</strong>
<small>所有请求次数</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">成功</span>
<strong class="tone-success">{{ usageSummary.success_calls }}</strong>
<small>成功响应次数</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">失败</span>
<strong class="tone-danger">{{ usageSummary.failed_calls }}</strong>
<small>需要排查的失败调用</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">总消耗点数</span>
<strong class="tone-primary">{{ usageSummary.total_credits }}</strong>
<small>组织级成本观察视角</small>
</article>
</div>
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="14">
<el-card shadow="never">
<template #header><span>按日调用趋势</span></template>
<LineChart :items="dailyItems" />
</el-card>
</el-col>
<el-col :span="5">
<el-card shadow="never">
<template #header><span>成功 / 失败占比</span></template>
<DonutChart
:segments="successSegments"
:size="130"
:stroke="22"
:center-value="usageSummary.success_calls"
center-label="成功次数"
/>
</el-card>
</el-col>
<el-col :span="5">
<el-card shadow="never">
<template #header><span>类型分布</span></template>
<DonutChart
:segments="usageKindSegments"
:size="130"
:stroke="22"
:center-value="usageSummary.total_calls"
center-label="总调用"
/>
</el-card>
</el-col>
</el-row>
<el-card shadow="never">
<template #header><span>分组明细({{ groupLabel }})</span></template>
<el-table :data="usageBuckets" border stripe v-loading="usageLoading">
<el-table-column :label="groupLabel" min-width="140">
<template #default="{ row }">{{ bucketLabel(row.bucket) }}</template>
</el-table-column>
<el-table-column prop="total_calls" label="总调用" width="120" />
<el-table-column prop="success_calls" label="成功" width="100" />
<el-table-column prop="failed_calls" label="失败" width="100" />
<el-table-column prop="total_credits" label="消耗点数" width="120" />
</el-table>
<el-empty v-if="!usageLoading && usageBuckets.length === 0" description="暂无调用记录" />
</el-card>
<el-card shadow="never" style="margin-top: 16px;">
<template #header><span>按用户用量与剩余点数</span></template>
<el-table :data="userRows" border stripe v-loading="userLoading">
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="full_name" label="姓名" width="140" />
<el-table-column label="角色" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '员工' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="total_calls" label="总调用" width="120" />
<el-table-column prop="total_credits" label="累计消耗" width="120" />
<el-table-column label="剩余点数" width="140">
<template #default="{ row }">
<el-tag size="small" :type="row.ai_points > 0 ? 'success' : 'danger'" effect="plain">
{{ row.ai_points }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
</section>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAiConfig, getAiUsage, getAiUsageUsers, getSecretsStatus, putAiConfig, reloadAiConfig } from '@/api/ai'
import { listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import DonutChart from '@/components/charts/DonutChart.vue'
import LineChart from '@/components/charts/LineChart.vue'
import { formatAiRouteTitle } from '@/utils/aiRouteLabel'
const USAGE_KIND_LABEL = { ai_chat: '对话', chat_message: '对话', text_gen: '快捷动作', embed: '检索' }
const USAGE_KIND_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
const activeTab = ref('routes')
const routeLoading = ref(false)
const savingAi = ref(false)
const reloading = ref(false)
const aiChatRoutes = ref([])
const aiEmbedRoutes = ref([])
const secrets = ref({})
const aiConfig = reactive({ agent_routes: {}, default_route: '', default_embed_route: '' })
const aiConfigText = ref('')
const days = ref(30)
const groupBy = ref('month')
const usageLoading = ref(false)
const userLoading = ref(false)
const usageSummary = reactive({ total_calls: 0, success_calls: 0, failed_calls: 0, total_credits: 0 })
const usageBuckets = ref([])
const userRows = ref([])
const dayBuckets = ref([])
const usageKindBuckets = ref([])
const providerCount = computed(() => Object.keys(secrets.value || {}).length)
const groupLabel = computed(() => ({ month: '月份', usage_kind: '类型', provider: 'Provider' }[groupBy.value] || '分组'))
const dailyItems = computed(() => dayBuckets.value.map((item) => ({ label: item.bucket, value: item.total_calls })))
const successSegments = computed(() => [
{ label: '成功', value: usageSummary.success_calls, color: '#67c23a' },
{ label: '失败', value: usageSummary.failed_calls, color: '#f56c6c' },
])
const usageKindSegments = computed(() =>
usageKindBuckets.value.map((item, index) => ({
label: USAGE_KIND_LABEL[item.bucket] || item.bucket,
value: item.total_calls,
color: USAGE_KIND_COLORS[index % USAGE_KIND_COLORS.length],
}))
)
function formatAiRouteLabel(aiRoute) {
return formatAiRouteTitle(aiRoute)
}
function bucketLabel(value) {
if (groupBy.value === 'usage_kind') return USAGE_KIND_LABEL[value] || value
return value
}
async function loadRoutes() {
routeLoading.value = true
try {
const [cfgRes, secretRes, chatRes, embedRes] = await Promise.all([
getAiConfig(),
getSecretsStatus(),
listAiChatRoutes().catch(() => ({ data: { routes: [] } })),
listAiEmbedRoutes().catch(() => ({ data: { routes: [] } })),
])
const cfg = cfgRes.data || {}
aiConfig.agent_routes = { ...(cfg.agent_routes || {}) }
aiConfig.default_route = cfg.default_route || ''
aiConfig.default_embed_route = cfg.default_embed_route || ''
aiConfigText.value = JSON.stringify(cfg, null, 2)
secrets.value = secretRes.data?.providers || {}
aiChatRoutes.value = chatRes.data?.routes || []
aiEmbedRoutes.value = embedRes.data?.routes || []
} catch (e) {
secrets.value = {}
aiChatRoutes.value = []
aiEmbedRoutes.value = []
} finally {
routeLoading.value = false
}
}
async function saveAi() {
savingAi.value = true
try {
let config
try {
config = JSON.parse(aiConfigText.value)
} catch {
ElMessage.error('ai_config.json 不是合法 JSON,请检查编辑器内容')
return
}
config.agent_routes = { ...(config.agent_routes || {}), ...aiConfig.agent_routes }
config.default_route = aiConfig.default_route
config.default_embed_route = aiConfig.default_embed_route
await putAiConfig(config)
aiConfigText.value = JSON.stringify(config, null, 2)
ElMessage.success('AI 配置已保存并热生效')
loadRoutes()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
savingAi.value = false
}
}
async function reloadAi() {
reloading.value = true
try {
await reloadAiConfig()
ElMessage.success('已热重载')
loadRoutes()
} catch (e) {
ElMessage.error(e?.message || '热重载失败')
} finally {
reloading.value = false
}
}
async function loadUsage() {
usageLoading.value = true
try {
const res = await getAiUsage({ days: days.value, group_by: groupBy.value })
const data = res.data || {}
Object.assign(usageSummary, data.summary || {})
usageBuckets.value = data.buckets || []
} catch (e) {
Object.assign(usageSummary, { total_calls: 0, success_calls: 0, failed_calls: 0, total_credits: 0 })
usageBuckets.value = []
} finally {
usageLoading.value = false
}
}
async function loadUsers() {
userLoading.value = true
try {
const res = await getAiUsageUsers()
userRows.value = res.data?.users || []
} catch (e) {
userRows.value = []
} finally {
userLoading.value = false
}
}
async function loadCharts() {
try {
const [dayRes, usageKindRes] = await Promise.all([
getAiUsage({ days: days.value, group_by: 'day' }),
getAiUsage({ days: days.value, group_by: 'usage_kind' }),
])
dayBuckets.value = dayRes.data?.buckets || []
usageKindBuckets.value = usageKindRes.data?.buckets || []
} catch (e) {
dayBuckets.value = []
usageKindBuckets.value = []
}
}
function onDaysChange() {
loadUsage()
loadCharts()
}
onMounted(() => {
loadRoutes()
loadUsage()
loadUsers()
loadCharts()
})
</script>
<style scoped>
.cfg-form {
max-width: 880px;
}
.cfg-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.route-selector-section {
background: #fafafa;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 12px;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.secret-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin-bottom: 8px;
}
.secret-item {
display: flex;
align-items: center;
gap: 8px;
}
.secret-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: #303133;
}
.json-collapse {
margin-top: 4px;
}
.json-editor :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.filter-card {
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.filter-label {
font-size: 14px;
color: #606266;
margin-left: 12px;
}
.filter-label:first-child {
margin-left: 0;
}
.tone-success {
color: #67c23a;
}
.tone-danger {
color: #f56c6c;
}
.tone-primary {
color: #409eff;
}
</style>
@@ -3,11 +3,7 @@
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">运行治理</div>
<h1>AI 用量</h1>
<p>
统一查看组织级 AI 调用量、成功率、用户消耗和 Provider 分布。
这页服务于资源治理、路由优化和异常排查,而不只是简单报表。
</p>
<h1>AI 用量分析</h1>
</div>
<div class="governance-hero-side">
<div class="governance-hero-side-label">总调用</div>
@@ -1,164 +0,0 @@
<template>
<div class="governance-page company-config-page">
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">系统治理</div>
<h1>公司配置</h1>
<p>
这里维护组织级基础信息,用于新员工 onboarding、公司介绍 APP、通知触达与系统默认展示。
它不是孤立配置表,而是平台组织身份的一部分。
</p>
<div class="governance-hero-actions">
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
</div>
<div class="governance-hero-side">
<div class="governance-hero-side-label">配置项</div>
<strong>{{ companyItems.length }}</strong>
<span>组织级基础参数</span>
</div>
</section>
<section class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">公司名称</span>
<strong>{{ values.company_name || '未设置' }}</strong>
<small>对外展示的组织名称</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">管理员邮箱</span>
<strong>{{ values.admin_email || '未设置' }}</strong>
<small>用于通知和运营联系</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">公司介绍长度</span>
<strong>{{ introLength }}</strong>
<small>字符数,用于公司介绍 APP 内容基础</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">内容状态</span>
<strong>{{ introLength ? '已配置' : '待完善' }}</strong>
<small>建议补齐 onboarding 主文案</small>
</article>
</section>
<section class="governance-section-panel">
<div class="governance-section-head">
<div>
<div class="governance-section-title">组织基础信息</div>
<div class="governance-section-subtitle">这些信息会影响公司介绍、通知触达和组织身份展示。</div>
</div>
</div>
<el-card shadow="never" v-loading="loading">
<el-alert
title="以下为公司基础信息配置,保存后立即生效。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in companyItems" :key="item.config_key">
<template #label>
<span class="cfg-key">{{ item.config_key }}</span>
</template>
<el-input
v-if="item.config_key === 'company_intro'"
v-model="values[item.config_key]"
type="textarea"
:rows="8"
/>
<el-input v-else v-model="values[item.config_key]" />
<div class="cfg-desc">{{ item.description }}</div>
</el-form-item>
</el-form>
<div class="save-bar">
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
<el-empty v-if="!loading && companyItems.length === 0" description="暂无公司配置项" />
</el-card>
</section>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getConfig, updateConfig } from '@/api/system'
const loading = ref(false)
const saving = ref(false)
const configItems = ref([])
const values = reactive({})
const companyItems = computed(() =>
configItems.value.filter((c) =>
['company_intro', 'company_name', 'admin_email'].includes(c.config_key)
)
)
const introLength = computed(() => String(values.company_intro || '').trim().length)
async function load() {
loading.value = true
try {
const cfgRes = await getConfig()
const list = cfgRes.data?.configs || []
configItems.value = list
list.forEach((c) => {
values[c.config_key] = c.config_value || ''
})
} catch (e) {
configItems.value = []
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
try {
const payload = {}
companyItems.value.forEach((c) => {
payload[c.config_key] = values[c.config_key] ?? ''
})
await updateConfig(payload)
ElMessage.success('配置已保存')
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.cfg-form {
max-width: 880px;
}
.cfg-key {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.cfg-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,789 @@
<template>
<div class="governance-page organization-management-page">
<section class="governance-hero">
<div class="governance-hero-main">
<h1>6.2 组织与公司配置</h1>
</div>
</section>
<section class="governance-section-panel">
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="6.2.1 部门管理" name="dept-list">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>部门字典(共 {{ departments.length }} 个)</span>
<el-button type="primary" size="small" @click="openDepartmentCreate">新增部门</el-button>
</div>
</template>
<el-table :data="departments" border stripe v-loading="departmentLoading">
<el-table-column prop="name" label="部门名称" min-width="140" />
<el-table-column prop="description" label="说明" min-width="180">
<template #default="{ row }">{{ row.description || '—' }}</template>
</el-table-column>
<el-table-column prop="member_count" label="成员数" width="90" align="center" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '启用' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openDepartmentEdit(row)">编辑</el-button>
<el-button
:type="row.status === 'active' ? 'warning' : 'success'"
link
size="small"
@click="toggleDepartmentStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
<el-button type="danger" link size="small" @click="removeDepartment(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.2.2 部门学情" name="dept-stats">
<el-card shadow="never">
<template #header><span>按部门学情统计(员工 {{ statsTotalEmployees }} 人)</span></template>
<el-table :data="departmentStatsRows" border stripe v-loading="departmentStatsLoading">
<el-table-column prop="department" label="部门" min-width="120" />
<el-table-column prop="employee_count" label="人数" width="80" align="center" />
<el-table-column prop="points_total" label="学习积分" width="100" align="right" />
<el-table-column label="公司介绍已读率" width="140" align="center">
<template #default="{ row }">{{ row.company_rate }}%</template>
</el-table-column>
<el-table-column label="人均产品/课程" width="130" align="center">
<template #default="{ row }">{{ row.product_avg }} / {{ row.course_avg }}</template>
</el-table-column>
<el-table-column label="正式考试" width="110" align="center">
<template #default="{ row }">{{ row.formal_passed }}/{{ row.formal_count }} 通过</template>
</el-table-column>
<el-table-column label="通过率" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.pass_rate >= 80 ? 'success' : row.pass_rate >= 60 ? 'warning' : 'danger'">
{{ row.pass_rate }}%
</el-tag>
</template>
</el-table-column>
<el-table-column prop="avg_score" label="平均分" width="90" align="right" />
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.2.3 岗位管理" name="position">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>岗位列表(共 {{ positions.length }} 个)</span>
<el-button type="primary" size="small" @click="openPositionCreate">新增岗位</el-button>
</div>
</template>
<el-table :data="pagedPositions" border stripe v-loading="positionLoading">
<el-table-column prop="id" label="编号" width="70" />
<el-table-column prop="code" label="编码" width="120" />
<el-table-column prop="name" label="名称" min-width="160" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="知识映射" width="100">
<template #default="{ row }">
<el-tag size="small" :type="positionKnowledgeCount[row.id] ? 'success' : 'info'" effect="plain">
{{ positionKnowledgeCount[row.id] || 0 }} 条
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag size="small" :type="row.status === 'active' ? 'success' : 'info'">
{{ labelOf(POSITION_STATUS, row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="openPositionEdit(row)">编辑</el-button>
<el-button type="primary" link size="small" @click="openKnowledge(row)">知识映射</el-button>
<el-button type="primary" link size="small" @click="openBlueprint(row)">考试蓝图</el-button>
<el-button type="danger" link size="small" @click="removePosition(row)">停用</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="positionPage"
v-model:page-size="positionPageSize"
:page-sizes="[10, 20, 40]"
:total="positions.length"
background
layout="sizes, prev, pager, next"
/>
</div>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.2.4 公司配置" name="company">
<div class="governance-summary-grid">
<article class="governance-summary-card">
<span class="governance-summary-label">公司名称</span>
<strong>{{ companyValues.company_name || '未设置' }}</strong>
<small>对外展示的组织名称</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">管理员邮箱</span>
<strong>{{ companyValues.admin_email || '未设置' }}</strong>
<small>用于通知和运营联系</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">公司介绍长度</span>
<strong>{{ introLength }}</strong>
<small>字符数,用于公司介绍 xApp 内容基础</small>
</article>
<article class="governance-summary-card">
<span class="governance-summary-label">内容状态</span>
<strong>{{ introLength ? '已配置' : '待完善' }}</strong>
<small>建议补齐 onboarding 主文案</small>
</article>
</div>
<el-card shadow="never" v-loading="companyLoading">
<el-alert
title="以下为公司基础信息配置,保存后立即生效。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in companyItems" :key="item.config_key">
<template #label>
<span class="cfg-key">{{ item.config_key }}</span>
</template>
<el-input
v-if="item.config_key === 'company_intro'"
v-model="companyValues[item.config_key]"
type="textarea"
:rows="8"
/>
<el-input v-else v-model="companyValues[item.config_key]" />
<div class="cfg-desc">{{ item.description }}</div>
</el-form-item>
</el-form>
<div class="save-bar">
<el-button type="primary" :loading="companySaving" @click="saveCompanyConfig">保存配置</el-button>
</div>
<el-empty v-if="!companyLoading && companyItems.length === 0" description="暂无公司配置项" />
</el-card>
</el-tab-pane>
</el-tabs>
</section>
<el-dialog v-model="departmentDialogVisible" :title="departmentForm.id ? '编辑部门' : '新增部门'" width="480px">
<el-form :model="departmentForm" label-width="90px">
<el-form-item label="部门名称">
<el-input v-model="departmentForm.name" placeholder="如:销售一部" />
</el-form-item>
<el-form-item label="说明">
<el-input v-model="departmentForm.description" type="textarea" :rows="2" placeholder="部门职责说明(可选)" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="departmentDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="departmentSaving" @click="saveDepartment">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="positionDialogVisible" :title="positionForm.id ? '编辑岗位' : '新增岗位'" width="520px">
<el-form :model="positionForm" label-width="70px">
<el-form-item label="编码">
<el-input v-model="positionForm.code" placeholder="如:SALES_01" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="positionForm.name" placeholder="如:销售顾问" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="positionForm.description" type="textarea" :rows="3" placeholder="岗位职责说明(可选)" />
</el-form-item>
<el-form-item v-if="positionForm.id" label="状态">
<el-radio-group v-model="positionForm.status">
<el-radio value="active">启用</el-radio>
<el-radio value="inactive">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="positionDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="positionSaving" @click="savePosition">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="knowledgeDialogVisible" :title="`知识映射 — ${currentPosition.name || ''}`" width="880px" top="5vh">
<el-alert type="info" :closable="false" show-icon class="k-alert">
圈定该岗位应掌握的知识范围:至少配置一条「知识域」;可细化到具体课程/产品;级别 L1-L4 用于分级标注。
</el-alert>
<el-table :data="knowledgeItems" border size="small">
<el-table-column label="知识域" width="150">
<template #default="{ row }">
<el-select v-model="row.domain" placeholder="选择知识域">
<el-option v-for="(value, key) in QUESTION_DOMAINS" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="课程(可选)" min-width="200">
<template #default="{ row }">
<el-select v-model="row.course_id" placeholder="不限课程" clearable filterable>
<el-option v-for="course in courses" :key="course.id" :label="course.name" :value="course.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="产品(可选)" min-width="200">
<template #default="{ row }">
<el-select v-model="row.product_id" placeholder="不限产品" clearable filterable>
<el-option v-for="product in products" :key="product.id" :label="product.name" :value="product.id" />
</el-select>
</template>
</el-table-column>
<el-table-column label="级别" width="160">
<template #default="{ row }">
<el-select v-model="row.required_level" placeholder="级别">
<el-option v-for="(value, key) in POSITION_LEVELS" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="权重" width="110">
<template #default="{ row }">
<el-input-number v-model="row.weight" :min="0.5" :max="10" :step="0.5" size="small" />
</template>
</el-table-column>
<el-table-column label="必学" width="70">
<template #default="{ row }">
<el-switch v-model="row.is_mandatory" />
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="knowledgeItems.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="dialog-footer-row">
<el-button @click="addKnowledgeItem">新增一行</el-button>
</div>
<template #footer>
<el-button @click="knowledgeDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="knowledgeSaving" @click="saveKnowledge">保存映射</el-button>
</template>
</el-dialog>
<el-dialog v-model="blueprintDialogVisible" :title="`考试蓝图 — ${currentPosition.name || ''}`" width="720px" top="8vh">
<el-alert type="info" :closable="false" show-icon class="k-alert">
蓝图规定岗位考试的题型构成。配置蓝图后,该岗位的考试优先按蓝图组卷。
</el-alert>
<el-table :data="blueprintItems" border size="small">
<el-table-column label="知识域" width="170">
<template #default="{ row }">
<el-select v-model="row.domain" placeholder="不限" clearable>
<el-option v-for="(value, key) in QUESTION_DOMAINS" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="题型" width="150">
<template #default="{ row }">
<el-select v-model="row.type" placeholder="题型">
<el-option v-for="(value, key) in QUESTION_TYPES" :key="key" :label="value" :value="key" />
</el-select>
</template>
</el-table-column>
<el-table-column label="题量" width="120">
<template #default="{ row }">
<el-input-number v-model="row.count" :min="1" :max="100" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="70">
<template #default="{ $index }">
<el-button type="danger" link size="small" @click="blueprintItems.splice($index, 1)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="dialog-footer-row">
<el-button @click="addBlueprintItem">新增一行</el-button>
<span class="b-summary">蓝图总题量:{{ blueprintTotal }}</span>
</div>
<template #footer>
<el-button @click="blueprintDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="blueprintSaving" @click="saveBlueprint">保存蓝图</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { createDepartment, deleteDepartment, departmentStats, listDepartments, updateDepartment } from '@/api/department'
import { listCourses } from '@/api/courses'
import { listProducts } from '@/api/products'
import { getConfig, updateConfig } from '@/api/system'
import {
createPosition,
deletePosition,
listPositionBlueprint,
listPositionKnowledge,
listPositions,
savePositionBlueprint,
savePositionKnowledge,
updatePosition,
} from '@/api/position'
import { POSITION_LEVELS, POSITION_STATUS, QUESTION_DOMAINS, QUESTION_TYPES, labelOf } from '@/utils/labels'
const activeTab = ref('dept-list')
const departmentLoading = ref(false)
const departments = ref([])
const departmentStatsLoading = ref(false)
const departmentStatsRows = ref([])
const departmentDialogVisible = ref(false)
const departmentSaving = ref(false)
const departmentForm = reactive({ id: null, name: '', description: '' })
const statsTotalEmployees = computed(() => departmentStatsRows.value.reduce((sum, row) => sum + (row.employee_count || 0), 0))
const positionLoading = ref(false)
const positions = ref([])
const positionKnowledgeCount = reactive({})
const positionDialogVisible = ref(false)
const positionSaving = ref(false)
const positionForm = reactive({ id: null, code: '', name: '', description: '', status: 'active' })
const positionPage = ref(1)
const positionPageSize = ref(10)
const pagedPositions = computed(() =>
positions.value.slice((positionPage.value - 1) * positionPageSize.value, positionPage.value * positionPageSize.value)
)
const companyLoading = ref(false)
const companySaving = ref(false)
const companyConfigItems = ref([])
const companyValues = reactive({})
const companyItems = computed(() =>
companyConfigItems.value.filter((c) =>
['company_intro', 'company_name', 'admin_email'].includes(c.config_key)
)
)
const introLength = computed(() => String(companyValues.company_intro || '').trim().length)
const knowledgeDialogVisible = ref(false)
const knowledgeSaving = ref(false)
const blueprintDialogVisible = ref(false)
const blueprintSaving = ref(false)
const currentPosition = ref({})
const knowledgeItems = ref([])
const blueprintItems = ref([])
const courses = ref([])
const products = ref([])
const blueprintTotal = computed(() => blueprintItems.value.reduce((sum, item) => sum + (item.count || 0), 0))
function resetDepartmentForm() {
departmentForm.id = null
departmentForm.name = ''
departmentForm.description = ''
}
function openDepartmentCreate() {
activeTab.value = 'dept-list'
resetDepartmentForm()
departmentDialogVisible.value = true
}
function openDepartmentEdit(row) {
activeTab.value = 'dept-list'
departmentForm.id = row.id
departmentForm.name = row.name
departmentForm.description = row.description || ''
departmentDialogVisible.value = true
}
async function loadDepartments() {
departmentLoading.value = true
try {
const res = await listDepartments({ status: 'all' })
departments.value = res.data || []
} catch (e) {
departments.value = []
} finally {
departmentLoading.value = false
}
}
async function loadDepartmentStats() {
departmentStatsLoading.value = true
try {
const res = await departmentStats()
departmentStatsRows.value = res.data?.items || []
} catch (e) {
departmentStatsRows.value = []
} finally {
departmentStatsLoading.value = false
}
}
async function saveDepartment() {
if (!departmentForm.name.trim()) {
ElMessage.warning('部门名称不能为空')
return
}
departmentSaving.value = true
try {
const payload = { name: departmentForm.name.trim(), description: departmentForm.description }
if (departmentForm.id) {
await updateDepartment(departmentForm.id, payload)
} else {
await createDepartment(payload)
}
ElMessage.success('部门已保存')
departmentDialogVisible.value = false
await Promise.all([loadDepartments(), loadDepartmentStats()])
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
departmentSaving.value = false
}
}
async function toggleDepartmentStatus(row) {
const nextStatus = row.status === 'active' ? 'inactive' : 'active'
try {
await updateDepartment(row.id, { name: row.name, description: row.description, status: nextStatus })
ElMessage.success('已更新状态')
loadDepartments()
} catch (e) {
ElMessage.error(e?.message || '操作失败')
}
}
async function removeDepartment(row) {
await ElMessageBox.confirm(`确定删除部门「${row.name}」吗?有员工归属的部门无法删除。`, '提示', { type: 'warning' })
try {
await deleteDepartment(row.id)
ElMessage.success('已删除')
await Promise.all([loadDepartments(), loadDepartmentStats()])
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
function resetPositionForm() {
positionForm.id = null
positionForm.code = ''
positionForm.name = ''
positionForm.description = ''
positionForm.status = 'active'
}
function openPositionCreate() {
activeTab.value = 'position'
resetPositionForm()
positionDialogVisible.value = true
}
function openPositionEdit(row) {
activeTab.value = 'position'
resetPositionForm()
positionForm.id = row.id
positionForm.code = row.code
positionForm.name = row.name
positionForm.description = row.description || ''
positionForm.status = row.status
positionDialogVisible.value = true
}
async function loadPositions() {
positionLoading.value = true
try {
const res = await listPositions({ status: 'all' })
positions.value = res.data || []
await Promise.all(positions.value.map((item) => loadKnowledgeCount(item.id)))
} catch (e) {
positions.value = []
} finally {
positionLoading.value = false
}
}
async function loadKnowledgeCount(positionId) {
try {
const res = await listPositionKnowledge(positionId)
positionKnowledgeCount[positionId] = (res.data || []).length
} catch (e) {
positionKnowledgeCount[positionId] = 0
}
}
async function savePosition() {
if (!positionForm.name.trim()) {
ElMessage.warning('岗位名称不能为空')
return
}
if (!positionForm.id && !positionForm.code.trim()) {
ElMessage.warning('岗位编码不能为空')
return
}
positionSaving.value = true
try {
const payload = {
code: positionForm.code.trim(),
name: positionForm.name.trim(),
description: positionForm.description,
}
if (positionForm.id) {
payload.status = positionForm.status
await updatePosition(positionForm.id, payload)
} else {
await createPosition(payload)
}
ElMessage.success('岗位已保存')
positionDialogVisible.value = false
loadPositions()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
positionSaving.value = false
}
}
async function removePosition(row) {
await ElMessageBox.confirm(`确定停用岗位「${row.name}」吗?`, '提示', { type: 'warning' })
try {
await deletePosition(row.id)
ElMessage.success('岗位已停用')
loadPositions()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '操作失败')
}
}
async function loadRefs() {
try {
const [courseRes, productRes] = await Promise.all([listCourses(), listProducts()])
courses.value = courseRes.data || []
products.value = productRes.data || []
} catch (e) {
courses.value = []
products.value = []
}
}
async function openKnowledge(row) {
activeTab.value = 'position'
currentPosition.value = row
knowledgeItems.value = []
knowledgeDialogVisible.value = true
try {
const res = await listPositionKnowledge(row.id)
knowledgeItems.value = (res.data || []).map((item) => ({
domain: item.domain,
course_id: item.course_id ?? null,
product_id: item.product_id ?? null,
required_level: item.required_level,
weight: item.weight,
is_mandatory: item.is_mandatory,
}))
} catch (e) {
knowledgeItems.value = []
}
}
function addKnowledgeItem() {
knowledgeItems.value.push({
domain: 'product',
course_id: null,
product_id: null,
required_level: 'L1',
weight: 1,
is_mandatory: true,
})
}
async function saveKnowledge() {
const items = knowledgeItems.value.map((item) => ({
domain: item.domain,
course_id: item.course_id ?? null,
product_id: item.product_id ?? null,
required_level: item.required_level,
weight: item.weight,
is_mandatory: item.is_mandatory,
}))
if (!items.length) {
ElMessage.warning('请至少配置一条知识映射')
return
}
if (items.some((item) => !item.domain)) {
ElMessage.warning('存在未选择知识域的条目')
return
}
knowledgeSaving.value = true
try {
await savePositionKnowledge(currentPosition.value.id, items)
ElMessage.success('知识映射已保存')
knowledgeDialogVisible.value = false
loadKnowledgeCount(currentPosition.value.id)
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
knowledgeSaving.value = false
}
}
async function openBlueprint(row) {
activeTab.value = 'position'
currentPosition.value = row
blueprintItems.value = []
blueprintDialogVisible.value = true
try {
const res = await listPositionBlueprint(row.id)
blueprintItems.value = (res.data || []).map((item) => ({
domain: item.domain || '',
type: item.type,
count: item.count,
}))
} catch (e) {
blueprintItems.value = []
}
}
function addBlueprintItem() {
blueprintItems.value.push({ domain: '', type: 'single', count: 5 })
}
async function loadCompanyConfig() {
companyLoading.value = true
try {
const cfgRes = await getConfig()
const list = cfgRes.data?.configs || []
companyConfigItems.value = list
list.forEach((item) => {
companyValues[item.config_key] = item.config_value || ''
})
} catch (e) {
companyConfigItems.value = []
} finally {
companyLoading.value = false
}
}
async function saveCompanyConfig() {
companySaving.value = true
try {
const payload = {}
companyItems.value.forEach((item) => {
payload[item.config_key] = companyValues[item.config_key] ?? ''
})
await updateConfig(payload)
ElMessage.success('公司配置已保存')
loadCompanyConfig()
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
companySaving.value = false
}
}
async function saveBlueprint() {
const items = blueprintItems.value.map((item) => ({
domain: item.domain || '',
type: item.type,
count: item.count,
}))
if (!items.length) {
ElMessage.warning('请至少配置一条蓝图')
return
}
if (items.some((item) => !item.type || !item.count)) {
ElMessage.warning('存在未选择题型或未填题量的条目')
return
}
blueprintSaving.value = true
try {
await savePositionBlueprint(currentPosition.value.id, items)
ElMessage.success('考试蓝图已保存')
blueprintDialogVisible.value = false
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
blueprintSaving.value = false
}
}
onMounted(() => {
loadDepartments()
loadDepartmentStats()
loadPositions()
loadRefs()
loadCompanyConfig()
})
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.table-pagination {
margin-top: 16px;
display: flex;
justify-content: flex-start;
}
.cfg-form {
max-width: 880px;
}
.cfg-key {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.cfg-desc {
font-size: 12px;
color: #909399;
line-height: 1.5;
margin-top: 4px;
}
.k-alert {
margin-bottom: 12px;
}
.dialog-footer-row {
margin-top: 12px;
display: flex;
align-items: center;
gap: 16px;
}
.b-summary {
font-size: 13px;
color: #909399;
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -2,23 +2,14 @@
<div class="governance-page system-config-page">
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">系统治理</div>
<h1>系统配置</h1>
<p>维护文件存储、身份会话、AI 路由与 Provider 密钥状态。</p>
<h1>6.3 系统配置</h1>
</div>
</section>
<section class="governance-section-panel">
<div class="governance-section-head">
<div>
<div class="governance-section-title">系统治理</div>
<div class="governance-section-subtitle">文件、会话与 AI 配置。</div>
</div>
</div>
<el-card shadow="never" class="config-panel" v-loading="loading">
<el-alert
title="以下参数会直接影响系统运行行为。普通系统参数保存后立即生效;AI 配置支持热生效,无需重启。"
title="以下参数会直接影响系统运行行为,保存后立即生效。"
type="info"
:closable="false"
show-icon
@@ -26,7 +17,7 @@
/>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="文件与存储" name="file">
<el-tab-pane label="6.3.1 文件与存储" name="file">
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in fileItems" :key="item.config_key">
<template #label>
@@ -38,7 +29,7 @@
</el-form>
</el-tab-pane>
<el-tab-pane label="身份与会话" name="other">
<el-tab-pane label="6.3.2 身份与会话" name="other">
<el-form label-width="220px" class="cfg-form">
<el-form-item v-for="item in otherItems" :key="item.config_key">
<template #label>
@@ -49,77 +40,9 @@
</el-form-item>
</el-form>
</el-tab-pane>
<el-tab-pane label="AI 路由与密钥" name="ai">
<div v-loading="aiLoading">
<el-alert
title="AI 路由与密钥通过 ai_config.json / ai_secrets.json 文件管理,保存后热生效、无需重启。下拉映射优先于 JSON 编辑器中的对应字段。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<!-- Agent → 路由映射 -->
<div class="route-selector-section">
<div class="section-label">Agent → 路由映射</div>
<el-form label-width="150px" class="cfg-form">
<el-form-item label="PathCoach 对话">
<el-select v-model="aiConfig.agent_routes.path_coach" placeholder="选择对话路由" style="width:100%">
<el-option v-for="aiRoute in aiChatRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="快捷动作">
<el-select v-model="aiConfig.agent_routes.title_gen" placeholder="选择对话路由" style="width:100%">
<el-option v-for="aiRoute in aiChatRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="Embedding">
<el-select v-model="aiConfig.agent_routes.embed_gen" placeholder="选择 Embedding 路由" style="width:100%">
<el-option v-for="aiRoute in aiEmbedRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="默认对话路由">
<el-select v-model="aiConfig.default_route" placeholder="选择默认对话路由" style="width:100%">
<el-option v-for="aiRoute in aiChatRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
<el-form-item label="默认 Embedding">
<el-select v-model="aiConfig.default_embed_route" placeholder="选择默认 Embedding 路由" style="width:100%">
<el-option v-for="aiRoute in aiEmbedRoutes" :key="aiRoute.ai_route_id" :label="formatAiRouteLabel(aiRoute)" :value="aiRoute.ai_route_id" />
</el-select>
</el-form-item>
</el-form>
</div>
<!-- 密钥状态 -->
<div class="route-selector-section">
<div class="section-label">Provider 密钥状态</div>
<div class="secret-grid">
<div v-for="(ok, provider) in secrets" :key="provider" class="secret-item">
<el-tag :type="ok ? 'success' : 'info'" size="small" effect="plain">{{ ok ? '已配置' : '未配置' }}</el-tag>
<span class="secret-name">{{ provider }}</span>
</div>
</div>
<div class="cfg-desc">密钥明文不回显;配置见 config/ai_secrets.json(仅示例文件入库,真实密钥不入库)</div>
</div>
<!-- JSON 编辑器 -->
<el-collapse class="json-collapse">
<el-collapse-item title="原始 JSON 编辑器(ai_config.json)" name="json">
<el-input v-model="aiConfigText" type="textarea" :rows="18" spellcheck="false" class="json-editor" />
</el-collapse-item>
</el-collapse>
<div class="save-bar">
<el-button :loading="reloading" @click="reloadAi">热重载</el-button>
<el-button type="primary" :loading="savingAi" @click="saveAi">保存 AI 配置</el-button>
</div>
</div>
</el-tab-pane>
</el-tabs>
<div class="save-bar" v-if="activeTab !== 'ai'">
<div class="save-bar">
<el-button type="primary" :loading="saving" @click="save">保存配置</el-button>
</div>
@@ -132,9 +55,7 @@
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getConfig, updateConfig, listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import { getAiConfig, putAiConfig, reloadAiConfig, getSecretsStatus } from '@/api/ai'
import { formatAiRouteShortLabel } from '@/utils/aiRouteLabel'
import { getConfig, updateConfig } from '@/api/system'
const loading = ref(false)
const saving = ref(false)
@@ -142,16 +63,6 @@ const configItems = ref([])
const values = reactive({})
const activeTab = ref('file')
// AI 配置
const aiLoading = ref(false)
const savingAi = ref(false)
const reloading = ref(false)
const aiChatRoutes = ref([])
const aiEmbedRoutes = ref([])
const secrets = ref({})
const aiConfig = reactive({ agent_routes: {}, default_route: '', default_embed_route: '' })
const aiConfigText = ref('')
// ── Tab 分组 ──
const fileItems = computed(() =>
@@ -166,10 +77,6 @@ const otherItems = computed(() =>
)
)
function formatAiRouteLabel(aiRoute) {
return formatAiRouteShortLabel(aiRoute)
}
// ── 数据加载 ──
async function load() {
@@ -188,37 +95,13 @@ async function load() {
}
}
async function loadAi() {
aiLoading.value = true
try {
const [cfgRes, secretRes, chatRes, embedRes] = await Promise.all([
getAiConfig(),
getSecretsStatus(),
listAiChatRoutes().catch(() => ({ data: { routes: [] } })),
listAiEmbedRoutes().catch(() => ({ data: { routes: [] } })),
])
const cfg = cfgRes.data || {}
aiConfig.agent_routes = { ...(cfg.agent_routes || {}) }
aiConfig.default_route = cfg.default_route || ''
aiConfig.default_embed_route = cfg.default_embed_route || ''
aiConfigText.value = JSON.stringify(cfg, null, 2)
secrets.value = secretRes.data?.providers || {}
aiChatRoutes.value = chatRes.data?.routes || []
aiEmbedRoutes.value = embedRes.data?.routes || []
} catch (e) {
// ignore
} finally {
aiLoading.value = false
}
}
// ── 保存 ──
async function save() {
saving.value = true
try {
const payload = {}
configItems.value.forEach((c) => {
;[...fileItems.value, ...otherItems.value].forEach((c) => {
payload[c.config_key] = values[c.config_key] ?? ''
})
await updateConfig(payload)
@@ -230,45 +113,8 @@ async function save() {
}
}
async function saveAi() {
savingAi.value = true
try {
let cfg
try {
cfg = JSON.parse(aiConfigText.value)
} catch {
ElMessage.error('ai_config.json 不是合法 JSON,请检查编辑器内容')
return
}
cfg.agent_routes = { ...(cfg.agent_routes || {}), ...aiConfig.agent_routes }
cfg.default_route = aiConfig.default_route
cfg.default_embed_route = aiConfig.default_embed_route
await putAiConfig(cfg)
aiConfigText.value = JSON.stringify(cfg, null, 2)
ElMessage.success('AI 配置已保存并热生效')
} catch (e) {
ElMessage.error(e?.message || '保存失败')
} finally {
savingAi.value = false
}
}
async function reloadAi() {
reloading.value = true
try {
await reloadAiConfig()
ElMessage.success('已热重载')
await loadAi()
} catch (e) {
ElMessage.error(e?.message || '热重载失败')
} finally {
reloading.value = false
}
}
onMounted(() => {
load()
loadAi()
})
</script>
@@ -288,42 +134,6 @@ onMounted(() => {
line-height: 1.5;
margin-top: 4px;
}
.route-selector-section {
background: #fafafa;
border-radius: 6px;
padding: 16px 20px;
margin-bottom: 12px;
}
.section-label {
font-size: 13px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.secret-grid {
display: flex;
flex-wrap: wrap;
gap: 12px 24px;
margin-bottom: 8px;
}
.secret-item {
display: flex;
align-items: center;
gap: 8px;
}
.secret-name {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
color: #303133;
}
.json-collapse {
margin-top: 4px;
}
.json-editor :deep(textarea) {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
}
.save-bar {
margin-top: 20px;
display: flex;
@@ -2,26 +2,13 @@
<div class="governance-page user-management-page">
<section class="governance-hero">
<div class="governance-hero-main">
<div class="governance-eyebrow">组织治理</div>
<h1>用户管理</h1>
<p>维护组织成员、岗位归属和学习推进。</p>
<div class="governance-hero-actions">
<el-button type="primary" @click="openCreate">新增用户</el-button>
<el-button @click="activeTab = 'progress'">查看学习进度</el-button>
</div>
<h1>6.1 用户管理</h1>
</div>
</section>
<section class="governance-section-panel">
<div class="governance-section-head">
<div>
<div class="governance-section-title">成员治理</div>
<div class="governance-section-subtitle">账号、设岗与学习推进。</div>
</div>
</div>
<el-tabs v-model="activeTab" type="border-card">
<el-tab-pane label="用户列表" name="users">
<el-tab-pane label="6.1.1 用户列表" name="users">
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filters" size="default">
<el-form-item label="用户名">
@@ -130,7 +117,7 @@
</el-card>
</el-tab-pane>
<el-tab-pane label="学习进度" name="progress">
<el-tab-pane label="6.1.2 学习进度" name="progress">
<el-row :gutter="16" style="margin-bottom: 16px;">
<el-col :span="8">
<el-card shadow="never">

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