feat: 同步知识库与工作台相关改动
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
# Debug Session: knowledge-chat-401
|
||||
- **Status**: [OPEN]
|
||||
- **Issue**: 04 知识库应用发送问题后,原先会看到 `LLM 返回 401: {"error":{"message":"No cookie auth credentials found","code":401}}`;修复目标是让问题路由正确,并且不要把底层模型鉴权报错直接暴露给前台。
|
||||
- **Debug Server**: http://127.0.0.1:7777/event
|
||||
- **Log File**: .dbg/trae-debug-log-knowledge-chat-401.ndjson
|
||||
|
||||
## Reproduction Steps
|
||||
1. 启动前端、Go 后端与知识服务。
|
||||
2. 打开 `04 知识库应用`。
|
||||
3. 发送闲聊、FAQ、总结类问题,例如:
|
||||
- `给我讲个小故事`
|
||||
- `你好,你是怎么工作的`
|
||||
- `请根据知识库总结规则制度库里关于审批的要点`
|
||||
|
||||
## Hypotheses & Verification
|
||||
| ID | Hypothesis | Likelihood | Effort | Evidence |
|
||||
|----|------------|------------|--------|----------|
|
||||
| A | 后端把知识服务或模型服务地址调错了 | High | Low | Rejected |
|
||||
| B | 当前运行进程不是最新版本 | Medium | Low | Rejected |
|
||||
| C | LLM 兜底层命中了未配置 API Key 的 OpenRouter 路由,所以把 401 直接抛给了前台 | High | Low | Confirmed |
|
||||
| D | 第一层分类器把闲聊 / 总结类问题分错了,所以问题走错层级 | High | Low | Confirmed |
|
||||
|
||||
## Instrumentation Plan
|
||||
- A: `frontend/src/api/ai.js` 记录请求入口与非 200 响应
|
||||
- B: `backend-go/internal/api/ai_chat.go` 记录聊天入口、四层计划、LLM 路由与错误
|
||||
- C: `backend-go/internal/ai/llm.go` 记录真实下游 URL、状态码与错误体前缀
|
||||
- D: `backend-go/internal/ai/knowledge_service_client.go` 记录知识服务调用状态
|
||||
|
||||
## Log Evidence
|
||||
- 知识服务稳定命中本地 `http://127.0.0.1:10233/classify` 和 `/search`
|
||||
- 分类修复后:
|
||||
- `给我讲个小故事` -> `intent=smalltalk`、`layer=bert_classifier`
|
||||
- `你好,你是怎么工作的` -> `intent=smalltalk`、`layer=bert_classifier`
|
||||
- `请根据知识库总结规则制度库里关于审批的要点` -> `intent=document`、`layer=llm_fallback`
|
||||
- 进入 `llm_fallback` 时,埋点显示:
|
||||
- 目标地址:`https://openrouter.ai/api/v1/chat/completions`
|
||||
- `hasAPIKey=false`
|
||||
- `authHeader=false`
|
||||
- 修复前下游直接返回 401 `No cookie auth credentials found`
|
||||
- 修复后,同类问题不再把 401 暴露给前台,而是返回“当前没有可用 LLM 兜底服务”的业务化提示
|
||||
|
||||
## Verification Conclusion
|
||||
- **pre-fix**
|
||||
- 闲聊 / 总结类问题会被误路由
|
||||
- 一旦进入 `llm_fallback`,前台直接看到 OpenRouter 401 / cookie 报错
|
||||
- **post-fix**
|
||||
- 第一层分类已改成规则优先,闲聊与总结类问题路由更符合预期
|
||||
- `llm_fallback` 不再直抛 401,而是返回更友好的业务化提示
|
||||
- **Remaining Blocker**
|
||||
- 当前剩余不是代码 Bug,而是运行环境缺口:
|
||||
- OpenRouter 未配置 API Key
|
||||
- 本地 `Ollama` 未启动(`127.0.0.1:11434` refused)
|
||||
@@ -26,6 +26,7 @@
|
||||
| `SY16_Ontology_Generalizes_Data_Actions_And_Workflows.md` | 本体如何让数据、动作、工作流一般化 |
|
||||
| `SY17_Workbench_UI_Wireframes.md` | 数字员工平台 UI 线框图(知识库固定 + 专员动态生长) |
|
||||
| `SY18_DWP_DW_ADW_Formal_Design_Contract.md` | DWP / DW / ADW 正式设计合同(供人和 AI 共用的对象基准) |
|
||||
| `SY19_Universal_Digital_Worker_Product_Planning.md` | 通用数字员工平台 V1.0 产品规划(先通用、后定制的产品打法收口) |
|
||||
|
||||
## 当前主线关系
|
||||
|
||||
@@ -42,3 +43,4 @@
|
||||
11. `SY16`:解释本体为何要把数据、动作、工作流提升为统一业务表达,服务六层架构理解与对象建模
|
||||
12. `SY17`:把六层架构落成数字员工平台工作台线框,明确平台不再是单一导航后台
|
||||
13. `SY18`:正式定义 DWP / DW / ADW 的结构合同、字段规范和 AI 生成规则
|
||||
14. `SY19`:把当前阶段产品路线正式收口为「先通用数字员工、后行业包与企业定制」
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
# SY19 — 通用数字员工平台 V1.0 产品规划
|
||||
|
||||
> **版本:V1.0 | 最后更新:2026-08-24**
|
||||
|
||||
---
|
||||
|
||||
## 1. 文档目的
|
||||
|
||||
本文用于把当前项目从「知识库中心化的培训/考试平台」进一步收口为:
|
||||
|
||||
**一套先通用、后定制的数字员工平台产品路线。**
|
||||
|
||||
本文重点回答五个问题:
|
||||
|
||||
- 当前阶段到底做什么,不做什么
|
||||
- 为什么不直接对标 WorkBuddy 的高自由度路线
|
||||
- 第一阶段应该先沉淀哪些通用数字员工
|
||||
- 现有项目哪些能力可以直接复用,哪些能力需要补齐
|
||||
- 后续如何从通用包自然演进到行业包和企业定制
|
||||
|
||||
本文属于 SY03、SY04、SY18 之后的收口文档。它不替代平台战略,而是把当前阶段的产品打法定死。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前判断
|
||||
|
||||
### 2.1 不直接做「高自由度 WorkBuddy」
|
||||
|
||||
WorkBuddy 的方向是高自由度、强定制、强执行型桌面智能体,这条路线的优势很明显,但对当前项目并不是最合适的第一步。
|
||||
|
||||
如果现在直接走这条路,会同时遇到几个问题:
|
||||
|
||||
- 产品边界太大,容易从培训系统、知识库、工作台、低代码平台一路发散
|
||||
- 不同企业之间复用性弱,难形成标准交付包
|
||||
- 权限、审核、知识边界、结果质量都更难控
|
||||
- 销售和交付阶段难讲清「标准版到底能直接帮客户做什么」
|
||||
|
||||
因此,当前项目不应该先做「无限自由的 AI 办公工作台」,而应该先做:
|
||||
|
||||
**标准化、可复制、可交付、可升级的通用数字员工平台。**
|
||||
|
||||
### 2.2 当前阶段的产品定义
|
||||
|
||||
项目当前阶段统一定义为:
|
||||
|
||||
**一个以知识库为底座、以数字员工为能力单元、面向企业通用办公场景的数字员工平台。**
|
||||
|
||||
关键词有四个:
|
||||
|
||||
- **知识底座**:所有数字员工都必须受知识空间、引用和权限边界约束
|
||||
- **能力单元**:面向用户的不是一个大而全 AI,而是一组职责明确的数字员工
|
||||
- **通用场景**:先覆盖跨行业共性工作,不先做重行业定制
|
||||
- **平台化交付**:可安装、可启停、可配置、可审核、可追溯
|
||||
|
||||
---
|
||||
|
||||
## 3. 当前阶段的产品原则
|
||||
|
||||
### 3.1 总原则
|
||||
|
||||
当前阶段的产品铁律只有一句话:
|
||||
|
||||
**先做通用数字员工产品包,不做无限自由平台。**
|
||||
|
||||
### 3.2 五条强约束
|
||||
|
||||
#### 原则 1:主界面从「问知识库」切到「把任务交给数字员工」
|
||||
|
||||
知识库仍然重要,但它应该退到后台成为数字员工的大脑底座。
|
||||
|
||||
面向用户的主叙事应改为:
|
||||
|
||||
- 我有哪些数字员工
|
||||
- 每个数字员工负责什么
|
||||
- 我应该把任务交给谁
|
||||
- 它现在推进到哪一步
|
||||
- 它交付了什么
|
||||
- 依据来自哪里
|
||||
- 哪一步需要我确认
|
||||
|
||||
#### 原则 2:每个数字员工必须职责清晰、边界明确
|
||||
|
||||
每个数字员工都必须说清楚:
|
||||
|
||||
- 它是谁
|
||||
- 它负责什么
|
||||
- 它不负责什么
|
||||
- 它可以访问哪些知识和资源
|
||||
- 它最后交付什么结果
|
||||
|
||||
不允许继续把多个能力混成一个「什么都能干的大助手」。
|
||||
|
||||
#### 原则 3:所有结果都要可追溯
|
||||
|
||||
这是当前项目相对 WorkBuddy 最值得保留并放大的优势。
|
||||
|
||||
所有数字员工输出都要尽量具备:
|
||||
|
||||
- 命中层级
|
||||
- 原文引用
|
||||
- 来源证据
|
||||
- 任务记录
|
||||
- 审核状态
|
||||
|
||||
这既是企业可信度要求,也是后续做合规、认证、复盘、经营分析的基础。
|
||||
|
||||
#### 原则 4:先做可配置,不做完全开放式编排
|
||||
|
||||
当前阶段只开放以下几类配置:
|
||||
|
||||
- 名称与描述
|
||||
- 适用部门/岗位
|
||||
- 绑定知识空间
|
||||
- 绑定模板
|
||||
- 输出格式
|
||||
- 审核人
|
||||
- 是否启用某连接器/能力
|
||||
|
||||
当前阶段不开放以下能力为一线主路径:
|
||||
|
||||
- 任意工作流编排
|
||||
- 任意动作拼装
|
||||
- 自定义 DSL
|
||||
- 完全开放的 Skill 开发模式
|
||||
- 无限自由的连接器联动
|
||||
|
||||
#### 原则 5:通用包优先,行业包和企业定制后置
|
||||
|
||||
第一阶段先做通用包,是为了先形成:
|
||||
|
||||
- 明确的产品边界
|
||||
- 统一的数据结构
|
||||
- 统一的任务闭环
|
||||
- 统一的交付机制
|
||||
- 可复制的商业版本
|
||||
|
||||
行业包、企业定制都必须建立在这套标准底座之上。
|
||||
|
||||
---
|
||||
|
||||
## 4. 产品心智模型
|
||||
|
||||
### 4.1 平台对用户的呈现方式
|
||||
|
||||
对用户来说,平台不是一个聊天框,而应该是一组可调用的数字员工。
|
||||
|
||||
统一心智如下:
|
||||
|
||||
```
|
||||
企业数字员工平台
|
||||
├─ 专员市场:我有哪些数字员工可安装/可启用
|
||||
├─ 专员工作区:某个数字员工怎么接任务、交付结果
|
||||
├─ 任务中心:事项现在做到哪一步
|
||||
├─ 交付中心:已经产出过什么结果
|
||||
└─ 知识底座:这些数字员工基于哪些可信知识在工作
|
||||
```
|
||||
|
||||
### 4.2 每个数字员工的最小定义
|
||||
|
||||
在 SY18 的「信源 / 动作 / 结果 / 权限」基础上,当前阶段进一步面向产品统一为五件套:
|
||||
|
||||
1. **角色**:它是谁,负责什么
|
||||
2. **知识**:它能访问哪些知识空间、FAQ、模板、案例
|
||||
3. **动作**:它能执行哪些标准动作
|
||||
4. **交付**:它能产出什么
|
||||
5. **审核**:哪些结果需要人确认
|
||||
|
||||
可理解为:
|
||||
|
||||
```
|
||||
数字员工 = 角色 + 知识 + 动作 + 交付 + 审核
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- SY18 更偏业务前台的呈现模型
|
||||
- 本文更偏当前产品阶段的标准化定义模型
|
||||
|
||||
两者不冲突,可共同存在。
|
||||
|
||||
---
|
||||
|
||||
## 5. 第一阶段:先做 4 个通用数字员工
|
||||
|
||||
当前阶段不建议同时铺很多专员。第一批只做 4 个通用数字员工,确保每个都能真正形成闭环。
|
||||
|
||||
### 5.1 知识运营专员
|
||||
|
||||
**定位:** 负责知识资料整理、摘要、FAQ 沉淀、知识空间维护的通用专员。
|
||||
|
||||
**典型任务:**
|
||||
|
||||
- 整理上传资料
|
||||
- 提取重点摘要
|
||||
- 生成 FAQ 初稿
|
||||
- 标记失效知识
|
||||
- 按主题归档
|
||||
|
||||
**交付结果:**
|
||||
|
||||
- FAQ 条目
|
||||
- 知识摘要
|
||||
- 知识卡片
|
||||
- 待审核知识项
|
||||
|
||||
**为什么优先做:**
|
||||
|
||||
- 与当前项目知识入库、审批、FAQ、检索能力直接衔接
|
||||
- 各行业通用
|
||||
- 能快速体现平台的知识底座价值
|
||||
|
||||
### 5.2 培训考试专员
|
||||
|
||||
**定位:** 负责岗位应学范围、题库生成、组卷、学习认证建议的通用专员。
|
||||
|
||||
**典型任务:**
|
||||
|
||||
- 基于知识生成题目初稿
|
||||
- 生成岗位应学清单
|
||||
- 推荐学习路径
|
||||
- 输出岗位考试建议
|
||||
- 生成认证结果说明
|
||||
|
||||
**交付结果:**
|
||||
|
||||
- 岗位学习包
|
||||
- 试卷/题目初稿
|
||||
- 认证建议
|
||||
- 补训建议
|
||||
|
||||
**为什么优先做:**
|
||||
|
||||
- 与当前项目最成熟的培训、考试、岗位映射能力强复用
|
||||
- 是现有底座最快能升级成数字员工形态的一块
|
||||
|
||||
### 5.3 内容生成专员
|
||||
|
||||
**定位:** 负责结构化文档、周报、会议纪要、培训材料、FAQ 卡片生成的通用专员。
|
||||
|
||||
**典型任务:**
|
||||
|
||||
- 生成会议纪要
|
||||
- 生成周报/月报
|
||||
- 输出培训讲义
|
||||
- 输出产品说明/方案初稿
|
||||
- 输出标准 FAQ 卡片
|
||||
|
||||
**交付结果:**
|
||||
|
||||
- Word/Markdown/HTML 文档
|
||||
- 培训材料初稿
|
||||
- 话术/FAQ 卡片
|
||||
- 结构化摘要
|
||||
|
||||
**为什么优先做:**
|
||||
|
||||
- 企业通用需求高频
|
||||
- 最容易让客户快速感知价值
|
||||
- 适合作为“任务 -> 草稿 -> 审核 -> 发布”的标准闭环示范
|
||||
|
||||
### 5.4 任务推进专员
|
||||
|
||||
**定位:** 负责任务拆解、状态推进、待确认项提醒、结果归档的通用专员。
|
||||
|
||||
**典型任务:**
|
||||
|
||||
- 接收任务并拆解步骤
|
||||
- 跟踪事项状态
|
||||
- 提醒待确认项
|
||||
- 汇总风险点
|
||||
- 归档交付结果
|
||||
|
||||
**交付结果:**
|
||||
|
||||
- 任务清单
|
||||
- 推进状态
|
||||
- 风险提醒
|
||||
- 交付归档包
|
||||
|
||||
**为什么优先做:**
|
||||
|
||||
- 这是把聊天式 AI 拉成工作流式 AI 的关键
|
||||
- 与当前 `worker_task`、`worker_artifact` 等结构最接近
|
||||
|
||||
---
|
||||
|
||||
## 6. 当前项目能力复用判断
|
||||
|
||||
### 6.1 已经具备、可直接复用的底座
|
||||
|
||||
结合当前 PRD、PROJECT_STATE 和代码现状,以下能力已经具备较强复用价值:
|
||||
|
||||
#### 1. 知识底座
|
||||
|
||||
- 素材上传、审批、入库
|
||||
- 文档切片与检索
|
||||
- FAQ / 混合检索 / 引用链路
|
||||
- 知识空间与工作台知识问答
|
||||
|
||||
#### 2. 人群与学习底座
|
||||
|
||||
- 岗位映射
|
||||
- 岗位应学清单
|
||||
- 考试蓝图
|
||||
- 错题本、证书、学习档案、能力雷达
|
||||
|
||||
#### 3. 平台雏形
|
||||
|
||||
- 专员市场
|
||||
- 控制台
|
||||
- 工作台概览
|
||||
- Studio / Market / KnowledgeHub 等前台入口
|
||||
|
||||
#### 4. 任务与交付雏形
|
||||
|
||||
- `worker_task`
|
||||
- `worker_artifact`
|
||||
- 工作台待办、风险、最近交付物
|
||||
|
||||
### 6.2 当前仍然偏弱、需要补齐的关键能力
|
||||
|
||||
#### 1. 数字员工统一定义仍不够收口
|
||||
|
||||
虽然已有 specialist 模型,但当前字段仍偏展示/概念堆叠,需要进一步统一成标准产品模型。
|
||||
|
||||
#### 2. 任务闭环还不够完整
|
||||
|
||||
需要把任务、执行、产出、审核、发布五个阶段拉通,而不是停留在卡片展示层。
|
||||
|
||||
#### 3. 模板中心还不够清晰
|
||||
|
||||
通用数字员工一定要有可复用的模板体系,否则很难形成跨企业复用。
|
||||
|
||||
#### 4. 安装与配置机制还要更产品化
|
||||
|
||||
需要更明确地区分:
|
||||
|
||||
- 哪些专员是已安装
|
||||
- 哪些专员是试用
|
||||
- 哪些专员属于通用包
|
||||
- 哪些专员属于行业包
|
||||
- 某个专员绑定了哪些知识空间、模板、部门、岗位
|
||||
|
||||
---
|
||||
|
||||
## 7. 当前阶段必须补齐的产品闭环
|
||||
|
||||
如果要把当前项目真正升级成「通用数字员工平台」,至少要补齐以下闭环。
|
||||
|
||||
### 7.1 数字员工定义闭环
|
||||
|
||||
每个数字员工都必须明确:
|
||||
|
||||
- 基本信息:名称、类型、适用范围、状态
|
||||
- 角色边界:负责什么、不负责什么
|
||||
- 知识边界:绑定哪些知识空间/FAQ/案例
|
||||
- 动作边界:可执行哪些标准动作
|
||||
- 交付边界:输出哪些标准结果
|
||||
- 审核边界:哪些需要人工确认
|
||||
|
||||
### 7.2 任务闭环
|
||||
|
||||
统一任务生命周期建议为:
|
||||
|
||||
```
|
||||
新任务 -> 待执行 -> 执行中 -> 草稿待确认 -> 已确认 -> 已发布/已归档
|
||||
```
|
||||
|
||||
无论是知识整理、内容生成、培训认证还是任务推进,都应落在同一套任务状态机里。
|
||||
|
||||
### 7.3 交付闭环
|
||||
|
||||
每个交付结果都建议带上:
|
||||
|
||||
- 交付类型
|
||||
- 来源任务
|
||||
- 来源数字员工
|
||||
- 版本
|
||||
- 审核状态
|
||||
- 知识依据/引用
|
||||
- 创建时间
|
||||
- 发布/归档状态
|
||||
|
||||
### 7.4 审核闭环
|
||||
|
||||
通用阶段不做复杂审批流,但必须有最小审核链:
|
||||
|
||||
- 哪类产出允许直接查看
|
||||
- 哪类产出必须确认后才能发布
|
||||
- 谁是默认确认人
|
||||
- 哪些风险必须提示
|
||||
|
||||
---
|
||||
|
||||
## 8. 当前阶段明确不做的事
|
||||
|
||||
为了防止产品再次发散,以下能力在当前阶段明确不作为主目标:
|
||||
|
||||
### 8.1 不做完全开放式 WorkBuddy 路线
|
||||
|
||||
不追求:
|
||||
|
||||
- 用户一句话就能自由编排一切
|
||||
- 桌面级全权限执行
|
||||
- 无限开放的工作流拼装
|
||||
- 无限开放的插件/Skill 开发
|
||||
|
||||
### 8.2 不做重行业定制优先
|
||||
|
||||
不先围绕某一个行业重做数据模型、流程和页面。
|
||||
|
||||
当前阶段行业包只作为第二阶段目标,不作为第一阶段主路径。
|
||||
|
||||
### 8.3 不做“什么都能干”的单一大助手
|
||||
|
||||
不再强化一个泛化聊天助手来承接所有任务。
|
||||
|
||||
当前阶段要坚持:
|
||||
|
||||
**多个职责明确的数字员工 > 一个无限泛化的大助手。**
|
||||
|
||||
---
|
||||
|
||||
## 9. 三阶段路线图
|
||||
|
||||
### 9.1 阶段一:通用数字员工 1.0
|
||||
|
||||
**目标:** 做出可卖、可演示、可复制的标准版产品。
|
||||
|
||||
**核心建设项:**
|
||||
|
||||
- 4 个通用数字员工
|
||||
- 专员市场
|
||||
- 专员工作区
|
||||
- 任务/交付/审核闭环
|
||||
- 模板中心
|
||||
- 知识引用与可追溯
|
||||
- 部门/岗位/知识空间绑定
|
||||
|
||||
**阶段结果:**
|
||||
|
||||
平台能够对外统一表达为:
|
||||
|
||||
> 一套可安装、可审核、可追溯的企业通用数字员工平台,先覆盖知识运营、培训考试、内容生成、任务推进四类共性场景。
|
||||
|
||||
### 9.2 阶段二:行业数字员工包 1.0
|
||||
|
||||
**目标:** 在通用底座之上,生长行业包,而不是重做底座。
|
||||
|
||||
优先建议的行业方向:
|
||||
|
||||
- 销售赋能
|
||||
- 合规审查
|
||||
- 培训认证
|
||||
|
||||
这些方向都与当前知识底座和培训底座天然接近,落地成本相对最低。
|
||||
|
||||
### 9.3 阶段三:企业半定制能力
|
||||
|
||||
**目标:** 在不破坏标准产品的前提下,开放企业可配置能力。
|
||||
|
||||
建议逐步开放:
|
||||
|
||||
- 自定义模板
|
||||
- 自定义知识空间绑定
|
||||
- 自定义专员副本
|
||||
- 自定义连接器绑定
|
||||
- 有边界的工作流配置
|
||||
|
||||
不建议一开始就完全开放到底层编排。
|
||||
|
||||
---
|
||||
|
||||
## 10. 对现有前后端的指导意义
|
||||
|
||||
### 10.1 前端
|
||||
|
||||
后续前端主轴应继续从「模块中心」转向「数字员工中心」:
|
||||
|
||||
- 首页/工作台强调任务与数字员工
|
||||
- 市场页强调专员包安装与分类
|
||||
- 工坊页强调装配与配置,不强调无限自由编排
|
||||
- 知识库页退到数字员工底座与证据支撑角色
|
||||
|
||||
### 10.2 后端
|
||||
|
||||
后续后端优先保障以下统一模型:
|
||||
|
||||
- specialist 的标准定义
|
||||
- worker_task 的统一状态机
|
||||
- worker_artifact 的统一交付模型
|
||||
- knowledge_space 的强边界约束
|
||||
- 模板中心与专员配置关系
|
||||
|
||||
### 10.3 商业表达
|
||||
|
||||
后续对外讲述口径建议统一为:
|
||||
|
||||
> 我们不是先做一个无限自由的 AI 工作台,而是先做一组企业拿来就能用、可审核、可追溯、可升级的数字员工产品包。
|
||||
|
||||
这比「什么都能做」更容易被企业理解,也更容易成交。
|
||||
|
||||
---
|
||||
|
||||
## 11. 最终结论
|
||||
|
||||
当前项目的正确路线不是:
|
||||
|
||||
**先追 WorkBuddy 的自由度,再想办法收回来。**
|
||||
|
||||
而应该是:
|
||||
|
||||
**先把数字员工做成标准化产品包,再在这个底座上逐步开放行业包和企业定制。**
|
||||
|
||||
因此,当前阶段的产品主线正式定为:
|
||||
|
||||
> **先通用,后定制;先产品包,后自由平台。**
|
||||
|
||||
这是后续所有页面、模型、任务系统、交付系统、模板系统的上位约束。
|
||||
@@ -8,6 +8,10 @@ backend-go/data/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# 本地知识服务
|
||||
knowledge_service/data/
|
||||
knowledge_service/__pycache__/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
backend-go/.env
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 项目状态总览(PROJECT_STATE)
|
||||
|
||||
> 单一事实源:新对话默认先读此文件恢复上下文;每次关键决策后必须更新。
|
||||
> 最后更新:2026-08-16
|
||||
> 最后更新:2026-08-24
|
||||
|
||||
## 1. 项目是什么
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
社交社区/讨论区**定死不做**
|
||||
|
||||
### 待开始
|
||||
- ⬜ (暂无待办)前端/后端能力已基本闭环;后续为可选打磨项
|
||||
- ⬜ 按 `docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md`
|
||||
将当前项目从“已有专员雏形”进一步收口为“通用数字员工平台 V1.0”
|
||||
|
||||
## 3. 已拍板决策
|
||||
|
||||
@@ -60,6 +61,7 @@
|
||||
| D17 | 错题本(学员自助,非学情分析,自测/正式均写)+ 简答题 LLM 评分(`essay` 题型,复用 title_gen 路由,`essay_grade` 不扣点) | 已定(P2 落地) |
|
||||
| D18 | 突破原「极致极简/杜绝学情分析」边界:新增学员成长体系 = 学习积分+排行榜(游戏化)+ 考试证书(认证)+ 学习档案/能力雷达(学情,学员自助视角);积分规则集中常量可调 | 已定(V1.6 落地) |
|
||||
| D19 | 部门组织架构(department 字典表,用户以 department 字符串归属、改名同步)+ 按部门学情统计;站内消息通知(考试发布/通过发证/设岗三事件);**社交社区/讨论区定死不做** | 已定(V1.7 落地) |
|
||||
| D20 | 当前阶段产品路线收口为「先通用、后定制」:先做标准化通用数字员工产品包,不直接追 WorkBuddy 的高自由度路线;第一批优先沉淀知识运营、培训考试、内容生成、任务推进 4 个通用数字员工,后续再长行业包与企业半定制 | 已定(见 `docs/01_System_Overall/SY19_Universal_Digital_Worker_Product_Planning.md`) |
|
||||
|
||||
## 4. 文档索引
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"eaisalestrain/backend/internal/config"
|
||||
)
|
||||
|
||||
type KnowledgeClassifyResult struct {
|
||||
Intent string `json:"intent"`
|
||||
Score float64 `json:"score"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type KnowledgeSearchItem struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Content string `json:"content"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
KnowledgeSpaceKey string `json:"knowledge_space_key"`
|
||||
KnowledgeSpaceName string `json:"knowledge_space_name"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type KnowledgeIndexItem struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
KnowledgeSpaceKey string `json:"knowledge_space_key"`
|
||||
KnowledgeSpaceName string `json:"knowledge_space_name"`
|
||||
}
|
||||
|
||||
func KnowledgeServiceEnabled(cfg *config.Config) bool {
|
||||
return cfg != nil && strings.TrimSpace(cfg.KnowledgeServiceURL) != ""
|
||||
}
|
||||
|
||||
func KnowledgeClassify(cfg *config.Config, query string) (*KnowledgeClassifyResult, error) {
|
||||
var resp struct {
|
||||
Data KnowledgeClassifyResult `json:"data"`
|
||||
}
|
||||
if err := postKnowledgeService(cfg, "/classify", map[string]any{
|
||||
"query": query,
|
||||
}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp.Data, nil
|
||||
}
|
||||
|
||||
func KnowledgeSearch(cfg *config.Config, query, spaceKey string, topK int) ([]KnowledgeSearchItem, error) {
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Items []KnowledgeSearchItem `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := postKnowledgeService(cfg, "/search", map[string]any{
|
||||
"query": query,
|
||||
"space_key": spaceKey,
|
||||
"top_k": topK,
|
||||
}, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Data.Items, nil
|
||||
}
|
||||
|
||||
func KnowledgeRebuildIndex(cfg *config.Config, items []KnowledgeIndexItem) error {
|
||||
var resp map[string]any
|
||||
return postKnowledgeService(cfg, "/index/rebuild", map[string]any{
|
||||
"index_dir": cfg.KnowledgeIndexDir,
|
||||
"items": items,
|
||||
}, &resp)
|
||||
}
|
||||
|
||||
func postKnowledgeService(cfg *config.Config, path string, payload any, out any) error {
|
||||
if !KnowledgeServiceEnabled(cfg) {
|
||||
return fmt.Errorf("knowledge service disabled")
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := strings.TrimRight(cfg.KnowledgeServiceURL, "/") + path
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// #region debug-point D:knowledge-service-request
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "D",
|
||||
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:request",
|
||||
"msg": "[DEBUG] knowledge service request",
|
||||
"data": map[string]any{
|
||||
"url": url,
|
||||
"path": path,
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
// #region debug-point D:knowledge-service-non200
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "D",
|
||||
"location": "backend-go/internal/ai/knowledge_service_client.go:postKnowledgeService:non200",
|
||||
"msg": "[DEBUG] knowledge service non-200",
|
||||
"data": map[string]any{
|
||||
"url": url,
|
||||
"status": resp.StatusCode,
|
||||
"bodyPreview": truncate(string(body), 240),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
return fmt.Errorf("knowledge service %d: %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
if out == nil || len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(body, out); err != nil {
|
||||
return fmt.Errorf("knowledge service parse failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -100,7 +100,45 @@ func (c *Client) post(path string, body any) (*http.Response, error) {
|
||||
for k, v := range c.headers() {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
return c.hc.Do(req)
|
||||
// #region debug-point C:llm-post
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "C",
|
||||
"location": "backend-go/internal/ai/llm.go:Client.post:request",
|
||||
"msg": "[DEBUG] llm client request",
|
||||
"data": map[string]any{
|
||||
"url": c.url(path),
|
||||
"model": c.model,
|
||||
"hasAPIKey": c.apiKey != "",
|
||||
"authHeader": req.Header.Get("Authorization") != "",
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// #region debug-point C:llm-response
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "C",
|
||||
"location": "backend-go/internal/ai/llm.go:Client.post:response",
|
||||
"msg": "[DEBUG] llm client response",
|
||||
"data": map[string]any{
|
||||
"url": c.url(path),
|
||||
"status": resp.StatusCode,
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
@@ -222,6 +260,24 @@ func (c *Client) GenerateStream(messages []Message, onChunk func(string)) error
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
// #region debug-point C:llm-non200
|
||||
if payload, err := json.Marshal(map[string]any{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "C",
|
||||
"location": "backend-go/internal/ai/llm.go:GenerateStream:non200",
|
||||
"msg": "[DEBUG] llm stream non-200",
|
||||
"data": map[string]any{
|
||||
"status": resp.StatusCode,
|
||||
"bodyPreview": truncate(string(data), 240),
|
||||
"model": c.model,
|
||||
"baseURL": c.baseURL,
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
return fmt.Errorf("LLM 返回 %d: %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
|
||||
@@ -335,6 +391,41 @@ func GenerateFullWithFallback(primary *config.RouteConfig, messages []Message) (
|
||||
return nil, nil, fmt.Errorf("所有路由均失败: %w", lastErr)
|
||||
}
|
||||
|
||||
// GenerateStreamWithFallback 流式 + 回退链:返回实际命中的路由
|
||||
func GenerateStreamWithFallback(primary *config.RouteConfig, messages []Message, onChunk func(string)) (*config.RouteConfig, error) {
|
||||
chain := []*config.RouteConfig{primary}
|
||||
if fallbacks, err := config.GetFallbackRoutes(primary.RouteID); err == nil && len(fallbacks) > 0 {
|
||||
chain = append(chain, fallbacks...)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, route := range chain {
|
||||
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
|
||||
lastErr = fmt.Errorf("[%s] 未配置 API Key", route.RouteID)
|
||||
continue
|
||||
}
|
||||
client := NewClient(route)
|
||||
if err := client.GenerateStream(messages, onChunk); err == nil {
|
||||
return route, nil
|
||||
} else {
|
||||
lastErr = fmt.Errorf("[%s] %w", route.RouteID, err)
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("所有路由均失败: %w", lastErr)
|
||||
}
|
||||
|
||||
func requiresAPIKey(route *config.RouteConfig) bool {
|
||||
if route == nil {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
|
||||
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
|
||||
return true
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(route.Provider))
|
||||
return provider == "openrouter" || provider == "openai"
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────
|
||||
// 配置解析(兼容旧接口)
|
||||
// ──────────────────────────────────────────────
|
||||
|
||||
@@ -44,22 +44,50 @@ func ChatMessage(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("message 必填"))
|
||||
return
|
||||
}
|
||||
if !checkBalance(c, user, ai.CapabilityAIChat) {
|
||||
return
|
||||
traceID := fmt.Sprintf("chat-%d", time.Now().UnixNano())
|
||||
// #region debug-point B:chat-entry
|
||||
if payload, err := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:entry",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] chat message received",
|
||||
"data": gin.H{
|
||||
"userId": user.ID,
|
||||
"messagePreview": []rune(strings.TrimSpace(req.Message)),
|
||||
"spaceKey": req.Context["knowledge_space_key"],
|
||||
"historySize": len(req.History),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
|
||||
route, err := config.GetRoute("path_coach")
|
||||
// #endregion
|
||||
plan, err := buildKnowledgeAnswerPlan(req.Message, req.Context, req.History)
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
|
||||
web.Fail(c, web.NewBadRequest("知识问答链路执行失败"))
|
||||
return
|
||||
}
|
||||
|
||||
knowledge := ai.Retrieve(Cfg, req.Message, 5)
|
||||
messages := []ai.Message{{Role: "system", Content: buildSystemPrompt(req.Context, knowledge)}}
|
||||
messages = append(messages, req.History...)
|
||||
messages = append(messages, ai.Message{Role: "user", Content: req.Message})
|
||||
|
||||
client := ai.NewClient(route)
|
||||
// #region debug-point B:plan-built
|
||||
if payload, err := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:plan",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] knowledge plan built",
|
||||
"data": gin.H{
|
||||
"intent": plan.Intent,
|
||||
"layer": plan.Layer,
|
||||
"citations": len(plan.Citations),
|
||||
"llmMessageSize": len(plan.LLMMessages),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
@@ -78,11 +106,75 @@ func ChatMessage(c *gin.Context) {
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
writeEvent(gin.H{
|
||||
"type": "meta",
|
||||
"intent": plan.Intent,
|
||||
"layer": plan.Layer,
|
||||
"citations": plan.Citations,
|
||||
})
|
||||
|
||||
if len(plan.LLMMessages) == 0 {
|
||||
writeEvent(gin.H{"type": "text", "content": plan.Answer})
|
||||
writeEvent(gin.H{"type": "done"})
|
||||
return
|
||||
}
|
||||
|
||||
if !checkBalance(c, user, ai.CapabilityAIChat) {
|
||||
writeEvent(gin.H{"type": "error", "message": "AI 点数不足,请联系管理员充值"})
|
||||
return
|
||||
}
|
||||
route, err := config.GetRoute("path_coach")
|
||||
if err != nil {
|
||||
writeEvent(gin.H{"type": "error", "message": "LLM 路由未配置: " + err.Error()})
|
||||
return
|
||||
}
|
||||
// #region debug-point B:llm-route
|
||||
if payload, err := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-route",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] llm route selected",
|
||||
"data": gin.H{
|
||||
"routeId": route.RouteID,
|
||||
"provider": route.Provider,
|
||||
"baseURL": route.BaseURL,
|
||||
"model": route.Model,
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); err == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
start := time.Now()
|
||||
if err := client.GenerateStream(messages, func(chunk string) {
|
||||
usedRoute, err := ai.GenerateStreamWithFallback(route, plan.LLMMessages, func(chunk string) {
|
||||
writeEvent(gin.H{"type": "text", "content": chunk})
|
||||
}); err != nil {
|
||||
writeEvent(gin.H{"type": "error", "message": err.Error()})
|
||||
})
|
||||
if err != nil {
|
||||
// #region debug-point B:llm-error
|
||||
if payload, marshalErr := json.Marshal(gin.H{
|
||||
"sessionId": "knowledge-chat-401",
|
||||
"runId": "pre-fix",
|
||||
"hypothesisId": "B",
|
||||
"location": "backend-go/internal/api/ai_chat.go:ChatMessage:llm-error",
|
||||
"traceId": traceID,
|
||||
"msg": "[DEBUG] llm stream returned error",
|
||||
"data": gin.H{
|
||||
"error": err.Error(),
|
||||
},
|
||||
"ts": time.Now().UnixMilli(),
|
||||
}); marshalErr == nil {
|
||||
go http.Post("http://127.0.0.1:7777/event", "application/json", strings.NewReader(string(payload)))
|
||||
}
|
||||
// #endregion
|
||||
message := err.Error()
|
||||
if strings.Contains(message, "未配置 API Key") || strings.Contains(message, "No cookie auth credentials found") {
|
||||
message = "当前知识库已进入 LLM 兜底层,但系统还没有配置可用的 LLM 服务。请管理员配置 OpenRouter API Key,或启动本地 Ollama 后再重试。"
|
||||
} else if strings.Contains(message, "所有路由均失败") || strings.Contains(message, "LLM 服务不可达") {
|
||||
message = "当前知识库已进入 LLM 兜底层,但本机没有可用的模型服务正在运行。请先启动本地 Ollama,或补充可用的远端模型配置。"
|
||||
}
|
||||
writeEvent(gin.H{"type": "error", "message": message})
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
|
||||
RouteID: route.RouteID, Model: route.Model, Success: false,
|
||||
@@ -91,9 +183,13 @@ func ChatMessage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
writeEvent(gin.H{"type": "done"})
|
||||
finalRoute := route
|
||||
if usedRoute != nil {
|
||||
finalRoute = usedRoute
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: route.Provider,
|
||||
RouteID: route.RouteID, Model: route.Model, Success: true,
|
||||
UserID: user.ID, Capability: ai.CapabilityAIChat, Provider: finalRoute.Provider,
|
||||
RouteID: finalRoute.RouteID, Model: finalRoute.Model, Success: true,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
}
|
||||
@@ -137,15 +233,15 @@ var quickActionTask = map[string]struct {
|
||||
}{
|
||||
"commission": {
|
||||
query: "产品佣金 佣金规则 回款 结算 公开课奖励",
|
||||
task: "请基于下方知识库,汇总相关产品的佣金比例、结算规则与奖励规则;知识库未覆盖的部分明确说明,不得臆测。",
|
||||
task: "请基于下方知识库,输出“佣金/规则清单”。格式固定为:一、适用范围;二、核心规则;三、注意事项;四、建议追问。每部分用短句或条目表达,不得编造知识库未覆盖的内容。",
|
||||
},
|
||||
"compare": {
|
||||
query: "产品对比 定位 收费 适用场景",
|
||||
task: "请基于下方知识库,对比相关产品的定位、收费与适用场景,突出差异。",
|
||||
task: "请基于下方知识库,输出“产品对比结果”。格式固定为:一、对比对象;二、共同点;三、核心差异;四、适用客户;五、销售建议。不要写成普通摘要,要写成结构化对比结果。",
|
||||
},
|
||||
"scenario": {
|
||||
query: "销售话术 销售流程 异议处理 情景演练",
|
||||
task: "请基于下方知识库,扮演销售进行客户情景演练:推介相关产品或课程,并演示异议处理话术。",
|
||||
task: "请基于下方知识库,输出一段“客户情景演练脚本”。格式固定为:客户、销售、客户、销售 四轮对话;最后补一行“本轮话术重点”。语言要像真实销售沟通,不要写成说明文。",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -167,17 +263,39 @@ func QuickAction(c *gin.Context) {
|
||||
if !checkBalance(c, user, ai.CapabilityTextGen) {
|
||||
return
|
||||
}
|
||||
route, err := config.GetRoute("title_gen")
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewLLMNotConfigured("LLM 路由未配置: "+err.Error()))
|
||||
return
|
||||
}
|
||||
task, ok := quickActionTask[req.ActionID]
|
||||
if !ok {
|
||||
web.Fail(c, web.NewBadRequest("未知快捷动作"))
|
||||
return
|
||||
}
|
||||
knowledge := ai.Retrieve(Cfg, task.query, 5)
|
||||
spaceKey := extractKnowledgeSpaceKey(req.Params)
|
||||
citations := retrieveKnowledgeCitations(task.query, spaceKey, 5)
|
||||
fallbackResult := buildQuickActionFallback(req.ActionID, citations)
|
||||
if len(citations) == 0 {
|
||||
web.OK(c, gin.H{
|
||||
"result": fallbackResult,
|
||||
"citations": citations,
|
||||
"layer": layerVector,
|
||||
"intent": intentDocument,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
knowledge := make([]string, 0, len(citations))
|
||||
for _, item := range citations {
|
||||
knowledge = append(knowledge, item.Content)
|
||||
}
|
||||
route, err := config.GetRoute("title_gen")
|
||||
if err != nil {
|
||||
web.OK(c, gin.H{
|
||||
"result": fallbackResult,
|
||||
"citations": citations,
|
||||
"layer": layerVector,
|
||||
"intent": intentDocument,
|
||||
"fallback": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
systemPrompt := buildSystemPrompt(req.Params, knowledge) + "\n\n当前任务:" + task.task
|
||||
messages := []ai.Message{
|
||||
{Role: "system", Content: systemPrompt},
|
||||
@@ -192,7 +310,13 @@ func QuickAction(c *gin.Context) {
|
||||
RouteID: route.RouteID, Model: route.Model, Success: false,
|
||||
ErrorMessage: err.Error(), LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
web.Fail(c, web.NewLLMError("LLM 调用失败:"+err.Error()))
|
||||
web.OK(c, gin.H{
|
||||
"result": fallbackResult,
|
||||
"citations": citations,
|
||||
"layer": layerVector,
|
||||
"intent": intentDocument,
|
||||
"fallback": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
ai.LogCall(ai.LogEntry{
|
||||
@@ -201,9 +325,99 @@ func QuickAction(c *gin.Context) {
|
||||
TokensInput: result.Usage.PromptTokens, TokensOutput: result.Usage.CompletionTokens,
|
||||
LatencyMs: int(time.Since(start).Milliseconds()),
|
||||
})
|
||||
out := gin.H{"result": result.Content}
|
||||
out := gin.H{
|
||||
"result": result.Content,
|
||||
"citations": citations,
|
||||
"layer": layerLLM,
|
||||
"intent": intentDocument,
|
||||
}
|
||||
if req.ActionID == "scenario" {
|
||||
out["mode"] = "scenario"
|
||||
}
|
||||
web.OK(c, out)
|
||||
}
|
||||
|
||||
func buildQuickActionFallback(actionID string, citations []knowledgeCitation) string {
|
||||
if len(citations) == 0 {
|
||||
return "当前知识库里还没有找到足够资料,暂时无法生成这个快捷结果。建议先切换到更合适的知识库,或继续补充 FAQ 与文档后再试。"
|
||||
}
|
||||
switch actionID {
|
||||
case "commission":
|
||||
lines := []string{
|
||||
"佣金/规则清单",
|
||||
"",
|
||||
"一、适用范围",
|
||||
"适用于当前知识库里已收录的产品佣金、谈判规则与相关业务口径。",
|
||||
"",
|
||||
"二、核心规则",
|
||||
}
|
||||
for idx, item := range citations {
|
||||
if idx >= 3 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(item.Snippet)))
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"三、注意事项",
|
||||
"- 涉及价格政策、合同条款、付款条件等敏感信息时,应按制度要求先审阅后发布。",
|
||||
"- 知识库未明确写到的比例、例外情况或特殊口径,不要直接对外承诺。",
|
||||
"",
|
||||
"四、建议追问",
|
||||
"- 帮我按产品分别列出佣金规则",
|
||||
"- 哪些规则属于可谈判范围",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
case "compare":
|
||||
lines := []string{
|
||||
"产品对比结果",
|
||||
"",
|
||||
"一、对比对象",
|
||||
"- 当前知识库可优先对比公开课、训练营、陪跑型服务等方案。",
|
||||
"",
|
||||
"二、共同点",
|
||||
"- 都服务于客户学习提升、业务落地或能力建设目标。",
|
||||
"",
|
||||
"三、核心差异",
|
||||
}
|
||||
for idx, item := range citations {
|
||||
if idx >= 3 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(item.Snippet)))
|
||||
}
|
||||
lines = append(lines,
|
||||
"",
|
||||
"四、适用客户",
|
||||
"- 公开课更适合快速普及认知;训练营和陪跑型方案更适合需要结果产出和过程辅导的客户。",
|
||||
"",
|
||||
"五、销售建议",
|
||||
"- 先判断客户是“只想了解”还是“希望真正落地”,再决定推荐公开课还是训练营/陪跑服务。",
|
||||
)
|
||||
return strings.Join(lines, "\n")
|
||||
case "scenario":
|
||||
first := strings.TrimSpace(citations[0].Snippet)
|
||||
second := first
|
||||
if len(citations) > 1 {
|
||||
second = strings.TrimSpace(citations[1].Snippet)
|
||||
}
|
||||
if first == "" {
|
||||
first = strings.TrimSpace(citations[0].Content)
|
||||
}
|
||||
if second == "" {
|
||||
second = first
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"客户情景演练脚本",
|
||||
"",
|
||||
"客户:我们之前也上过一些课,但团队学完还是落不了地,你们这边有什么更适合的方案吗?",
|
||||
"销售:有的。根据我们当前知识库里的资料," + first,
|
||||
"客户:那如果我们更关注执行过程,担心中途推进不动,怎么办?",
|
||||
"销售:" + second,
|
||||
"",
|
||||
"本轮话术重点:先确认客户是“只想了解”还是“希望真正落地”,再把课程、作业、点评和陪跑闭环讲清楚。",
|
||||
}, "\n")
|
||||
default:
|
||||
return buildVectorDirectAnswer(citations)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,12 +56,13 @@ func KnowledgeScan(c *gin.Context) {
|
||||
Domain: orDefault(fm["domain"], "product"),
|
||||
SourceVersion: fm["version"],
|
||||
AuditStatus: "pending",
|
||||
KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(orDefault(fm["knowledge_space_key"], inferKnowledgeSpaceKey(parseTitle(string(data)), fm["domain"], fm["category"]+" "+e.Name()))),
|
||||
}
|
||||
if err := store.DB.Create(&src).Error; err != nil {
|
||||
results = append(results, gin.H{"file_path": e.Name(), "status": "error", "title": src.Title})
|
||||
continue
|
||||
}
|
||||
results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title})
|
||||
results = append(results, gin.H{"file_path": e.Name(), "status": "created", "title": src.Title, "knowledge_space_key": src.KnowledgeSpaceKey})
|
||||
}
|
||||
web.OK(c, gin.H{"results": results})
|
||||
}
|
||||
@@ -72,6 +73,9 @@ func KnowledgeAuditList(c *gin.Context) {
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("audit_status = ?", s)
|
||||
}
|
||||
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
|
||||
q = q.Where("knowledge_space_key = ?", key)
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
if page < 1 {
|
||||
@@ -129,6 +133,7 @@ func KnowledgeAudit(c *gin.Context) {
|
||||
web.Fail(c, web.NewBadRequest("审批失败"))
|
||||
return
|
||||
}
|
||||
triggerKnowledgeIndexRebuild()
|
||||
web.OK(c, gin.H{
|
||||
"status": "approved", "source_id": src.ID,
|
||||
"products": counts[0], "chunks": counts[1], "questions": counts[2],
|
||||
@@ -167,6 +172,7 @@ func KnowledgeStatus(c *gin.Context) {
|
||||
"audit_status": src.AuditStatus,
|
||||
"ingested": src.Ingested,
|
||||
"reject_reason": src.RejectReason,
|
||||
"knowledge_space_key": resolveKnowledgeSourceSpaceKey(src),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,6 +355,7 @@ func ingestSource(src *model.KnowledgeSource) ([3]int, error) {
|
||||
KnowledgeSourceID: &src.ID,
|
||||
SourceType: "md",
|
||||
SourceID: strconv.FormatUint(uint64(src.ID), 10),
|
||||
KnowledgeSpaceKey: resolveKnowledgeSourceSpaceKey(*src),
|
||||
ChunkIndex: i,
|
||||
Content: text,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
// ListKnowledgeFAQs GET /api/knowledge/faqs?knowledge_space_key=&status=&keyword=&page=&size=
|
||||
func ListKnowledgeFAQs(c *gin.Context) {
|
||||
q := store.DB.Model(&model.KnowledgeFAQ{})
|
||||
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
|
||||
q = q.Where("knowledge_space_key = ?", key)
|
||||
}
|
||||
if status := strings.TrimSpace(c.Query("status")); status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
if keyword := strings.TrimSpace(c.Query("keyword")); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
q = q.Where("question LIKE ? OR answer LIKE ? OR keywords LIKE ?", like, like, like)
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var items []model.KnowledgeFAQ
|
||||
if err := q.Order("sort_order ASC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("加载 FAQ 失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{
|
||||
"total": total,
|
||||
"items": buildFAQItems(items),
|
||||
})
|
||||
}
|
||||
|
||||
// CreateKnowledgeFAQ POST /api/knowledge/faqs
|
||||
func CreateKnowledgeFAQ(c *gin.Context) {
|
||||
var req struct {
|
||||
KnowledgeSpaceKey string `json:"knowledge_space_key"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SimilarQuestions []string `json:"similar_questions"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
question := strings.TrimSpace(req.Question)
|
||||
answer := strings.TrimSpace(req.Answer)
|
||||
if question == "" || answer == "" {
|
||||
web.Fail(c, web.NewBadRequest("question 与 answer 必填"))
|
||||
return
|
||||
}
|
||||
similarJSON, _ := json.Marshal(cleanStringList(req.SimilarQuestions))
|
||||
faq := model.KnowledgeFAQ{
|
||||
KnowledgeSpaceKey: ensureKnowledgeSpaceKeyOrDefault(req.KnowledgeSpaceKey),
|
||||
Question: question,
|
||||
Answer: answer,
|
||||
SimilarQuestions: string(similarJSON),
|
||||
Keywords: strings.Join(cleanStringList(req.Keywords), ","),
|
||||
Status: orDefault(strings.TrimSpace(req.Status), "active"),
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
if err := store.DB.Create(&faq).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建 FAQ 失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"item": buildFAQItem(faq)})
|
||||
}
|
||||
|
||||
// UpdateKnowledgeFAQ PUT /api/knowledge/faqs/:id
|
||||
func UpdateKnowledgeFAQ(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var faq model.KnowledgeFAQ
|
||||
if err := store.DB.First(&faq, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("FAQ 不存在"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
KnowledgeSpaceKey string `json:"knowledge_space_key"`
|
||||
Question string `json:"question"`
|
||||
Answer string `json:"answer"`
|
||||
SimilarQuestions []string `json:"similar_questions"`
|
||||
Keywords []string `json:"keywords"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
question := strings.TrimSpace(req.Question)
|
||||
answer := strings.TrimSpace(req.Answer)
|
||||
if question == "" || answer == "" {
|
||||
web.Fail(c, web.NewBadRequest("question 与 answer 必填"))
|
||||
return
|
||||
}
|
||||
similarJSON, _ := json.Marshal(cleanStringList(req.SimilarQuestions))
|
||||
faq.KnowledgeSpaceKey = ensureKnowledgeSpaceKeyOrDefault(req.KnowledgeSpaceKey)
|
||||
faq.Question = question
|
||||
faq.Answer = answer
|
||||
faq.SimilarQuestions = string(similarJSON)
|
||||
faq.Keywords = strings.Join(cleanStringList(req.Keywords), ",")
|
||||
faq.Status = orDefault(strings.TrimSpace(req.Status), "active")
|
||||
faq.SortOrder = req.SortOrder
|
||||
if err := store.DB.Save(&faq).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新 FAQ 失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"item": buildFAQItem(faq)})
|
||||
}
|
||||
|
||||
// DeleteKnowledgeFAQ DELETE /api/knowledge/faqs/:id
|
||||
func DeleteKnowledgeFAQ(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var faq model.KnowledgeFAQ
|
||||
if err := store.DB.First(&faq, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("FAQ 不存在"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&faq).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除 FAQ 失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
func buildFAQItems(items []model.KnowledgeFAQ) []gin.H {
|
||||
out := make([]gin.H, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, buildFAQItem(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildFAQItem(item model.KnowledgeFAQ) gin.H {
|
||||
return gin.H{
|
||||
"id": item.ID,
|
||||
"knowledge_space_key": item.KnowledgeSpaceKey,
|
||||
"knowledge_space_name": getKnowledgeSpaceDisplayName(item.KnowledgeSpaceKey),
|
||||
"question": item.Question,
|
||||
"answer": item.Answer,
|
||||
"similar_questions": parseJSONArray(item.SimilarQuestions),
|
||||
"keywords": splitCSV(item.Keywords),
|
||||
"status": item.Status,
|
||||
"sort_order": item.SortOrder,
|
||||
"hit_count": item.HitCount,
|
||||
"created_at": item.CreatedAt,
|
||||
"updated_at": item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONArray(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return []string{}
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return []string{}
|
||||
}
|
||||
return cleanStringList(out)
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
parts := strings.Split(raw, ",")
|
||||
return cleanStringList(parts)
|
||||
}
|
||||
|
||||
func cleanStringList(items []string) []string {
|
||||
out := make([]string, 0, len(items))
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/ai"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
var knowledgeIndexRebuildLock sync.Mutex
|
||||
|
||||
// RebuildKnowledgeIndex POST /api/knowledge/index/rebuild
|
||||
func RebuildKnowledgeIndex(c *gin.Context) {
|
||||
count, err := rebuildKnowledgeIndexNow()
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("重建向量索引失败: "+err.Error()))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{
|
||||
"rebuild": true,
|
||||
"chunk_count": count,
|
||||
"service_enabled": ai.KnowledgeServiceEnabled(Cfg),
|
||||
})
|
||||
}
|
||||
|
||||
func triggerKnowledgeIndexRebuild() {
|
||||
if !ai.KnowledgeServiceEnabled(Cfg) {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
if count, err := rebuildKnowledgeIndexNow(); err != nil {
|
||||
log.Printf("[知识索引] 重建失败: %v", err)
|
||||
} else {
|
||||
log.Printf("[知识索引] 重建完成,chunks=%d", count)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func rebuildKnowledgeIndexNow() (int, error) {
|
||||
knowledgeIndexRebuildLock.Lock()
|
||||
defer knowledgeIndexRebuildLock.Unlock()
|
||||
|
||||
items := buildKnowledgeIndexItems()
|
||||
if err := ai.KnowledgeRebuildIndex(Cfg, items); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(items), nil
|
||||
}
|
||||
|
||||
func buildKnowledgeIndexItems() []ai.KnowledgeIndexItem {
|
||||
var chunks []model.KnowledgeChunk
|
||||
store.DB.Order("id ASC").Find(&chunks)
|
||||
|
||||
var mediaFiles []model.MediaFile
|
||||
store.DB.Where("status = ?", "approved").Find(&mediaFiles)
|
||||
mediaMap := make(map[uint]model.MediaFile, len(mediaFiles))
|
||||
for _, item := range mediaFiles {
|
||||
mediaMap[item.ID] = item
|
||||
}
|
||||
|
||||
var sources []model.KnowledgeSource
|
||||
store.DB.Where("audit_status = ?", "approved").Find(&sources)
|
||||
sourceMap := make(map[uint]model.KnowledgeSource, len(sources))
|
||||
for _, item := range sources {
|
||||
sourceMap[item.ID] = item
|
||||
}
|
||||
|
||||
items := make([]ai.KnowledgeIndexItem, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
title, spaceKey, ok := resolveChunkMeta(chunk, mediaMap, sourceMap)
|
||||
if !ok || chunk.Content == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, ai.KnowledgeIndexItem{
|
||||
ID: chunk.ID,
|
||||
Title: title,
|
||||
Content: chunk.Content,
|
||||
ChunkIndex: chunk.ChunkIndex,
|
||||
SourceType: chunk.SourceType,
|
||||
SourceID: chunk.SourceID,
|
||||
KnowledgeSpaceKey: spaceKey,
|
||||
KnowledgeSpaceName: getKnowledgeSpaceDisplayName(spaceKey),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eaisalestrain/backend/internal/ai"
|
||||
"eaisalestrain/backend/internal/config"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
intentInvalid = "invalid"
|
||||
intentSmallTalk = "smalltalk"
|
||||
intentOutOfScope = "out_of_scope"
|
||||
intentFAQ = "faq"
|
||||
intentDocument = "document"
|
||||
|
||||
layerClassifier = "bert_classifier"
|
||||
layerFAQ = "faq"
|
||||
layerVector = "vector"
|
||||
layerLLM = "llm_fallback"
|
||||
)
|
||||
|
||||
type knowledgeCitation struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
Content string `json:"content"`
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
KnowledgeSpaceKey string `json:"knowledge_space_key"`
|
||||
KnowledgeSpaceName string `json:"knowledge_space_name"`
|
||||
Score float64 `json:"score"`
|
||||
}
|
||||
|
||||
type knowledgeAnswerPlan struct {
|
||||
Intent string
|
||||
Layer string
|
||||
Answer string
|
||||
Citations []knowledgeCitation
|
||||
LLMMessages []ai.Message
|
||||
}
|
||||
|
||||
func buildKnowledgeAnswerPlan(query string, ctx map[string]any, history []ai.Message) (*knowledgeAnswerPlan, error) {
|
||||
intent, interceptAnswer := classifyKnowledgeIntent(query)
|
||||
if interceptAnswer != "" {
|
||||
return &knowledgeAnswerPlan{
|
||||
Intent: intent,
|
||||
Layer: layerClassifier,
|
||||
Answer: interceptAnswer,
|
||||
Citations: []knowledgeCitation{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
spaceKey := extractKnowledgeSpaceKey(ctx)
|
||||
if intent == intentFAQ {
|
||||
if faq, ok := matchKnowledgeFAQ(query, spaceKey); ok {
|
||||
return &knowledgeAnswerPlan{
|
||||
Intent: intent,
|
||||
Layer: layerFAQ,
|
||||
Answer: faq.Answer,
|
||||
Citations: []knowledgeCitation{{
|
||||
ID: faq.ID,
|
||||
Title: "FAQ 标准答案",
|
||||
Snippet: faq.Question,
|
||||
Content: faq.Answer,
|
||||
SourceType: "faq",
|
||||
SourceID: fmt.Sprintf("%d", faq.ID),
|
||||
KnowledgeSpaceKey: faq.KnowledgeSpaceKey,
|
||||
KnowledgeSpaceName: getKnowledgeSpaceDisplayName(faq.KnowledgeSpaceKey),
|
||||
Score: 1,
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
citations := retrieveKnowledgeCitations(query, spaceKey, 5)
|
||||
if canDirectAnswerFromVector(query, citations) {
|
||||
return &knowledgeAnswerPlan{
|
||||
Intent: intent,
|
||||
Layer: layerVector,
|
||||
Answer: buildVectorDirectAnswer(citations),
|
||||
Citations: citations,
|
||||
}, nil
|
||||
}
|
||||
|
||||
messages := []ai.Message{{
|
||||
Role: "system",
|
||||
Content: buildKnowledgePipelinePrompt(ctx, intent, citations),
|
||||
}}
|
||||
messages = append(messages, history...)
|
||||
messages = append(messages, ai.Message{Role: "user", Content: query})
|
||||
return &knowledgeAnswerPlan{
|
||||
Intent: intent,
|
||||
Layer: layerLLM,
|
||||
Citations: citations,
|
||||
LLMMessages: messages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func classifyKnowledgeIntent(query string) (string, string) {
|
||||
if ai.KnowledgeServiceEnabled(Cfg) {
|
||||
if result, err := ai.KnowledgeClassify(Cfg, query); err == nil && result != nil {
|
||||
switch result.Intent {
|
||||
case intentSmallTalk:
|
||||
return intentSmallTalk, buildSmallTalkAnswer(query)
|
||||
case intentOutOfScope:
|
||||
if result.Score >= 0.9 {
|
||||
return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。"
|
||||
}
|
||||
case intentInvalid:
|
||||
if result.Score >= 0.9 {
|
||||
return intentInvalid, "请输入更明确的问题后再试。"
|
||||
}
|
||||
case intentFAQ, intentDocument:
|
||||
if result.Score >= 0.6 {
|
||||
return result.Intent, ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return heuristicKnowledgeIntent(query)
|
||||
}
|
||||
|
||||
func heuristicKnowledgeIntent(query string) (string, string) {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
if len([]rune(trimmed)) < 2 {
|
||||
return intentInvalid, "请输入更明确的问题后再试。"
|
||||
}
|
||||
lower := strings.ToLower(trimmed)
|
||||
smallTalkKeywords := []string{"讲个故事", "小故事", "笑话", "闲聊", "聊天", "夸夸我", "你是谁", "今天天气", "天气", "星座"}
|
||||
for _, keyword := range smallTalkKeywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
return intentSmallTalk, buildSmallTalkAnswer(query)
|
||||
}
|
||||
}
|
||||
outOfScopeKeywords := []string{"股票", "彩票", "电影", "明星", "旅游", "菜谱", "医学诊断", "法律咨询", "写诗", "翻译成英文"}
|
||||
for _, keyword := range outOfScopeKeywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
return intentOutOfScope, "当前知识库仅面向博昇 AI 实验室内部业务知识,不处理越域问题。"
|
||||
}
|
||||
}
|
||||
documentKeywords := []string{"总结", "梳理", "分析", "对比", "归纳", "提纲", "解读", "起草", "生成", "合同", "报告", "方案", "条款", "根据资料", "根据文档", "整理"}
|
||||
for _, keyword := range documentKeywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
return intentDocument, ""
|
||||
}
|
||||
}
|
||||
faqKeywords := []string{"如何", "怎么", "哪里", "在哪", "是否", "有没有", "可以", "支持", "密码", "登录", "佣金", "规则", "流程", "审批", "上传", "删除", "新建"}
|
||||
for _, keyword := range faqKeywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
return intentFAQ, ""
|
||||
}
|
||||
}
|
||||
if len([]rune(trimmed)) <= 18 {
|
||||
return intentFAQ, ""
|
||||
}
|
||||
return intentDocument, ""
|
||||
}
|
||||
|
||||
func buildSmallTalkAnswer(query string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(query))
|
||||
switch {
|
||||
case strings.Contains(lower, "你是谁"):
|
||||
return "你好,我是博昇 AI 实验室的知识库助手,负责基于企业知识库回答业务制度、产品资料、培训内容和规则流程相关问题。你可以直接提问,也可以先选择左侧知识库缩小检索范围。"
|
||||
case strings.Contains(lower, "你能做什么"), strings.Contains(lower, "怎么工作"):
|
||||
return "我可以帮你回答制度规则、产品资料、FAQ、培训文档相关问题,也可以基于知识库做摘要、梳理、对比和提纲生成。默认会全局检索全部知识库,你也可以在左侧切换到某个知识库后再提问。"
|
||||
case strings.Contains(lower, "你好"), strings.Contains(lower, "hello"), strings.Contains(lower, "hi"):
|
||||
return "你好,欢迎使用博昇 AI 实验室知识库助手。你可以直接问我产品资料、审批规则、培训内容、FAQ 或制度流程相关问题,我会按知识库内容为你检索和回答。"
|
||||
default:
|
||||
return "你好,我是知识库助手。当前更适合处理业务知识、制度规则、产品资料和培训文档相关问题;如果你愿意,我可以继续帮你查规则、找资料或整理内容。"
|
||||
}
|
||||
}
|
||||
|
||||
func extractKnowledgeSpaceKey(ctx map[string]any) string {
|
||||
if ctx == nil {
|
||||
return "all"
|
||||
}
|
||||
if raw, ok := ctx["knowledge_space_key"]; ok {
|
||||
key := sanitizeSpaceKey(fmt.Sprint(raw))
|
||||
if key == "" {
|
||||
return "all"
|
||||
}
|
||||
if key == "all" {
|
||||
return "all"
|
||||
}
|
||||
return ensureKnowledgeSpaceKeyOrDefault(key)
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
func matchKnowledgeFAQ(query, spaceKey string) (model.KnowledgeFAQ, bool) {
|
||||
spaceKey = sanitizeSpaceKey(spaceKey)
|
||||
q := store.DB.Where("status = ?", "active")
|
||||
if spaceKey != "" && spaceKey != "general" && spaceKey != "all" {
|
||||
q = q.Where("knowledge_space_key IN ?", []string{spaceKey, "general"})
|
||||
}
|
||||
var faqs []model.KnowledgeFAQ
|
||||
if err := q.Order("sort_order ASC, id DESC").Find(&faqs).Error; err != nil {
|
||||
return model.KnowledgeFAQ{}, false
|
||||
}
|
||||
queryNorm := normalizeQuestion(query)
|
||||
queryTerms := buildSearchTerms(query)
|
||||
bestScore := 0
|
||||
var best model.KnowledgeFAQ
|
||||
for _, faq := range faqs {
|
||||
score := scoreFAQ(queryNorm, queryTerms, faq)
|
||||
if score > bestScore {
|
||||
bestScore = score
|
||||
best = faq
|
||||
}
|
||||
}
|
||||
if bestScore < 70 {
|
||||
return model.KnowledgeFAQ{}, false
|
||||
}
|
||||
store.DB.Model(&model.KnowledgeFAQ{}).Where("id = ?", best.ID).
|
||||
UpdateColumn("hit_count", gorm.Expr("hit_count + ?", 1))
|
||||
return best, true
|
||||
}
|
||||
|
||||
func scoreFAQ(queryNorm string, queryTerms []string, faq model.KnowledgeFAQ) int {
|
||||
questionNorm := normalizeQuestion(faq.Question)
|
||||
if queryNorm == questionNorm {
|
||||
return 100
|
||||
}
|
||||
best := 0
|
||||
if queryNorm != "" && (strings.Contains(queryNorm, questionNorm) || strings.Contains(questionNorm, queryNorm)) {
|
||||
best = 88
|
||||
}
|
||||
for _, alias := range parseJSONArray(faq.SimilarQuestions) {
|
||||
aliasNorm := normalizeQuestion(alias)
|
||||
if aliasNorm == "" {
|
||||
continue
|
||||
}
|
||||
if queryNorm == aliasNorm {
|
||||
return 96
|
||||
}
|
||||
if strings.Contains(queryNorm, aliasNorm) || strings.Contains(aliasNorm, queryNorm) {
|
||||
if best < 84 {
|
||||
best = 84
|
||||
}
|
||||
}
|
||||
}
|
||||
questionTerms := buildSearchTerms(faq.Question + " " + strings.Join(parseJSONArray(faq.SimilarQuestions), " "))
|
||||
overlap := countTermOverlap(queryTerms, questionTerms)
|
||||
if overlap > 0 {
|
||||
score := 50 + overlap*8
|
||||
if score > best {
|
||||
best = score
|
||||
}
|
||||
}
|
||||
keywordHits := countTermOverlap(queryTerms, splitCSV(faq.Keywords))
|
||||
if keywordHits > 0 {
|
||||
score := 58 + keywordHits*10
|
||||
if score > best {
|
||||
best = score
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func normalizeQuestion(input string) string {
|
||||
input = strings.ToLower(strings.TrimSpace(input))
|
||||
var b strings.Builder
|
||||
for _, ch := range input {
|
||||
switch ch {
|
||||
case ' ', '\t', '\n', '\r', ',', ',', '。', '.', '?', '?', '!', '!', ':', ':', ';', ';', '、', '-', '_':
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func countTermOverlap(left, right []string) int {
|
||||
if len(left) == 0 || len(right) == 0 {
|
||||
return 0
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range right {
|
||||
seen[strings.ToLower(strings.TrimSpace(item))] = true
|
||||
}
|
||||
total := 0
|
||||
for _, item := range left {
|
||||
if seen[strings.ToLower(strings.TrimSpace(item))] {
|
||||
total++
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func retrieveKnowledgeCitations(query, spaceKey string, topK int) []knowledgeCitation {
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
if ai.KnowledgeServiceEnabled(Cfg) {
|
||||
if items, err := ai.KnowledgeSearch(Cfg, query, spaceKey, topK); err == nil {
|
||||
out := make([]knowledgeCitation, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, knowledgeCitation{
|
||||
ID: item.ID,
|
||||
Title: item.Title,
|
||||
Snippet: item.Snippet,
|
||||
Content: item.Content,
|
||||
ChunkIndex: item.ChunkIndex,
|
||||
SourceType: item.SourceType,
|
||||
SourceID: item.SourceID,
|
||||
KnowledgeSpaceKey: item.KnowledgeSpaceKey,
|
||||
KnowledgeSpaceName: item.KnowledgeSpaceName,
|
||||
Score: item.Score,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
candidates := loadKnowledgeCandidates(spaceKey)
|
||||
if len(candidates) == 0 {
|
||||
return []knowledgeCitation{}
|
||||
}
|
||||
if route, err := config.GetRoute("embed_gen"); err == nil {
|
||||
if items, ok := vectorRetrieveCitations(route, query, candidates, topK); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return keywordRetrieveCitations(query, candidates, topK)
|
||||
}
|
||||
|
||||
type knowledgeCandidate struct {
|
||||
Chunk model.KnowledgeChunk
|
||||
Title string
|
||||
Space string
|
||||
}
|
||||
|
||||
type retrievalScore struct {
|
||||
Index int
|
||||
Score float64
|
||||
}
|
||||
|
||||
func loadKnowledgeCandidates(spaceKey string) []knowledgeCandidate {
|
||||
var chunks []model.KnowledgeChunk
|
||||
store.DB.Order("id ASC").Find(&chunks)
|
||||
mediaMap := map[uint]model.MediaFile{}
|
||||
sourceMap := map[uint]model.KnowledgeSource{}
|
||||
out := make([]knowledgeCandidate, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !matchSpaceForKnowledgeQuery(spaceKey, resolvedSpace) {
|
||||
continue
|
||||
}
|
||||
out = append(out, knowledgeCandidate{Chunk: chunk, Title: title, Space: resolvedSpace})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func matchSpaceForKnowledgeQuery(selected, current string) bool {
|
||||
selected = sanitizeSpaceKey(selected)
|
||||
current = sanitizeSpaceKey(current)
|
||||
if selected == "" || selected == "general" || selected == "all" {
|
||||
return true
|
||||
}
|
||||
return current == selected || current == "general"
|
||||
}
|
||||
|
||||
func vectorRetrieveCitations(route *config.RouteConfig, query string, candidates []knowledgeCandidate, topK int) ([]knowledgeCitation, bool) {
|
||||
client := ai.NewClient(route)
|
||||
inputs := make([]string, 0, len(candidates)+1)
|
||||
inputs = append(inputs, query)
|
||||
for _, item := range candidates {
|
||||
inputs = append(inputs, item.Chunk.Content)
|
||||
}
|
||||
vecs, err := client.Embed(inputs)
|
||||
if err != nil || len(vecs) != len(inputs) {
|
||||
return nil, false
|
||||
}
|
||||
qv := vecs[0]
|
||||
items := make([]retrievalScore, 0, len(candidates))
|
||||
for idx := 1; idx < len(vecs); idx++ {
|
||||
score := cosineSimilarity(qv, vecs[idx])
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, retrievalScore{Index: idx - 1, Score: score})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score })
|
||||
return makeKnowledgeCitations(query, candidates, items, topK), true
|
||||
}
|
||||
|
||||
func keywordRetrieveCitations(query string, candidates []knowledgeCandidate, topK int) []knowledgeCitation {
|
||||
terms := buildSearchTerms(query)
|
||||
items := make([]retrievalScore, 0, len(candidates))
|
||||
for idx, item := range candidates {
|
||||
score := float64(scoreChunkMatch(item.Chunk.Content, item.Title, query, terms)) / 20
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, retrievalScore{Index: idx, Score: score})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Score > items[j].Score })
|
||||
return makeKnowledgeCitations(query, candidates, items, topK)
|
||||
}
|
||||
|
||||
func makeKnowledgeCitations(query string, candidates []knowledgeCandidate, items []retrievalScore, topK int) []knowledgeCitation {
|
||||
seen := map[uint]bool{}
|
||||
out := make([]knowledgeCitation, 0, topK)
|
||||
for _, item := range items {
|
||||
candidate := candidates[item.Index]
|
||||
if seen[candidate.Chunk.ID] {
|
||||
continue
|
||||
}
|
||||
seen[candidate.Chunk.ID] = true
|
||||
out = append(out, knowledgeCitation{
|
||||
ID: candidate.Chunk.ID,
|
||||
Title: candidate.Title,
|
||||
Snippet: buildChunkSnippet(candidate.Chunk.Content, query),
|
||||
Content: candidate.Chunk.Content,
|
||||
ChunkIndex: candidate.Chunk.ChunkIndex,
|
||||
SourceType: candidate.Chunk.SourceType,
|
||||
SourceID: candidate.Chunk.SourceID,
|
||||
KnowledgeSpaceKey: candidate.Space,
|
||||
KnowledgeSpaceName: getKnowledgeSpaceDisplayName(candidate.Space),
|
||||
Score: item.Score,
|
||||
})
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func canDirectAnswerFromVector(query string, citations []knowledgeCitation) bool {
|
||||
if len(citations) == 0 {
|
||||
return false
|
||||
}
|
||||
if citations[0].Score < 0.35 {
|
||||
return false
|
||||
}
|
||||
return !needsLLMFallback(query, citations)
|
||||
}
|
||||
|
||||
func needsLLMFallback(query string, citations []knowledgeCitation) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(query))
|
||||
keywords := []string{"总结", "梳理", "分析", "对比", "归纳", "起草", "生成", "提纲", "方案", "报告", "合同", "条款", "审阅", "写一份", "整理"}
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(lower, keyword) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return len(citations) > 1 && len([]rune(query)) > 20
|
||||
}
|
||||
|
||||
func buildVectorDirectAnswer(citations []knowledgeCitation) string {
|
||||
if len(citations) == 0 {
|
||||
return "未找到相关资料。"
|
||||
}
|
||||
lines := []string{"根据知识库命中的资料,先给你直接结果:"}
|
||||
for idx, citation := range citations {
|
||||
if idx >= 3 {
|
||||
break
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", idx+1, strings.TrimSpace(citation.Snippet)))
|
||||
}
|
||||
lines = append(lines, "如需继续做归纳、对比或正式输出,可以继续追问。")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func buildKnowledgePipelinePrompt(ctx map[string]any, intent string, citations []knowledgeCitation) string {
|
||||
ctxJSON, _ := json.Marshal(ctx)
|
||||
var kb strings.Builder
|
||||
for idx, citation := range citations {
|
||||
if idx >= 5 {
|
||||
break
|
||||
}
|
||||
kb.WriteString(fmt.Sprintf("[%d] 标题:%s\n空间:%s\n内容:%s\n\n", idx+1, citation.Title, citation.KnowledgeSpaceName, citation.Content))
|
||||
}
|
||||
if kb.Len() == 0 {
|
||||
kb.WriteString("未找到有效知识片段。")
|
||||
}
|
||||
return fmt.Sprintf(`你是博昇 AI 实验室知识库问答助手。
|
||||
|
||||
当前链路位置:LLM 兜底层
|
||||
前置结果:
|
||||
1. Bert 分类器已完成意图识别:%s
|
||||
2. FAQ 库未命中标准答案
|
||||
3. 向量检索已尝试召回参考片段
|
||||
|
||||
回答要求:
|
||||
1. 只能基于下方知识片段作答
|
||||
2. 如果知识片段为空或无法支持回答,必须明确回答「未找到相关资料」
|
||||
3. 不得闲聊,不得扩展到越域内容,不得编造
|
||||
4. 优先给出结论,再给出依据
|
||||
|
||||
当前页面上下文:
|
||||
%s
|
||||
|
||||
知识片段:
|
||||
%s`, intent, string(ctxJSON), kb.String())
|
||||
}
|
||||
|
||||
func cosineSimilarity(a, b []float64) float64 {
|
||||
if len(a) == 0 || len(a) != len(b) {
|
||||
return 0
|
||||
}
|
||||
var dot, na, nb float64
|
||||
for i := range a {
|
||||
dot += a[i] * b[i]
|
||||
na += a[i] * a[i]
|
||||
nb += b[i] * b[i]
|
||||
}
|
||||
if na == 0 || nb == 0 {
|
||||
return 0
|
||||
}
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
type spaceMetrics struct {
|
||||
Documents int `json:"document_count"`
|
||||
Chunks int `json:"chunk_count"`
|
||||
}
|
||||
|
||||
var defaultKnowledgeSpaces = []model.KnowledgeSpace{
|
||||
{Key: "general", Name: "通用知识库", Description: "面向企业通用制度、规则、案例与产品总览。", Scope: "general", Status: "active", SortOrder: 10, IsDefault: true},
|
||||
{Key: "product", Name: "产品知识库", Description: "聚合产品资料、FAQ、对比说明与销售话术。", Scope: "business", Status: "active", SortOrder: 20, IsDefault: true},
|
||||
{Key: "training", Name: "培训资料库", Description: "聚合培训课程、训练营资料与学习内容。", Scope: "training", Status: "active", SortOrder: 30, IsDefault: true},
|
||||
{Key: "policy", Name: "规则制度库", Description: "聚合制度文件、审批规则与合规要求。", Scope: "governance", Status: "active", SortOrder: 40, IsDefault: true},
|
||||
}
|
||||
|
||||
// ListKnowledgeSpaces GET /api/knowledge/spaces
|
||||
func ListKnowledgeSpaces(c *gin.Context) {
|
||||
spaces, metrics, err := listKnowledgeSpacesWithMetrics()
|
||||
if err != nil {
|
||||
web.Fail(c, web.NewBadRequest("加载知识空间失败"))
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(spaces))
|
||||
for _, space := range spaces {
|
||||
m := metrics[space.Key]
|
||||
items = append(items, gin.H{
|
||||
"id": space.ID,
|
||||
"key": space.Key,
|
||||
"name": space.Name,
|
||||
"description": space.Description,
|
||||
"scope": space.Scope,
|
||||
"status": space.Status,
|
||||
"sort_order": space.SortOrder,
|
||||
"is_default": space.IsDefault,
|
||||
"document_count": m.Documents,
|
||||
"chunk_count": m.Chunks,
|
||||
})
|
||||
}
|
||||
web.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
// CreateKnowledgeSpace POST /api/knowledge/spaces
|
||||
func CreateKnowledgeSpace(c *gin.Context) {
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Scope string `json:"scope"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
req.Key = sanitizeSpaceKey(req.Key)
|
||||
if req.Key == "" || strings.TrimSpace(req.Name) == "" {
|
||||
web.Fail(c, web.NewBadRequest("key 与 name 必填"))
|
||||
return
|
||||
}
|
||||
space := model.KnowledgeSpace{
|
||||
Key: req.Key,
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
Scope: orDefault(strings.TrimSpace(req.Scope), "general"),
|
||||
Status: orDefault(strings.TrimSpace(req.Status), "active"),
|
||||
SortOrder: req.SortOrder,
|
||||
}
|
||||
if err := store.DB.Create(&space).Error; err != nil {
|
||||
web.Fail(c, web.NewConflictError("知识空间 key 已存在"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"item": space})
|
||||
}
|
||||
|
||||
// UpdateKnowledgeSpace PUT /api/knowledge/spaces/:id
|
||||
func UpdateKnowledgeSpace(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var space model.KnowledgeSpace
|
||||
if err := store.DB.First(&space, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("知识空间不存在"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Scope string `json:"scope"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("请求参数错误"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) != "" {
|
||||
space.Name = strings.TrimSpace(req.Name)
|
||||
}
|
||||
space.Description = strings.TrimSpace(req.Description)
|
||||
if strings.TrimSpace(req.Scope) != "" {
|
||||
space.Scope = strings.TrimSpace(req.Scope)
|
||||
}
|
||||
if strings.TrimSpace(req.Status) != "" {
|
||||
space.Status = strings.TrimSpace(req.Status)
|
||||
}
|
||||
space.SortOrder = req.SortOrder
|
||||
if err := store.DB.Save(&space).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("更新知识空间失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"item": space})
|
||||
}
|
||||
|
||||
// DeleteKnowledgeSpace DELETE /api/knowledge/spaces/:id
|
||||
func DeleteKnowledgeSpace(c *gin.Context) {
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var space model.KnowledgeSpace
|
||||
if err := store.DB.First(&space, id).Error; err != nil {
|
||||
web.Fail(c, web.NewNotFoundError("知识空间不存在"))
|
||||
return
|
||||
}
|
||||
if space.IsDefault {
|
||||
web.Fail(c, web.NewConflictError("默认知识空间不可删除"))
|
||||
return
|
||||
}
|
||||
if err := store.DB.Delete(&space).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("删除知识空间失败"))
|
||||
return
|
||||
}
|
||||
web.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// SearchKnowledge GET /api/knowledge/search?query=&space_key=&limit=
|
||||
func SearchKnowledge(c *gin.Context) {
|
||||
query := strings.TrimSpace(c.Query("query"))
|
||||
spaceKey := strings.TrimSpace(c.Query("space_key"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "6"))
|
||||
if limit < 1 || limit > 20 {
|
||||
limit = 6
|
||||
}
|
||||
|
||||
if _, err := buildKnowledgeSpaceMetrics(); err != nil {
|
||||
web.Fail(c, web.NewBadRequest("加载知识引用失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var chunks []model.KnowledgeChunk
|
||||
chunkQuery := store.DB.Order("created_at DESC")
|
||||
if spaceKey != "" {
|
||||
// 优先按显式空间过滤;同时保留空值记录作为兼容兜底,避免历史数据完全不可见。
|
||||
chunkQuery = chunkQuery.Where("knowledge_space_key = ? OR knowledge_space_key = '' OR knowledge_space_key IS NULL", spaceKey)
|
||||
}
|
||||
chunkQuery.Limit(400).Find(&chunks)
|
||||
|
||||
mediaMap := map[uint]model.MediaFile{}
|
||||
sourceMap := map[uint]model.KnowledgeSource{}
|
||||
for _, chunk := range chunks {
|
||||
if chunk.MediaFileID != nil {
|
||||
if _, ok := mediaMap[*chunk.MediaFileID]; !ok {
|
||||
var media model.MediaFile
|
||||
if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil {
|
||||
mediaMap[*chunk.MediaFileID] = media
|
||||
}
|
||||
}
|
||||
}
|
||||
if chunk.KnowledgeSourceID != nil {
|
||||
if _, ok := sourceMap[*chunk.KnowledgeSourceID]; !ok {
|
||||
var source model.KnowledgeSource
|
||||
if err := store.DB.First(&source, *chunk.KnowledgeSourceID).Error; err == nil {
|
||||
sourceMap[*chunk.KnowledgeSourceID] = source
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type searchHit struct {
|
||||
Chunk model.KnowledgeChunk
|
||||
Score int
|
||||
Title string
|
||||
Space string
|
||||
}
|
||||
hits := make([]searchHit, 0, limit)
|
||||
terms := buildSearchTerms(query)
|
||||
for _, chunk := range chunks {
|
||||
title, resolvedSpace, ok := resolveChunkMeta(chunk, mediaMap, sourceMap)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if spaceKey != "" && resolvedSpace != spaceKey {
|
||||
continue
|
||||
}
|
||||
score := scoreChunkMatch(chunk.Content, title, query, terms)
|
||||
if query != "" && score == 0 {
|
||||
continue
|
||||
}
|
||||
if query == "" {
|
||||
score = int(chunk.ID)
|
||||
}
|
||||
hits = append(hits, searchHit{
|
||||
Chunk: chunk,
|
||||
Score: score,
|
||||
Title: title,
|
||||
Space: resolvedSpace,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(hits, func(i, j int) bool {
|
||||
if hits[i].Score == hits[j].Score {
|
||||
return hits[i].Chunk.CreatedAt.After(hits[j].Chunk.CreatedAt)
|
||||
}
|
||||
return hits[i].Score > hits[j].Score
|
||||
})
|
||||
if len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(hits))
|
||||
for _, hit := range hits {
|
||||
spaceName := getKnowledgeSpaceDisplayName(hit.Space)
|
||||
items = append(items, gin.H{
|
||||
"id": hit.Chunk.ID,
|
||||
"title": hit.Title,
|
||||
"snippet": buildChunkSnippet(hit.Chunk.Content, query),
|
||||
"content": hit.Chunk.Content,
|
||||
"chunk_index": hit.Chunk.ChunkIndex,
|
||||
"source_type": hit.Chunk.SourceType,
|
||||
"source_id": hit.Chunk.SourceID,
|
||||
"knowledge_space_key": hit.Space,
|
||||
"knowledge_space_name": spaceName,
|
||||
"created_at": hit.Chunk.CreatedAt,
|
||||
})
|
||||
}
|
||||
web.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func listKnowledgeSpacesWithMetrics() ([]model.KnowledgeSpace, map[string]spaceMetrics, error) {
|
||||
if err := ensureDefaultKnowledgeSpaces(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var spaces []model.KnowledgeSpace
|
||||
if err := store.DB.Order("sort_order ASC, id ASC").Find(&spaces).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
metrics, err := buildKnowledgeSpaceMetrics()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return spaces, metrics, nil
|
||||
}
|
||||
|
||||
func ensureDefaultKnowledgeSpaces() error {
|
||||
for _, item := range defaultKnowledgeSpaces {
|
||||
var existing model.KnowledgeSpace
|
||||
if err := store.DB.Where("key = ?", item.Key).First(&existing).Error; err == nil {
|
||||
continue
|
||||
}
|
||||
if err := store.DB.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildKnowledgeSpaceMetrics() (map[string]spaceMetrics, error) {
|
||||
metrics := map[string]spaceMetrics{}
|
||||
for _, item := range defaultKnowledgeSpaces {
|
||||
metrics[item.Key] = spaceMetrics{}
|
||||
}
|
||||
|
||||
var mediaFiles []model.MediaFile
|
||||
if err := store.DB.Where("status = ?", "approved").Find(&mediaFiles).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, media := range mediaFiles {
|
||||
key := resolveMediaKnowledgeSpaceKey(media)
|
||||
m := metrics[key]
|
||||
m.Documents++
|
||||
metrics[key] = m
|
||||
// 通用空间汇总所有已通过文档
|
||||
if key != "general" {
|
||||
g := metrics["general"]
|
||||
g.Documents++
|
||||
metrics["general"] = g
|
||||
}
|
||||
}
|
||||
|
||||
var sources []model.KnowledgeSource
|
||||
if err := store.DB.Where("audit_status = ?", "approved").Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, source := range sources {
|
||||
key := resolveKnowledgeSourceSpaceKey(source)
|
||||
m := metrics[key]
|
||||
m.Documents++
|
||||
metrics[key] = m
|
||||
if key != "general" {
|
||||
g := metrics["general"]
|
||||
g.Documents++
|
||||
metrics["general"] = g
|
||||
}
|
||||
}
|
||||
|
||||
var chunks []model.KnowledgeChunk
|
||||
if err := store.DB.Find(&chunks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, chunk := range chunks {
|
||||
key := ensureKnowledgeSpaceKeyOrDefault(chunk.KnowledgeSpaceKey)
|
||||
if chunk.KnowledgeSourceID != nil {
|
||||
var src model.KnowledgeSource
|
||||
if err := store.DB.First(&src, *chunk.KnowledgeSourceID).Error; err == nil {
|
||||
key = resolveKnowledgeSourceSpaceKey(src)
|
||||
}
|
||||
} else if chunk.MediaFileID != nil {
|
||||
var media model.MediaFile
|
||||
if err := store.DB.First(&media, *chunk.MediaFileID).Error; err == nil {
|
||||
key = resolveMediaKnowledgeSpaceKey(media)
|
||||
}
|
||||
} else if key == "general" {
|
||||
key = inferKnowledgeSpaceKey(chunk.Content, "", chunk.SourceType)
|
||||
}
|
||||
m := metrics[key]
|
||||
m.Chunks++
|
||||
metrics[key] = m
|
||||
if key != "general" {
|
||||
g := metrics["general"]
|
||||
g.Chunks++
|
||||
metrics["general"] = g
|
||||
}
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
func getKnowledgeSpaceDisplayName(key string) string {
|
||||
for _, item := range defaultKnowledgeSpaces {
|
||||
if item.Key == key {
|
||||
return item.Name
|
||||
}
|
||||
}
|
||||
var space model.KnowledgeSpace
|
||||
if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil && strings.TrimSpace(space.Name) != "" {
|
||||
return space.Name
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func inferKnowledgeSpaceKey(title, domain, extra string) string {
|
||||
text := strings.ToLower(strings.Join([]string{title, domain, extra}, " "))
|
||||
switch {
|
||||
case domain == "product" || strings.Contains(text, "产品") || strings.Contains(text, "faq") || strings.Contains(text, "product"):
|
||||
return "product"
|
||||
case strings.Contains(text, "培训") || strings.Contains(text, "训练") || strings.Contains(text, "课程") || strings.Contains(text, "sales") || domain == "sales" || domain == "company":
|
||||
return "training"
|
||||
case strings.Contains(text, "规则") || strings.Contains(text, "制度") || strings.Contains(text, "合规") || strings.Contains(text, "policy") || strings.Contains(text, "rule"):
|
||||
return "policy"
|
||||
default:
|
||||
return "general"
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeSpaceKey(key string) string {
|
||||
key = strings.TrimSpace(strings.ToLower(key))
|
||||
key = strings.ReplaceAll(key, " ", "-")
|
||||
key = strings.ReplaceAll(key, "_", "-")
|
||||
var b strings.Builder
|
||||
for _, ch := range key {
|
||||
if (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func ensureKnowledgeSpaceKeyOrDefault(key string) string {
|
||||
key = sanitizeSpaceKey(key)
|
||||
if key == "" {
|
||||
return "general"
|
||||
}
|
||||
if err := ensureDefaultKnowledgeSpaces(); err == nil {
|
||||
for _, item := range defaultKnowledgeSpaces {
|
||||
if item.Key == key {
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
var space model.KnowledgeSpace
|
||||
if err := store.DB.Where("key = ?", key).First(&space).Error; err == nil {
|
||||
return key
|
||||
}
|
||||
return "general"
|
||||
}
|
||||
|
||||
func resolveMediaKnowledgeSpaceKey(media model.MediaFile) string {
|
||||
if key := ensureKnowledgeSpaceKeyOrDefault(media.KnowledgeSpaceKey); key != "general" || sanitizeSpaceKey(media.KnowledgeSpaceKey) != "" {
|
||||
return key
|
||||
}
|
||||
return inferKnowledgeSpaceKey(media.Filename, "", media.FileExt)
|
||||
}
|
||||
|
||||
func resolveKnowledgeSourceSpaceKey(source model.KnowledgeSource) string {
|
||||
if key := ensureKnowledgeSpaceKeyOrDefault(source.KnowledgeSpaceKey); key != "general" || sanitizeSpaceKey(source.KnowledgeSpaceKey) != "" {
|
||||
return key
|
||||
}
|
||||
return inferKnowledgeSpaceKey(source.Title, source.Domain, source.Category+" "+source.FilePath)
|
||||
}
|
||||
|
||||
func buildSearchTerms(query string) []string {
|
||||
query = strings.TrimSpace(strings.ToLower(query))
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.FieldsFunc(query, func(r rune) bool {
|
||||
return r == ' ' || r == ',' || r == ',' || r == '。' || r == ';' || r == ';'
|
||||
})
|
||||
seen := map[string]bool{query: true}
|
||||
terms := []string{query}
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if len([]rune(part)) < 2 || seen[part] {
|
||||
continue
|
||||
}
|
||||
seen[part] = true
|
||||
terms = append(terms, part)
|
||||
}
|
||||
return terms
|
||||
}
|
||||
|
||||
func scoreChunkMatch(content, title, query string, terms []string) int {
|
||||
text := strings.ToLower(title + "\n" + content)
|
||||
score := 0
|
||||
if query != "" && strings.Contains(text, strings.ToLower(query)) {
|
||||
score += 8
|
||||
}
|
||||
for _, term := range terms {
|
||||
if strings.Contains(text, term) {
|
||||
score += 2
|
||||
}
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func resolveChunkMeta(chunk model.KnowledgeChunk, mediaMap map[uint]model.MediaFile, sourceMap map[uint]model.KnowledgeSource) (string, string, bool) {
|
||||
if chunk.MediaFileID != nil {
|
||||
media, ok := mediaMap[*chunk.MediaFileID]
|
||||
if !ok || media.Status != "approved" {
|
||||
return "", "", false
|
||||
}
|
||||
return media.Filename, resolveMediaKnowledgeSpaceKey(media), true
|
||||
}
|
||||
if chunk.KnowledgeSourceID != nil {
|
||||
source, ok := sourceMap[*chunk.KnowledgeSourceID]
|
||||
if !ok || source.AuditStatus != "approved" {
|
||||
return "", "", false
|
||||
}
|
||||
return source.Title, resolveKnowledgeSourceSpaceKey(source), true
|
||||
}
|
||||
title := fmt.Sprintf("%s-%s", chunk.SourceType, chunk.SourceID)
|
||||
if key := sanitizeSpaceKey(chunk.KnowledgeSpaceKey); key != "" {
|
||||
return title, ensureKnowledgeSpaceKeyOrDefault(key), true
|
||||
}
|
||||
return title, inferKnowledgeSpaceKey(title, "", chunk.SourceType), true
|
||||
}
|
||||
|
||||
func buildChunkSnippet(content, query string) string {
|
||||
content = strings.TrimSpace(content)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
if query == "" || len([]rune(content)) <= 120 {
|
||||
return content
|
||||
}
|
||||
lowerContent := strings.ToLower(content)
|
||||
lowerQuery := strings.ToLower(strings.TrimSpace(query))
|
||||
idx := strings.Index(lowerContent, lowerQuery)
|
||||
if idx < 0 {
|
||||
runes := []rune(content)
|
||||
if len(runes) > 120 {
|
||||
return string(runes[:120]) + "..."
|
||||
}
|
||||
return content
|
||||
}
|
||||
runes := []rune(content)
|
||||
start := max(idx-30, 0)
|
||||
end := min(idx+90, len(runes))
|
||||
snippet := string(runes[start:end])
|
||||
if start > 0 {
|
||||
snippet = "..." + snippet
|
||||
}
|
||||
if end < len(runes) {
|
||||
snippet += "..."
|
||||
}
|
||||
return snippet
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -87,6 +87,14 @@ func parseBind(c *gin.Context) (string, *uint) {
|
||||
return bt, bid
|
||||
}
|
||||
|
||||
func parseKnowledgeSpaceKey(c *gin.Context) string {
|
||||
key := sanitizeSpaceKey(c.PostForm("knowledge_space_key"))
|
||||
if key == "" {
|
||||
return "general"
|
||||
}
|
||||
return ensureKnowledgeSpaceKeyOrDefault(key)
|
||||
}
|
||||
|
||||
func sourceOf(c *gin.Context) string {
|
||||
u := middleware.CurrentUser(c)
|
||||
if u != nil && u.Role == "admin" {
|
||||
@@ -122,6 +130,7 @@ func Upload(c *gin.Context) {
|
||||
}
|
||||
|
||||
bindType, bindID := parseBind(c)
|
||||
knowledgeSpaceKey := parseKnowledgeSpaceKey(c)
|
||||
source := sourceOf(c)
|
||||
status := "pending"
|
||||
targetDir := pendingDir()
|
||||
@@ -159,6 +168,7 @@ func Upload(c *gin.Context) {
|
||||
SubmitterID: u.ID,
|
||||
BindType: bindType,
|
||||
BindID: bindID,
|
||||
KnowledgeSpaceKey: knowledgeSpaceKey,
|
||||
}
|
||||
if err := store.DB.Create(&m).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
|
||||
@@ -181,6 +191,7 @@ type uploadSession struct {
|
||||
ChunkSize int64
|
||||
ChunkCount int
|
||||
Chunks map[int]bool
|
||||
KnowledgeSpaceKey string
|
||||
}
|
||||
|
||||
var uploadSessions = struct {
|
||||
@@ -195,6 +206,7 @@ func UploadInit(c *gin.Context) {
|
||||
FileSize int64 `json:"file_size"`
|
||||
BindType string `json:"bind_type"`
|
||||
BindID *uint `json:"bind_id"`
|
||||
KnowledgeSpaceKey string `json:"knowledge_space_key"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Filename == "" || req.FileSize <= 0 {
|
||||
web.Fail(c, web.NewBadRequest("filename/file_size 必填"))
|
||||
@@ -220,6 +232,7 @@ func UploadInit(c *gin.Context) {
|
||||
BindType: req.BindType, BindID: req.BindID,
|
||||
ChunkSize: defaultChunkSize, ChunkCount: chunkCount, Chunks: map[int]bool{},
|
||||
}
|
||||
sess.KnowledgeSpaceKey = ensureKnowledgeSpaceKeyOrDefault(sanitizeSpaceKey(req.KnowledgeSpaceKey))
|
||||
uploadSessions.Lock()
|
||||
uploadSessions.m[id] = sess
|
||||
uploadSessions.Unlock()
|
||||
@@ -346,6 +359,7 @@ func UploadComplete(c *gin.Context) {
|
||||
Filename: sess.Filename, StoredName: storedName, StoredPath: storedName,
|
||||
FileExt: sess.Ext, FileSize: sess.FileSize, Status: status,
|
||||
Source: source, SubmitterID: u.ID, BindType: sess.BindType, BindID: sess.BindID,
|
||||
KnowledgeSpaceKey: sess.KnowledgeSpaceKey,
|
||||
}
|
||||
if err := store.DB.Create(&m).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("创建素材记录失败"))
|
||||
@@ -394,7 +408,7 @@ func MediaStatus(c *gin.Context) {
|
||||
}
|
||||
var chunkCount int64
|
||||
store.DB.Model(&model.KnowledgeChunk{}).Where("media_file_id = ?", m.ID).Count(&chunkCount)
|
||||
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount})
|
||||
web.OK(c, gin.H{"status": m.Status, "extracted": m.Extracted, "chunk_count": chunkCount, "knowledge_space_key": m.KnowledgeSpaceKey})
|
||||
}
|
||||
|
||||
// ============ 审批 ============
|
||||
@@ -405,6 +419,9 @@ func AuditList(c *gin.Context) {
|
||||
if s := c.Query("status"); s != "" {
|
||||
q = q.Where("status = ?", s)
|
||||
}
|
||||
if key := sanitizeSpaceKey(c.Query("knowledge_space_key")); key != "" {
|
||||
q = q.Where("knowledge_space_key = ?", key)
|
||||
}
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
if page < 1 {
|
||||
@@ -534,11 +551,13 @@ func runExtractPipeline(mediaID uint) {
|
||||
MediaFileID: &m.ID,
|
||||
SourceType: m.FileExt,
|
||||
SourceID: strconv.FormatUint(uint64(m.ID), 10),
|
||||
KnowledgeSpaceKey: resolveMediaKnowledgeSpaceKey(m),
|
||||
ChunkIndex: i,
|
||||
Content: chunk,
|
||||
})
|
||||
}
|
||||
store.DB.Model(&m).Update("extracted", true)
|
||||
triggerKnowledgeIndexRebuild()
|
||||
log.Printf("[提取完成] media_id=%d chunks=%d", mediaID, len(chunks))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
@@ -28,6 +30,26 @@ func notifyAllEmployees(ntype, title, content, link string) {
|
||||
}
|
||||
}
|
||||
|
||||
func notifyAdmins(ntype, title, content, link string) {
|
||||
var users []model.User
|
||||
store.DB.Where("role = ? AND status = ?", "admin", "active").Select("id").Find(&users)
|
||||
for _, u := range users {
|
||||
notifyUser(u.ID, ntype, title, content, link)
|
||||
}
|
||||
}
|
||||
|
||||
func notifyUsersByOwner(owner, ntype, title, content, link string) {
|
||||
owner = strings.TrimSpace(owner)
|
||||
if owner == "" || owner == "待指派" {
|
||||
return
|
||||
}
|
||||
var users []model.User
|
||||
store.DB.Where("status = ? AND (full_name = ? OR username = ?)", "active", owner, owner).Select("id").Find(&users)
|
||||
for _, u := range users {
|
||||
notifyUser(u.ID, ntype, title, content, link)
|
||||
}
|
||||
}
|
||||
|
||||
// MyNotifications GET /api/notifications?unread_only=true —— 我的通知列表
|
||||
func MyNotifications(c *gin.Context) {
|
||||
u := middleware.CurrentUser(c)
|
||||
|
||||
@@ -43,6 +43,10 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.GET("/api/specialists", middleware.Auth(cfg), ListSpecialists)
|
||||
r.GET("/api/specialists/by-key/:key", middleware.Auth(cfg), GetSpecialistByKey)
|
||||
r.GET("/api/specialists/summary", middleware.Auth(cfg), SpecialistSummary)
|
||||
r.GET("/api/workbench/overview", middleware.Auth(cfg), WorkbenchOverview)
|
||||
r.GET("/api/worker/tasks", middleware.Auth(cfg), ListWorkerTasks)
|
||||
r.GET("/api/worker/tasks/:id", middleware.Auth(cfg), GetWorkerTaskDetail)
|
||||
r.GET("/api/worker/artifacts/:id", middleware.Auth(cfg), GetWorkerArtifactDetail)
|
||||
r.GET("/api/connectors", middleware.Auth(cfg), ListConnectors)
|
||||
r.GET("/api/connectors/:key", middleware.Auth(cfg), GetConnector)
|
||||
r.POST("/api/connectors/:key/query", middleware.Auth(cfg), QueryConnector)
|
||||
@@ -84,6 +88,8 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
r.GET("/api/media/preview/:mediaId", middleware.Auth(cfg), Preview)
|
||||
r.GET("/api/media/status/:mediaId", middleware.Auth(cfg), MediaStatus)
|
||||
|
||||
r.GET("/api/knowledge/spaces", middleware.Auth(cfg), ListKnowledgeSpaces)
|
||||
r.GET("/api/knowledge/search", middleware.Auth(cfg), SearchKnowledge)
|
||||
r.GET("/api/knowledge/status/:sourceId", middleware.Auth(cfg), KnowledgeStatus)
|
||||
|
||||
// ── AI 对话(普通员工可访问,管理员访问)──
|
||||
@@ -130,6 +136,14 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
admin.POST("/media/audit/:mediaId", AuditMedia)
|
||||
|
||||
admin.POST("/knowledge/scan", KnowledgeScan)
|
||||
admin.POST("/knowledge/index/rebuild", RebuildKnowledgeIndex)
|
||||
admin.POST("/knowledge/spaces", CreateKnowledgeSpace)
|
||||
admin.PUT("/knowledge/spaces/:id", UpdateKnowledgeSpace)
|
||||
admin.DELETE("/knowledge/spaces/:id", DeleteKnowledgeSpace)
|
||||
admin.GET("/knowledge/faqs", ListKnowledgeFAQs)
|
||||
admin.POST("/knowledge/faqs", CreateKnowledgeFAQ)
|
||||
admin.PUT("/knowledge/faqs/:id", UpdateKnowledgeFAQ)
|
||||
admin.DELETE("/knowledge/faqs/:id", DeleteKnowledgeFAQ)
|
||||
admin.GET("/knowledge/audit-list", KnowledgeAuditList)
|
||||
admin.POST("/knowledge/audit/:sourceId", KnowledgeAudit)
|
||||
admin.GET("/knowledge/export", ExportKnowledge)
|
||||
@@ -141,6 +155,12 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
||||
admin.POST("/specialists", CreateSpecialist)
|
||||
admin.PUT("/specialists/:id", UpdateSpecialist)
|
||||
admin.DELETE("/specialists/:id", DeleteSpecialist)
|
||||
admin.POST("/worker/tasks", CreateWorkerTask)
|
||||
admin.PUT("/worker/tasks/:id", UpdateWorkerTask)
|
||||
admin.PUT("/worker/tasks/:id/status", UpdateWorkerTaskStatus)
|
||||
admin.DELETE("/worker/tasks/:id", DeleteWorkerTask)
|
||||
admin.POST("/worker/tasks/:id/actions", ExecuteWorkerTaskAction)
|
||||
admin.PUT("/worker/artifacts/:id/status", UpdateWorkerArtifactStatus)
|
||||
admin.GET("/positions/:id/knowledge", ListPositionKnowledge)
|
||||
admin.PUT("/positions/:id/knowledge", SavePositionKnowledge)
|
||||
admin.GET("/positions/:id/blueprint", ListPositionBlueprint)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"eaisalestrain/backend/internal/middleware"
|
||||
"eaisalestrain/backend/internal/model"
|
||||
"eaisalestrain/backend/internal/store"
|
||||
"eaisalestrain/backend/internal/web"
|
||||
)
|
||||
|
||||
func WorkbenchOverview(c *gin.Context) {
|
||||
user := middleware.CurrentUser(c)
|
||||
if user == nil {
|
||||
web.Fail(c, web.NewAuthError("未登录"))
|
||||
return
|
||||
}
|
||||
|
||||
openStatuses := []string{
|
||||
workerTaskStatusPending,
|
||||
workerTaskStatusInProgress,
|
||||
workerTaskStatusDraft,
|
||||
workerTaskStatusPendingReview,
|
||||
}
|
||||
closedStatuses := []string{
|
||||
workerTaskStatusCompleted,
|
||||
workerTaskStatusArchived,
|
||||
}
|
||||
|
||||
taskQuery := scopedWorkbenchTaskQuery(user)
|
||||
|
||||
var todoCount int64
|
||||
if err := taskQuery.Where("status IN ?", openStatuses).Count(&todoCount).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询工作台待办失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var tasks []model.WorkerTask
|
||||
if err := scopedWorkbenchTaskQuery(user).
|
||||
Where("status IN ?", openStatuses).
|
||||
Order("CASE priority WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END").
|
||||
Order("updated_at DESC, id DESC").
|
||||
Limit(6).
|
||||
Find(&tasks).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询工作台事项失败"))
|
||||
return
|
||||
}
|
||||
|
||||
var riskTasks []model.WorkerTask
|
||||
if err := scopedWorkbenchTaskQuery(user).
|
||||
Where("(status = ? OR priority = ?) AND status NOT IN ?", workerTaskStatusPendingReview, "P1", closedStatuses).
|
||||
Order("updated_at DESC, id DESC").
|
||||
Limit(4).
|
||||
Find(&riskTasks).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询风险事项失败"))
|
||||
return
|
||||
}
|
||||
|
||||
artifactQuery := store.DB.Table("worker_artifact").
|
||||
Select("worker_artifact.id, worker_artifact.title, worker_artifact.status, worker_artifact.artifact_type, worker_artifact.created_at, worker_task.id as task_id, worker_task.title as task_title, worker_task.specialist_key").
|
||||
Joins("left join worker_task on worker_task.id = worker_artifact.task_id")
|
||||
if user.Role != "admin" {
|
||||
artifactQuery = artifactQuery.Where("worker_task.owner IN ?", []string{user.FullName, user.Username})
|
||||
}
|
||||
|
||||
type artifactRow struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ArtifactType string `json:"artifact_type"`
|
||||
TaskID uint `json:"task_id"`
|
||||
TaskTitle string `json:"task_title"`
|
||||
SpecialistKey string `json:"specialist_key"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
var artifactRows []artifactRow
|
||||
if err := artifactQuery.Order("worker_artifact.created_at DESC, worker_artifact.id DESC").Limit(6).Scan(&artifactRows).Error; err != nil {
|
||||
web.Fail(c, web.NewBadRequest("查询最近交付物失败"))
|
||||
return
|
||||
}
|
||||
|
||||
specialistKeys := make([]string, 0, len(tasks)+len(riskTasks)+len(artifactRows))
|
||||
for _, item := range tasks {
|
||||
specialistKeys = append(specialistKeys, item.SpecialistKey)
|
||||
}
|
||||
for _, item := range riskTasks {
|
||||
specialistKeys = append(specialistKeys, item.SpecialistKey)
|
||||
}
|
||||
for _, item := range artifactRows {
|
||||
specialistKeys = append(specialistKeys, item.SpecialistKey)
|
||||
}
|
||||
specialistMap := loadSpecialistMap(specialistKeys)
|
||||
|
||||
todoItems := make([]gin.H, 0, len(tasks))
|
||||
for _, item := range tasks {
|
||||
spec := specialistMap[item.SpecialistKey]
|
||||
todoItems = append(todoItems, gin.H{
|
||||
"id": item.ID,
|
||||
"title": item.Title,
|
||||
"summary": item.Summary,
|
||||
"status": item.Status,
|
||||
"priority": item.Priority,
|
||||
"owner": item.Owner,
|
||||
"specialist_key": item.SpecialistKey,
|
||||
"specialist": spec.Label,
|
||||
"route": spec.Route,
|
||||
})
|
||||
}
|
||||
|
||||
riskItems := make([]gin.H, 0, len(riskTasks))
|
||||
for _, item := range riskTasks {
|
||||
spec := specialistMap[item.SpecialistKey]
|
||||
riskItems = append(riskItems, gin.H{
|
||||
"id": item.ID,
|
||||
"title": item.Title,
|
||||
"sub": buildWorkbenchRiskText(item),
|
||||
"status": item.Status,
|
||||
"route": spec.Route,
|
||||
"specialist": spec.Label,
|
||||
})
|
||||
}
|
||||
|
||||
recentArtifacts := make([]gin.H, 0, len(artifactRows))
|
||||
for _, item := range artifactRows {
|
||||
spec := specialistMap[item.SpecialistKey]
|
||||
recentArtifacts = append(recentArtifacts, gin.H{
|
||||
"id": item.ID,
|
||||
"title": item.Title,
|
||||
"sub": strings.TrimSpace(spec.Label + " / " + item.TaskTitle),
|
||||
"tag": artifactStatusLabel(item.Status),
|
||||
"status": item.Status,
|
||||
"task_id": item.TaskID,
|
||||
"specialist_key": item.SpecialistKey,
|
||||
"route": spec.Route,
|
||||
"artifact_type": item.ArtifactType,
|
||||
"created_at": item.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
web.OK(c, gin.H{
|
||||
"todo_count": todoCount,
|
||||
"todo_items": todoItems,
|
||||
"recent_artifacts": recentArtifacts,
|
||||
"risk_items": riskItems,
|
||||
})
|
||||
}
|
||||
|
||||
func scopedWorkbenchTaskQuery(user *model.User) *gorm.DB {
|
||||
query := store.DB.Model(&model.WorkerTask{})
|
||||
if user != nil && user.Role != "admin" {
|
||||
query = query.Where("owner IN ?", []string{user.FullName, user.Username})
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
func loadSpecialistMap(keys []string) map[string]model.Specialist {
|
||||
filtered := make([]string, 0, len(keys))
|
||||
seen := map[string]struct{}{}
|
||||
for _, key := range keys {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
filtered = append(filtered, key)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return map[string]model.Specialist{}
|
||||
}
|
||||
var items []model.Specialist
|
||||
store.DB.Where("key IN ?", filtered).Find(&items)
|
||||
out := make(map[string]model.Specialist, len(items))
|
||||
for _, item := range items {
|
||||
out[item.Key] = item
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildWorkbenchRiskText(task model.WorkerTask) string {
|
||||
switch task.Status {
|
||||
case workerTaskStatusPendingReview:
|
||||
return "当前事项正在等待确认,建议优先处理。"
|
||||
case workerTaskStatusDraft:
|
||||
return "草案已生成,仍需确认和发布。"
|
||||
default:
|
||||
if task.Priority == "P1" {
|
||||
return "P1 高优事项仍未闭环。"
|
||||
}
|
||||
return "当前事项仍在推进中,需要继续跟进。"
|
||||
}
|
||||
}
|
||||
|
||||
func artifactStatusLabel(status string) string {
|
||||
switch normalizeWorkerArtifactStatus(status) {
|
||||
case workerArtifactStatusDraft:
|
||||
return "草案"
|
||||
case workerArtifactStatusReady:
|
||||
return "待确认"
|
||||
case workerArtifactStatusApproved:
|
||||
return "已确认"
|
||||
case workerArtifactStatusPublished:
|
||||
return "已发布"
|
||||
default:
|
||||
return "已生成"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,9 @@ type Config struct {
|
||||
LLMModel string
|
||||
EmbedModel string
|
||||
|
||||
KnowledgeServiceURL string
|
||||
KnowledgeIndexDir string
|
||||
|
||||
KBDataDir string
|
||||
KnowledgeSourceDir string
|
||||
|
||||
@@ -52,6 +55,9 @@ func Load() *Config {
|
||||
LLMModel: getenv("LLM_MODEL", "qwen2.5:7b"),
|
||||
EmbedModel: getenv("EMBED_MODEL", "bge-m3"),
|
||||
|
||||
KnowledgeServiceURL: getenv("KNOWLEDGE_SERVICE_URL", "http://127.0.0.1:10233"),
|
||||
KnowledgeIndexDir: getenv("KNOWLEDGE_INDEX_DIR", filepath.Join(baseDir, "data", "faiss")),
|
||||
|
||||
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
|
||||
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(baseDir, "knowledge_source")),
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ type KnowledgeChunk struct {
|
||||
KnowledgeSourceID *uint `gorm:"index" json:"knowledge_source_id"` // 与 media_file_id 二选一
|
||||
SourceType string `gorm:"size:32;not null" json:"source_type"` // pdf/doc/md
|
||||
SourceID string `gorm:"size:64" json:"source_id"`
|
||||
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
|
||||
ChunkIndex int `gorm:"not null" json:"chunk_index"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// KnowledgeFAQ FAQ 标准问答表
|
||||
type KnowledgeFAQ struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
|
||||
Question string `gorm:"size:512;not null" json:"question"`
|
||||
Answer string `gorm:"type:text;not null" json:"answer"`
|
||||
SimilarQuestions string `gorm:"type:text" json:"similar_questions"`
|
||||
Keywords string `gorm:"size:512" json:"keywords"`
|
||||
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
HitCount int `gorm:"not null;default:0" json:"hit_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (KnowledgeFAQ) TableName() string { return "knowledge_faq" }
|
||||
@@ -11,6 +11,7 @@ type KnowledgeSource struct {
|
||||
Domain string `gorm:"size:16;not null;default:product" json:"domain"` // company / product / sales
|
||||
SourceVersion string `gorm:"size:32;not null" json:"source_version"`
|
||||
AuditStatus string `gorm:"size:16;not null;default:pending;index" json:"audit_status"` // pending / approved / rejected
|
||||
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
|
||||
AuditBy *uint `json:"audit_by"`
|
||||
AuditAt *time.Time `json:"audit_at"`
|
||||
RejectReason string `gorm:"size:512" json:"reject_reason"`
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// KnowledgeSpace 知识空间定义,供知识库应用与知识库管理共享。
|
||||
type KnowledgeSpace struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Key string `gorm:"size:64;uniqueIndex;not null" json:"key"`
|
||||
Name string `gorm:"size:128;not null" json:"name"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Scope string `gorm:"size:32;not null;default:general" json:"scope"`
|
||||
Status string `gorm:"size:16;not null;default:active;index" json:"status"`
|
||||
SortOrder int `gorm:"not null;default:0" json:"sort_order"`
|
||||
IsDefault bool `gorm:"not null;default:false" json:"is_default"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (KnowledgeSpace) TableName() string { return "knowledge_space" }
|
||||
@@ -19,6 +19,7 @@ type MediaFile struct {
|
||||
RejectReason string `gorm:"size:512" json:"reject_reason"`
|
||||
AuditBy *uint `json:"audit_by"`
|
||||
AuditAt *time.Time `json:"audit_at"`
|
||||
KnowledgeSpaceKey string `gorm:"size:64;index" json:"knowledge_space_key"`
|
||||
Extracted bool `gorm:"not null;default:false;index" json:"extracted"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkerArtifact 专员交付物
|
||||
type WorkerArtifact struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"not null;index" json:"task_id"`
|
||||
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
ArtifactType string `gorm:"size:32;not null;default:text" json:"artifact_type"`
|
||||
Status string `gorm:"size:32;not null;default:draft;index" json:"status"`
|
||||
ContentText string `gorm:"type:text" json:"content_text"`
|
||||
ContentJSON string `gorm:"type:text" json:"content_json"`
|
||||
SourceRefsJSON string `gorm:"type:text" json:"source_refs_json"`
|
||||
CreatedByRunID *uint `gorm:"index" json:"created_by_run_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WorkerArtifact) TableName() string { return "worker_artifact" }
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkerRun 专员动作执行记录
|
||||
type WorkerRun struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
TaskID uint `gorm:"not null;index" json:"task_id"`
|
||||
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
|
||||
ActionKey string `gorm:"size:64;not null;index" json:"action_key"`
|
||||
ActionTitle string `gorm:"size:128;not null" json:"action_title"`
|
||||
ActionType string `gorm:"size:32;default:''" json:"action_type"`
|
||||
Status string `gorm:"size:16;not null;default:done;index" json:"status"`
|
||||
InputJSON string `gorm:"type:text" json:"input_json"`
|
||||
OutputJSON string `gorm:"type:text" json:"output_json"`
|
||||
LogsJSON string `gorm:"type:text" json:"logs_json"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WorkerRun) TableName() string { return "worker_run" }
|
||||
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// WorkerTask 专员事项/任务
|
||||
type WorkerTask struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
SpecialistKey string `gorm:"size:64;not null;index" json:"specialist_key"`
|
||||
Title string `gorm:"size:128;not null" json:"title"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
Owner string `gorm:"size:64;default:''" json:"owner"`
|
||||
Priority string `gorm:"size:16;not null;default:P2" json:"priority"`
|
||||
Status string `gorm:"size:32;not null;default:待处理;index" json:"status"`
|
||||
ContextJSON string `gorm:"type:text" json:"context_json"`
|
||||
CurrentRunID *uint `gorm:"index" json:"current_run_id"`
|
||||
CurrentResult string `gorm:"type:text" json:"current_result"`
|
||||
DueAt *time.Time `json:"due_at"`
|
||||
CreatedBy *uint `gorm:"index" json:"created_by"`
|
||||
LastTriggeredAt *time.Time `json:"last_triggered_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WorkerTask) TableName() string { return "worker_task" }
|
||||
@@ -35,6 +35,8 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
&model.Product{},
|
||||
&model.Course{},
|
||||
&model.MediaFile{},
|
||||
&model.KnowledgeSpace{},
|
||||
&model.KnowledgeFAQ{},
|
||||
&model.KnowledgeChunk{},
|
||||
&model.Question{},
|
||||
&model.ExamPaper{},
|
||||
@@ -53,6 +55,9 @@ func Init(dbPath string) (*gorm.DB, error) {
|
||||
&model.Notification{},
|
||||
&model.StudyNote{},
|
||||
&model.Specialist{},
|
||||
&model.WorkerTask{},
|
||||
&model.WorkerArtifact{},
|
||||
&model.WorkerRun{},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"eaisalestrain/backend/internal/auth"
|
||||
@@ -633,7 +635,40 @@ func ensureMediaLink(src, dst string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(absSrc, dst)
|
||||
if err := os.Symlink(absSrc, dst); err == nil {
|
||||
return nil
|
||||
} else if shouldCopySeedMedia(err) {
|
||||
return copySeedMedia(absSrc, dst)
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func shouldCopySeedMedia(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
// Windows 普通终端默认没有创建符号链接权限,开发环境回退为复制文件即可。
|
||||
return runtime.GOOS == "windows" || strings.Contains(strings.ToLower(err.Error()), "privilege")
|
||||
}
|
||||
|
||||
func copySeedMedia(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func firstExistingPath(paths ...string) string {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
knowledge_source: 规则制度库审批制度样本 V1.0
|
||||
version: V1.0
|
||||
category: policy
|
||||
domain: governance
|
||||
knowledge_space_key: policy
|
||||
audit_status: pending
|
||||
---
|
||||
|
||||
# 规则制度库:事项审批与资料提交流程
|
||||
|
||||
## 结构化产品数据
|
||||
|
||||
> 本文件为制度规则样本,不写入产品表,仅生成知识切片与题目,供 04 知识库应用做制度问答演示。
|
||||
|
||||
## AI 检索知识
|
||||
|
||||
### 审批适用范围
|
||||
涉及合同盖章、付款申请、客户报价确认、对外正式承诺、知识库正式发布的事项,必须先完成系统审批,未经审批不得直接对外执行。
|
||||
|
||||
### 提交流程要求
|
||||
提交审批时,申请人必须填写事项名称、业务背景、影响范围、期望完成时间,并上传支撑材料。缺少业务背景或附件不全的申请,管理员可以直接退回并要求补充。
|
||||
|
||||
### 审批时效要求
|
||||
普通事项应在 1 个工作日内完成审批;涉及合同、报价、付款与对外承诺的高风险事项,应优先处理并在 4 小时内给出审批意见。
|
||||
|
||||
### 审批结论要求
|
||||
审批结论分为通过、退回补充、驳回三种。通过后方可进入正式执行;退回补充后需重新提交;驳回代表当前事项不予继续推进。
|
||||
|
||||
### 知识库发布要求
|
||||
知识库文档、FAQ 与切片内容在正式对员工开放前,必须由管理员完成审批。未审批内容只能停留在待审核状态,不得作为正式知识来源对外回答。
|
||||
|
||||
## 考试题目
|
||||
|
||||
### Q1
|
||||
- type: judge
|
||||
- domain: governance
|
||||
- stem: 涉及合同盖章与付款申请的事项,可以不经过审批直接执行。
|
||||
- answer: [false]
|
||||
- explanation: 合同盖章与付款申请属于必须先审批的高风险事项。
|
||||
|
||||
### Q2
|
||||
- type: single
|
||||
- domain: governance
|
||||
- stem: 审批结论包含以下哪三种?
|
||||
- options: ["通过、退回补充、驳回", "通过、存档、结束", "补录、审批、归档", "通过、修改、删除"]
|
||||
- answer: [0]
|
||||
- explanation: 审批结论标准为通过、退回补充、驳回。
|
||||
|
||||
### Q3
|
||||
- type: judge
|
||||
- domain: governance
|
||||
- stem: 未完成管理员审批的知识库内容,可以直接作为正式知识来源对外回答。
|
||||
- answer: [false]
|
||||
- explanation: 未审批内容不能作为正式知识来源对外回答。
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
knowledge_source: 通用知识库日常协同样本 V1.0
|
||||
version: V1.0
|
||||
category: general
|
||||
domain: collaboration
|
||||
knowledge_space_key: general
|
||||
audit_status: pending
|
||||
---
|
||||
|
||||
# 通用知识库:日常协同与交付规范
|
||||
|
||||
## 结构化产品数据
|
||||
|
||||
> 本文件用于知识库检索演示,不写入产品表。
|
||||
|
||||
## AI 检索知识
|
||||
|
||||
### 日报提交要求
|
||||
员工应在每个工作日 18:00 前提交当日工作日报,至少包含今日完成事项、当前阻塞问题、明日计划三项内容。连续两次未提交日报的,由直属管理员进行提醒。
|
||||
|
||||
### 周会材料准备要求
|
||||
部门周会材料应在会议开始前 2 小时上传到共享空间,内容至少包含上周进展、本周重点、风险事项和需要协同支持的问题。
|
||||
|
||||
### 客户沟通记录要求
|
||||
涉及正式业务推进的客户沟通,应在沟通结束后 30 分钟内补录到系统,记录沟通对象、核心结论、后续动作与负责人。
|
||||
|
||||
### 交付物命名规范
|
||||
正式交付物命名格式统一为“项目简称-交付物类型-版本号-日期”,便于后续检索、审批与归档。
|
||||
|
||||
### 高风险事项升级要求
|
||||
若事项存在客户投诉、合同争议、付款异常或超期未处理情况,负责人需在 1 小时内同步管理员,并在系统中标记为高风险。
|
||||
|
||||
## 考试题目
|
||||
|
||||
### Q1
|
||||
- type: judge
|
||||
- domain: collaboration
|
||||
- stem: 日报至少要包含今日完成事项、当前阻塞问题、明日计划。
|
||||
- answer: [true]
|
||||
- explanation: 这三项是日报提交的基础要求。
|
||||
|
||||
### Q2
|
||||
- type: single
|
||||
- domain: collaboration
|
||||
- stem: 正式交付物命名格式应包含哪些内容?
|
||||
- options: ["项目简称-交付物类型-版本号-日期", "项目简称-日期", "交付物类型-负责人", "客户名称-版本号"]
|
||||
- answer: [0]
|
||||
- explanation: 正式交付物命名格式统一为项目简称-交付物类型-版本号-日期。
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
knowledge_source: 产品知识库产品卖点样本 V1.0
|
||||
version: V1.0
|
||||
category: product
|
||||
domain: sales
|
||||
knowledge_space_key: product
|
||||
audit_status: pending
|
||||
---
|
||||
|
||||
# 产品知识库:核心产品卖点与销售话术
|
||||
|
||||
## 结构化产品数据
|
||||
|
||||
### 产品A
|
||||
- code: DEMO-PRO-01
|
||||
- name: 智能训练营陪跑服务
|
||||
- category: 服务产品
|
||||
- tags: 培训,陪跑,顾问式交付
|
||||
- description: 面向企业客户提供训练营设计、作业点评、过程复盘和陪跑式辅导。
|
||||
- pricing: 标准版 3 万 / 期,高配版 5 万 / 期
|
||||
- commission_recommend: 12%
|
||||
- commission_negotiate: 8%
|
||||
- public_course_bonus: 底价内按回款结算,超额部分归乙方
|
||||
- version_risk: 高配版需要提前锁定顾问排期
|
||||
- report_rules: 成交后需同步客户阶段目标和交付节奏
|
||||
|
||||
## AI 检索知识
|
||||
|
||||
### 智能训练营核心卖点
|
||||
智能训练营强调“课程学习 + 实战作业 + 顾问点评 + 复盘陪跑”的闭环,不只是交付课程,而是帮助客户把学习结果转成真实业务动作。
|
||||
|
||||
### 公开课与训练营区别
|
||||
公开课适合快速普及认知,交付标准化;训练营更适合需要过程陪跑、结果产出的客户,投入更高但业务转化更强。
|
||||
|
||||
### 销售切入建议
|
||||
当客户表达“学了很多但落不了地”“团队不知道怎么推进”时,优先推荐训练营或陪跑型产品,而不是单次公开课。
|
||||
|
||||
### 佣金说明口径
|
||||
标准产品优先执行推荐佣金比例;当客户价格需要谈判时,可在谈判佣金区间内申请调整,并在系统中补充谈判原因。
|
||||
|
||||
### 演示话术示例
|
||||
可以对客户说:我们不是只交付一套课件,而是把课程、作业、复盘和顾问陪跑一起交付,帮助团队真正把方法用起来。
|
||||
|
||||
## 考试题目
|
||||
|
||||
### Q1
|
||||
- type: single
|
||||
- domain: sales
|
||||
- stem: 训练营更适合哪类客户?
|
||||
- options: ["需要过程陪跑和结果产出的客户", "只想快速了解概念的客户", "只关注价格最低的客户", "没有明确目标的客户"]
|
||||
- answer: [0]
|
||||
- explanation: 训练营适合需要过程陪跑、结果产出的客户。
|
||||
|
||||
### Q2
|
||||
- type: judge
|
||||
- domain: sales
|
||||
- stem: 公开课与训练营的交付方式完全相同。
|
||||
- answer: [false]
|
||||
- explanation: 公开课更标准化,训练营强调陪跑和结果产出。
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
knowledge_source: 培训资料库学习运营样本 V1.0
|
||||
version: V1.0
|
||||
category: training
|
||||
domain: learning
|
||||
knowledge_space_key: training
|
||||
audit_status: pending
|
||||
---
|
||||
|
||||
# 培训资料库:学习运营与训练营执行样本
|
||||
|
||||
## 结构化产品数据
|
||||
|
||||
> 本文件用于培训资料检索演示,不写入产品表。
|
||||
|
||||
## AI 检索知识
|
||||
|
||||
### 开营前准备
|
||||
训练营开营前需完成学员名单确认、群内通知、课表发布、预习资料发放和讲师试讲检查。以上事项应在开营前 1 个工作日内全部完成。
|
||||
|
||||
### 作业点评要求
|
||||
训练营作业点评应在学员提交后 24 小时内完成,并明确指出亮点、问题和下一步改进建议,避免只给结论不给方法。
|
||||
|
||||
### 学习完成率跟进
|
||||
若某一期训练营学习完成率低于 80%,运营负责人需在周复盘中说明原因,并提出补课、提醒、督学等改进措施。
|
||||
|
||||
### 优秀案例沉淀要求
|
||||
学员在训练营中形成的高质量案例,应在结营后 3 个工作日内整理成标准案例卡片,沉淀到培训资料库中。
|
||||
|
||||
### 复盘输出要求
|
||||
每期训练营结束后应输出一份复盘报告,至少包含参与情况、完成率、优秀案例、常见问题和下一期优化建议。
|
||||
|
||||
## 考试题目
|
||||
|
||||
### Q1
|
||||
- type: judge
|
||||
- domain: learning
|
||||
- stem: 作业点评只要告知结果即可,不需要给改进建议。
|
||||
- answer: [false]
|
||||
- explanation: 作业点评应指出亮点、问题和下一步改进建议。
|
||||
|
||||
### Q2
|
||||
- type: single
|
||||
- domain: learning
|
||||
- stem: 若学习完成率低于 80%,负责人需要做什么?
|
||||
- options: ["在周复盘中说明原因并提出改进措施", "不处理,等待下一期", "只在群里提醒一次", "直接结束本期训练营"]
|
||||
- answer: [0]
|
||||
- explanation: 完成率偏低时需要复盘原因并提出改进措施。
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
knowledge_source: 规则制度库发布合规样本 V1.0
|
||||
version: V1.0
|
||||
category: policy
|
||||
domain: governance
|
||||
knowledge_space_key: policy
|
||||
audit_status: pending
|
||||
---
|
||||
|
||||
# 规则制度库:知识发布与合规要求
|
||||
|
||||
## 结构化产品数据
|
||||
|
||||
> 本文件用于制度类知识检索演示,不写入产品表。
|
||||
|
||||
## AI 检索知识
|
||||
|
||||
### FAQ 发布要求
|
||||
FAQ 上线前应由管理员确认问法、答案口径、适用范围和所属知识空间。未经确认的 FAQ 不得直接对员工开放。
|
||||
|
||||
### 知识切片质量要求
|
||||
知识切片应保持语义完整,单条切片聚焦一个主题,避免把多个无关规则混在同一条内容中,影响向量召回质量。
|
||||
|
||||
### 合规审阅要求
|
||||
涉及客户承诺、价格政策、合同条款、付款条件等敏感信息时,必须由管理员或授权负责人完成审阅后方可发布。
|
||||
|
||||
### 失效知识处理要求
|
||||
当制度更新、产品下线或话术失效时,管理员需在 1 个工作日内完成 FAQ 下线、知识切片更新和历史版本标记。
|
||||
|
||||
### 展示演示要求
|
||||
用于客户演示的知识库内容,应优先使用已审批、低风险、口径统一的资料,避免展示内部草稿和未确认结论。
|
||||
|
||||
## 考试题目
|
||||
|
||||
### Q1
|
||||
- type: judge
|
||||
- domain: governance
|
||||
- stem: 知识切片可以把多个无关规则混在一条里,方便一次性展示。
|
||||
- answer: [false]
|
||||
- explanation: 知识切片应聚焦一个主题,保持语义完整。
|
||||
|
||||
### Q2
|
||||
- type: single
|
||||
- domain: governance
|
||||
- stem: 演示知识库时应优先使用哪类资料?
|
||||
- options: ["已审批、低风险、口径统一的资料", "内部草稿", "未确认结论", "随手记录内容"]
|
||||
- answer: [0]
|
||||
- explanation: 演示应优先使用已审批且口径统一的资料。
|
||||
@@ -38,8 +38,32 @@ export function getSecretsStatus() {
|
||||
}
|
||||
|
||||
// SSE 流式对话:后端 text/event-stream,每行 "data: {json}\n\n"
|
||||
export async function streamChat(message, context, history, onChunk) {
|
||||
export async function streamChat(message, context, history, handlers) {
|
||||
const token = localStorage.getItem('token')
|
||||
const onChunk = typeof handlers === 'function' ? handlers : handlers?.onChunk
|
||||
const onMeta = typeof handlers === 'function' ? null : handlers?.onMeta
|
||||
const onDone = typeof handlers === 'function' ? null : handlers?.onDone
|
||||
const traceId = `chat-${Date.now()}`
|
||||
// #region debug-point A:frontend-request
|
||||
fetch('http://127.0.0.1:7777/event', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'knowledge-chat-401',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'A',
|
||||
location: 'frontend/src/api/ai.js:streamChat:request',
|
||||
traceId,
|
||||
msg: '[DEBUG] streamChat request',
|
||||
data: {
|
||||
hasToken: Boolean(token),
|
||||
url: '/api/ai-chat/message',
|
||||
messagePreview: String(message || '').slice(0, 80),
|
||||
spaceKey: context?.knowledge_space_key || '',
|
||||
},
|
||||
ts: Date.now(),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
// #endregion
|
||||
const resp = await fetch('/api/ai-chat/message', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -51,12 +75,33 @@ export async function streamChat(message, context, history, onChunk) {
|
||||
|
||||
if (!resp.ok) {
|
||||
let msg = 'LLM 请求失败'
|
||||
let raw = ''
|
||||
try {
|
||||
const j = await resp.json()
|
||||
raw = await resp.text()
|
||||
const j = JSON.parse(raw)
|
||||
msg = j.message || j.error || msg
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// #region debug-point A:frontend-non200
|
||||
fetch('http://127.0.0.1:7777/event', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: 'knowledge-chat-401',
|
||||
runId: 'pre-fix',
|
||||
hypothesisId: 'A',
|
||||
location: 'frontend/src/api/ai.js:streamChat:non200',
|
||||
traceId,
|
||||
msg: '[DEBUG] streamChat non-200 response',
|
||||
data: {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
bodyPreview: String(raw || '').slice(0, 240),
|
||||
},
|
||||
ts: Date.now(),
|
||||
}),
|
||||
}).catch(() => {})
|
||||
// #endregion
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
@@ -84,10 +129,13 @@ export async function streamChat(message, context, history, onChunk) {
|
||||
continue
|
||||
}
|
||||
if (obj.type === 'text') {
|
||||
onChunk(obj.content)
|
||||
onChunk?.(obj.content)
|
||||
} else if (obj.type === 'meta') {
|
||||
onMeta?.(obj)
|
||||
} else if (obj.type === 'error') {
|
||||
throw new Error(obj.message || 'LLM 请求失败')
|
||||
} else if (obj.type === 'done') {
|
||||
onDone?.()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,45 @@
|
||||
import http from './http'
|
||||
|
||||
export function listKnowledgeSpaces() {
|
||||
return http.get('/knowledge/spaces')
|
||||
}
|
||||
|
||||
export function createKnowledgeSpace(data) {
|
||||
return http.post('/knowledge/spaces', data)
|
||||
}
|
||||
|
||||
export function updateKnowledgeSpace(id, data) {
|
||||
return http.put(`/knowledge/spaces/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteKnowledgeSpace(id) {
|
||||
return http.delete(`/knowledge/spaces/${id}`)
|
||||
}
|
||||
|
||||
export function listKnowledgeFaqs(params) {
|
||||
return http.get('/knowledge/faqs', { params })
|
||||
}
|
||||
|
||||
export function createKnowledgeFaq(data) {
|
||||
return http.post('/knowledge/faqs', data)
|
||||
}
|
||||
|
||||
export function updateKnowledgeFaq(id, data) {
|
||||
return http.put(`/knowledge/faqs/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteKnowledgeFaq(id) {
|
||||
return http.delete(`/knowledge/faqs/${id}`)
|
||||
}
|
||||
|
||||
export function rebuildKnowledgeIndex() {
|
||||
return http.post('/knowledge/index/rebuild')
|
||||
}
|
||||
|
||||
export function searchKnowledge(params) {
|
||||
return http.get('/knowledge/search', { params })
|
||||
}
|
||||
|
||||
export function knowledgeScan() {
|
||||
return http.post('/knowledge/scan')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import http from './http'
|
||||
|
||||
export function getWorkbenchOverview() {
|
||||
return http.get('/workbench/overview')
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import http from './http'
|
||||
|
||||
export function listWorkerTasks(params) {
|
||||
return http.get('/worker/tasks', { params })
|
||||
}
|
||||
|
||||
export function getWorkerTaskDetail(id) {
|
||||
return http.get(`/worker/tasks/${id}`)
|
||||
}
|
||||
|
||||
export function updateWorkerTaskStatus(id, data) {
|
||||
return http.put(`/worker/tasks/${id}/status`, data)
|
||||
}
|
||||
|
||||
export function createWorkerTask(data) {
|
||||
return http.post('/worker/tasks', data)
|
||||
}
|
||||
|
||||
export function updateWorkerTask(id, data) {
|
||||
return http.put(`/worker/tasks/${id}`, data)
|
||||
}
|
||||
|
||||
export function deleteWorkerTask(id) {
|
||||
return http.delete(`/worker/tasks/${id}`)
|
||||
}
|
||||
|
||||
export function getWorkerArtifactDetail(id) {
|
||||
return http.get(`/worker/artifacts/${id}`)
|
||||
}
|
||||
|
||||
export function updateWorkerArtifactStatus(id, data) {
|
||||
return http.put(`/worker/artifacts/${id}/status`, data)
|
||||
}
|
||||
|
||||
export function executeWorkerTaskAction(id, data) {
|
||||
return http.post(`/worker/tasks/${id}/actions`, data)
|
||||
}
|
||||
@@ -2,8 +2,9 @@ export const topNavItems = [
|
||||
{ key: 'apps', label: '01 专员工作区', title: '专员工作区', route: '/home' },
|
||||
{ key: 'market', label: '02 专员市场', title: '专员市场', route: '/market', adminOnly: true },
|
||||
{ key: 'studio', label: '03 工坊', title: '工坊', route: '/studio', adminOnly: true },
|
||||
{ key: 'knowledge', label: '04 知识库', title: '知识库', route: '/knowledge-hub' },
|
||||
{ key: 'console', label: '05 控制台', title: '控制台', route: '/console', adminOnly: true },
|
||||
{ key: 'knowledge-app', label: '04 知识库应用', title: '知识库应用', route: '/knowledge-hub' },
|
||||
{ key: 'knowledge-admin', label: '05 知识库管理', title: '知识库管理', route: '/knowledge-management', adminOnly: true },
|
||||
{ key: 'console', label: '06 控制台', title: '控制台', route: '/console', adminOnly: true },
|
||||
]
|
||||
|
||||
export const businessApps = [
|
||||
@@ -136,7 +137,8 @@ export const businessApps = [
|
||||
]
|
||||
|
||||
export const knowledgeLinks = [
|
||||
{ label: '资料资产', route: '/knowledge-hub', caption: 'Assets / Evidence / Rules' },
|
||||
{ label: '知识库应用', route: '/knowledge-hub', caption: 'Chat / Search / Upload' },
|
||||
{ label: '知识库管理', route: '/knowledge-management', caption: 'Spaces / Docs / Chunks', adminOnly: true },
|
||||
{ label: '产品知识', route: '/products', caption: '现有知识内容' },
|
||||
{ label: '销售培训', route: '/courses', caption: '课程与训练' },
|
||||
{ label: '公司介绍', route: '/company-train', caption: '组织基础资料' },
|
||||
@@ -159,12 +161,18 @@ export const secondaryNavItems = {
|
||||
{ label: 'DW 模板', route: '/studio' },
|
||||
{ label: '发布记录', route: '/studio' },
|
||||
],
|
||||
knowledge: [
|
||||
{ label: '资料资产', route: '/knowledge-hub' },
|
||||
'knowledge-app': [
|
||||
{ label: '对话工作台', route: '/knowledge-hub' },
|
||||
{ label: '产品知识', route: '/products' },
|
||||
{ label: '销售培训', route: '/courses' },
|
||||
{ label: '公司介绍', route: '/company-train' },
|
||||
],
|
||||
'knowledge-admin': [
|
||||
{ label: '知识空间', route: '/knowledge-management' },
|
||||
{ label: '文档管理', route: '/knowledge-management' },
|
||||
{ label: '审批发布', route: '/knowledge-management' },
|
||||
{ label: '质量运营', route: '/knowledge-management' },
|
||||
],
|
||||
console: [
|
||||
{ label: '连接器健康', route: '/console' },
|
||||
{ label: '资源用量', route: '/console' },
|
||||
@@ -598,18 +606,33 @@ const appWorkspaceData = {
|
||||
|
||||
const genericSurfaceContext = {
|
||||
'/knowledge-hub': {
|
||||
outputs: ['对象字典更新建议', '证据片段索引', '规则库摘要'],
|
||||
artifacts: ['知识台摘要.md', '对象映射表.xlsx'],
|
||||
evidence: ['资料资产 128', '证据片段 349', '规则条目 26'],
|
||||
risks: ['两个对象映射待确认', '规则库缺少版本号'],
|
||||
checkpoints: ['知识管理员复核'],
|
||||
relatedObjects: ['Object: Contract', 'PolicyRule: 合规红线', 'Scenario: 销售培训'],
|
||||
events: ['对象字典已同步', '新增 Evidence 12 条'],
|
||||
outputs: ['知识问答结果', '上传文档摘要', '会话沉淀结果'],
|
||||
artifacts: ['chat-session-brief.md'],
|
||||
evidence: ['产品知识库', '培训资料库', '规则制度库'],
|
||||
risks: ['当前引用明细仍以会话上下文展示为主'],
|
||||
checkpoints: ['确认知识空间范围'],
|
||||
relatedObjects: ['KnowledgeSpace', 'KnowledgeDocument', 'KnowledgeSession'],
|
||||
events: ['进入知识库应用工作台', '已加载默认知识空间'],
|
||||
bottomPanel: {
|
||||
timeline: ['09:00 知识台加载完成', '09:15 规则同步', '09:45 对象映射更新'],
|
||||
replay: ['回放节点 1: LoadAssets', '回放节点 2: RebuildEvidence', '回放节点 3: UpdateObjects'],
|
||||
trace: ['connector.dms.loadAssets()', 'plugin.knowledge.extractEvidence()', 'plugin.object.syncDictionary()'],
|
||||
logs: ['[INFO] assets loaded', '[INFO] evidence indexed', '[WARN] object mapping pending'],
|
||||
timeline: ['09:00 知识工作台加载完成', '09:15 初始化默认会话'],
|
||||
replay: ['回放节点 1: OpenWorkspace', '回放节点 2: PrepareContext'],
|
||||
trace: ['knowledge.chat.open()', 'knowledge.chat.prepareContext()'],
|
||||
logs: ['[INFO] knowledge application ready'],
|
||||
},
|
||||
},
|
||||
'/knowledge-management': {
|
||||
outputs: ['知识空间概览', '文档治理面板', '审批发布队列'],
|
||||
artifacts: ['knowledge-ops-overview.md'],
|
||||
evidence: ['资料文档', '知识源记录', '切片统计'],
|
||||
risks: ['部分知识空间仍为逻辑划分,待后续独立落库'],
|
||||
checkpoints: ['管理员确认上传与审批策略'],
|
||||
relatedObjects: ['KnowledgeSpace', 'KnowledgeDocument', 'KnowledgeChunk'],
|
||||
events: ['进入知识库管理台', '已加载资料与审批统计'],
|
||||
bottomPanel: {
|
||||
timeline: ['09:10 加载文档列表', '09:30 拉取审批队列'],
|
||||
replay: ['回放节点 1: LoadDocuments', '回放节点 2: LoadAuditQueue'],
|
||||
trace: ['knowledge.admin.listDocuments()', 'knowledge.admin.listSources()'],
|
||||
logs: ['[INFO] knowledge management ready'],
|
||||
},
|
||||
},
|
||||
'/market': {
|
||||
@@ -661,11 +684,12 @@ const genericSurfaceContext = {
|
||||
|
||||
export function getWorkbenchSurface(path) {
|
||||
if (path === '/knowledge-hub' || path.startsWith('/products') || path.startsWith('/courses') || path.startsWith('/company-train')) {
|
||||
return 'knowledge'
|
||||
return 'knowledge-app'
|
||||
}
|
||||
if (path === '/knowledge-management' || path.startsWith('/knowledge/')) return 'knowledge-admin'
|
||||
if (path === '/market') return 'market'
|
||||
if (path === '/studio') return 'studio'
|
||||
if (path === '/console' || path.startsWith('/system') || path.startsWith('/knowledge/')) return 'console'
|
||||
if (path === '/console' || path.startsWith('/system')) return 'console'
|
||||
return 'apps'
|
||||
}
|
||||
|
||||
@@ -744,6 +768,8 @@ export function getWorkspaceSnapshot(path, caseId = '') {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (path.startsWith('/knowledge/')) return genericSurfaceContext['/knowledge-management']
|
||||
if (path.startsWith('/system') || path === '/console') return genericSurfaceContext['/console']
|
||||
return genericSurfaceContext[path] || genericSurfaceContext['/knowledge-hub']
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<span class="section-caption">固定入口</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="item in knowledgeLinks"
|
||||
v-for="item in visibleKnowledgeLinks"
|
||||
:key="item.route"
|
||||
class="rail-link"
|
||||
:class="{ active: route.path === item.route }"
|
||||
@@ -243,6 +243,7 @@ import { Bell, Close, Search } from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useNotificationStore } from '@/store/notification'
|
||||
import { useWorkbenchStore } from '@/store/workbench'
|
||||
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
|
||||
import { listSpecialists } from '@/api/specialist'
|
||||
import {
|
||||
businessApps,
|
||||
@@ -263,6 +264,7 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const notif = useNotificationStore()
|
||||
const workbench = useWorkbenchStore()
|
||||
const workerRuntime = useWorkerRuntimeStore()
|
||||
|
||||
const bottomTabs = [
|
||||
{ key: 'timeline', label: 'Timeline' },
|
||||
@@ -277,16 +279,32 @@ const avatarText = computed(() => username.value.charAt(0))
|
||||
const unread = computed(() => notif.unread)
|
||||
const activeSurface = computed(() => getWorkbenchSurface(route.path))
|
||||
const appKey = computed(() => route.meta.workspaceKey || '')
|
||||
const currentCases = computed(() => (appKey.value ? getAppCases(appKey.value) : []))
|
||||
const runtimeCases = computed(() => workerRuntime.tasksBySpecialist[appKey.value] || [])
|
||||
const currentCases = computed(() => {
|
||||
if (runtimeCases.value.length) {
|
||||
return runtimeCases.value.map(item => ({
|
||||
id: String(item.id),
|
||||
title: item.title,
|
||||
owner: item.owner || '待指派',
|
||||
priority: item.priority || 'P2',
|
||||
due: formatDueAt(item.due_at),
|
||||
summary: item.summary || '待补充事项摘要',
|
||||
status: item.status || '待处理',
|
||||
}))
|
||||
}
|
||||
return appKey.value ? getAppCases(appKey.value) : []
|
||||
})
|
||||
const selectedCaseId = computed(() => {
|
||||
if (!appKey.value) return ''
|
||||
return workbench.selectedCases[appKey.value] || getDefaultCaseId(appKey.value)
|
||||
return workbench.selectedCases[appKey.value] || currentCases.value[0]?.id || getDefaultCaseId(appKey.value)
|
||||
})
|
||||
const remoteSpecialists = ref([])
|
||||
const workspaceDescriptor = computed(() => getWorkspaceDescriptor(route.path, selectedCaseId.value))
|
||||
const workspaceSnapshot = computed(() => getWorkspaceSnapshot(route.path, selectedCaseId.value))
|
||||
const currentTaskDetail = computed(() => workerRuntime.detailByTask[String(selectedCaseId.value)] || null)
|
||||
const visibleTopNavItems = computed(() => getVisibleTopNavItems(isAdmin.value))
|
||||
const visibleBackendLinks = computed(() => getVisibleBackendLinks(isAdmin.value))
|
||||
const visibleKnowledgeLinks = computed(() => knowledgeLinks.filter((item) => !item.adminOnly || isAdmin.value))
|
||||
const genericApps = computed(() => {
|
||||
const items = remoteSpecialists.value.filter(item => item.tier === 'generic')
|
||||
return items.length ? items : getBusinessAppsByTier('generic')
|
||||
@@ -311,7 +329,7 @@ const currentTitle = computed(() => {
|
||||
})
|
||||
|
||||
const activeBottomItems = computed(() => {
|
||||
const panel = workspaceSnapshot.value?.bottomPanel || {}
|
||||
const panel = currentTaskDetail.value?.bottom_panel || workspaceSnapshot.value?.bottomPanel || {}
|
||||
return panel[activeBottomTab.value] || []
|
||||
})
|
||||
|
||||
@@ -327,6 +345,44 @@ onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
watch(
|
||||
appKey,
|
||||
async (key) => {
|
||||
if (!key) return
|
||||
try {
|
||||
await workerRuntime.loadTasks(key)
|
||||
} catch (error) {
|
||||
console.warn('load worker tasks for shell failed', error)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => currentCases.value.map(item => item.id).join(','),
|
||||
(ids) => {
|
||||
if (!appKey.value || !ids) return
|
||||
const selected = workbench.selectedCases[appKey.value]
|
||||
if (!selected || !currentCases.value.some(item => item.id === selected)) {
|
||||
workbench.setSelectedCase(appKey.value, currentCases.value[0]?.id || getDefaultCaseId(appKey.value))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
selectedCaseId,
|
||||
async (id) => {
|
||||
if (!id || !/^\d+$/.test(String(id))) return
|
||||
try {
|
||||
await workerRuntime.loadTaskDetail(id)
|
||||
} catch (error) {
|
||||
console.warn('load worker task detail for shell failed', error)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
@@ -396,6 +452,17 @@ async function loadSpecialists() {
|
||||
console.warn('load specialists for shell failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDueAt(value) {
|
||||
if (!value) return '待设置'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '待设置'
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -13,6 +13,7 @@ const routes = [
|
||||
children: [
|
||||
{ path: 'home', name: 'Home', component: () => import('@/views/home/HomePage.vue') },
|
||||
{ path: 'knowledge-hub', name: 'KnowledgeHub', component: () => import('@/views/workbench/KnowledgeHub.vue') },
|
||||
{ path: 'knowledge-management', name: 'KnowledgeManagement', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/KnowledgeManagementPage.vue') },
|
||||
{ path: 'market', name: 'Market', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/MarketPage.vue') },
|
||||
{ path: 'studio', name: 'Studio', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/StudioPage.vue') },
|
||||
{ path: 'console', name: 'Console', meta: { requiresAdmin: true }, component: () => import('@/views/workbench/ConsolePage.vue') },
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
createWorkerTask,
|
||||
deleteWorkerTask,
|
||||
executeWorkerTaskAction,
|
||||
getWorkerArtifactDetail,
|
||||
getWorkerTaskDetail,
|
||||
listWorkerTasks,
|
||||
updateWorkerArtifactStatus,
|
||||
updateWorkerTask,
|
||||
updateWorkerTaskStatus,
|
||||
} from '@/api/worker'
|
||||
|
||||
export const useWorkerRuntimeStore = defineStore('workerRuntime', () => {
|
||||
const tasksBySpecialist = ref({})
|
||||
const detailByTask = ref({})
|
||||
const artifactDetailById = ref({})
|
||||
const loadingTasksBySpecialist = ref({})
|
||||
const loadingDetailByTask = ref({})
|
||||
|
||||
async function loadTasks(specialistKey) {
|
||||
if (!specialistKey) return []
|
||||
loadingTasksBySpecialist.value = { ...loadingTasksBySpecialist.value, [specialistKey]: true }
|
||||
try {
|
||||
const res = await listWorkerTasks({ specialist_key: specialistKey })
|
||||
const items = res?.data || []
|
||||
tasksBySpecialist.value = { ...tasksBySpecialist.value, [specialistKey]: items }
|
||||
return items
|
||||
} finally {
|
||||
loadingTasksBySpecialist.value = { ...loadingTasksBySpecialist.value, [specialistKey]: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId) {
|
||||
if (!taskId) return null
|
||||
const key = String(taskId)
|
||||
loadingDetailByTask.value = { ...loadingDetailByTask.value, [key]: true }
|
||||
try {
|
||||
const res = await getWorkerTaskDetail(taskId)
|
||||
const detail = res?.data || null
|
||||
detailByTask.value = { ...detailByTask.value, [key]: detail }
|
||||
return detail
|
||||
} finally {
|
||||
loadingDetailByTask.value = { ...loadingDetailByTask.value, [key]: false }
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAction(taskId, payload) {
|
||||
const res = await executeWorkerTaskAction(taskId, payload)
|
||||
const task = res?.data?.task
|
||||
if (task?.specialist_key) {
|
||||
await loadTasks(task.specialist_key)
|
||||
}
|
||||
await loadTaskDetail(taskId)
|
||||
return res?.data || null
|
||||
}
|
||||
|
||||
async function createTask(payload) {
|
||||
const res = await createWorkerTask(payload)
|
||||
const task = res?.data || null
|
||||
if (task?.specialist_key) {
|
||||
await loadTasks(task.specialist_key)
|
||||
}
|
||||
if (task?.id) {
|
||||
await loadTaskDetail(task.id)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
async function updateTask(taskId, payload) {
|
||||
const res = await updateWorkerTask(taskId, payload)
|
||||
const task = res?.data || null
|
||||
if (task?.specialist_key) {
|
||||
await loadTasks(task.specialist_key)
|
||||
}
|
||||
if (task?.id) {
|
||||
await loadTaskDetail(task.id)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
async function deleteTask(taskId) {
|
||||
const existing = detailByTask.value[String(taskId)]?.task || null
|
||||
const res = await deleteWorkerTask(taskId)
|
||||
const specialistKey = res?.data?.specialist_key || existing?.specialist_key || ''
|
||||
if (specialistKey) {
|
||||
await loadTasks(specialistKey)
|
||||
}
|
||||
const nextDetail = { ...detailByTask.value }
|
||||
delete nextDetail[String(taskId)]
|
||||
detailByTask.value = nextDetail
|
||||
return res?.data || null
|
||||
}
|
||||
|
||||
async function loadArtifactDetail(artifactId) {
|
||||
if (!artifactId) return null
|
||||
const res = await getWorkerArtifactDetail(artifactId)
|
||||
const detail = res?.data || null
|
||||
artifactDetailById.value = {
|
||||
...artifactDetailById.value,
|
||||
[String(artifactId)]: detail,
|
||||
}
|
||||
return detail
|
||||
}
|
||||
|
||||
async function updateArtifactStatus(artifactId, payload) {
|
||||
const res = await updateWorkerArtifactStatus(artifactId, payload)
|
||||
const task = res?.data?.task
|
||||
if (task?.specialist_key) {
|
||||
await loadTasks(task.specialist_key)
|
||||
}
|
||||
if (task?.id) {
|
||||
await loadTaskDetail(task.id)
|
||||
}
|
||||
if (artifactId) {
|
||||
await loadArtifactDetail(artifactId)
|
||||
}
|
||||
return res?.data || null
|
||||
}
|
||||
|
||||
async function updateTaskStatus(taskId, payload) {
|
||||
const res = await updateWorkerTaskStatus(taskId, payload)
|
||||
const task = res?.data || null
|
||||
if (task?.specialist_key) {
|
||||
await loadTasks(task.specialist_key)
|
||||
}
|
||||
if (task?.id) {
|
||||
await loadTaskDetail(task.id)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
const hasTasks = computed(() => Object.values(tasksBySpecialist.value).some(items => Array.isArray(items) && items.length))
|
||||
|
||||
return {
|
||||
tasksBySpecialist,
|
||||
detailByTask,
|
||||
artifactDetailById,
|
||||
loadingTasksBySpecialist,
|
||||
loadingDetailByTask,
|
||||
hasTasks,
|
||||
loadTasks,
|
||||
loadTaskDetail,
|
||||
loadArtifactDetail,
|
||||
executeAction,
|
||||
createTask,
|
||||
updateTask,
|
||||
updateTaskStatus,
|
||||
deleteTask,
|
||||
updateArtifactStatus,
|
||||
}
|
||||
})
|
||||
@@ -53,11 +53,27 @@ function fmtTime(t) {
|
||||
}
|
||||
|
||||
function typeLabel(t) {
|
||||
return { exam_publish: '考试发布', exam_pass: '考试通过', position_set: '岗位设置' }[t] || '通知'
|
||||
return {
|
||||
exam_publish: '考试发布',
|
||||
exam_pass: '考试通过',
|
||||
position_set: '岗位设置',
|
||||
worker_task_assign: '事项指派',
|
||||
worker_artifact_review: '交付待确认',
|
||||
worker_artifact_approved: '交付已确认',
|
||||
worker_artifact_published: '交付已发布',
|
||||
}[t] || '通知'
|
||||
}
|
||||
|
||||
function typeTag(t) {
|
||||
return { exam_publish: 'warning', exam_pass: 'success', position_set: 'primary' }[t] || 'info'
|
||||
return {
|
||||
exam_publish: 'warning',
|
||||
exam_pass: 'success',
|
||||
position_set: 'primary',
|
||||
worker_task_assign: 'primary',
|
||||
worker_artifact_review: 'warning',
|
||||
worker_artifact_approved: 'success',
|
||||
worker_artifact_published: 'success',
|
||||
}[t] || 'info'
|
||||
}
|
||||
|
||||
function openNotice(n) {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</div>
|
||||
<div class="hero-metrics">
|
||||
<div class="metric-card">
|
||||
<strong>11</strong>
|
||||
<strong>{{ overview.todo_count }}</strong>
|
||||
<span>我的待办</span>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
@@ -31,15 +31,15 @@
|
||||
<section class="summary-row">
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">我的待办</span>
|
||||
<strong>合同复核、方案评审、训练营日报、甩柜异常升级</strong>
|
||||
<strong>{{ todoSummaryText }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">最近交付物</span>
|
||||
<strong>redline 包、售前方案 v3、训练营周报、客户状态报告</strong>
|
||||
<strong>{{ artifactSummaryText }}</strong>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="summary-label">高风险事项</span>
|
||||
<strong>赔偿上限未对齐、连接器边界待确认、船司异常未关闭</strong>
|
||||
<strong>{{ riskSummaryText }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -107,34 +107,55 @@
|
||||
</section>
|
||||
|
||||
<section class="content-grid">
|
||||
<div class="grid-card">
|
||||
<div class="section-title">我的待办</div>
|
||||
<button
|
||||
v-for="item in todoItems"
|
||||
:key="item.id"
|
||||
class="home-link-item"
|
||||
@click="openRoute(item.route)"
|
||||
>
|
||||
<div>
|
||||
<div class="list-title">{{ item.title }}</div>
|
||||
<div class="list-sub">{{ item.summary }}</div>
|
||||
</div>
|
||||
<el-tag size="small" effect="plain">{{ item.status }}</el-tag>
|
||||
</button>
|
||||
<el-empty v-if="!todoItems.length" description="当前没有待办事项" :image-size="72" />
|
||||
</div>
|
||||
|
||||
<div class="grid-card">
|
||||
<div class="section-title">最近交付物</div>
|
||||
<div
|
||||
<button
|
||||
v-for="item in recentArtifacts"
|
||||
:key="item.title"
|
||||
class="list-item"
|
||||
:key="item.id || item.title"
|
||||
class="home-link-item"
|
||||
@click="openRoute(item.route)"
|
||||
>
|
||||
<div>
|
||||
<div class="list-title">{{ item.title }}</div>
|
||||
<div class="list-sub">{{ item.sub }}</div>
|
||||
</div>
|
||||
<el-tag size="small" effect="plain">{{ item.tag }}</el-tag>
|
||||
</div>
|
||||
</button>
|
||||
<el-empty v-if="!recentArtifacts.length" description="暂无最新交付物" :image-size="72" />
|
||||
</div>
|
||||
|
||||
<div class="grid-card">
|
||||
<div class="section-title">风险与阻塞</div>
|
||||
<div
|
||||
<button
|
||||
v-for="item in risks"
|
||||
:key="item.title"
|
||||
class="risk-item"
|
||||
:key="item.id || item.title"
|
||||
class="home-link-item risk-item"
|
||||
@click="openRoute(item.route)"
|
||||
>
|
||||
<div class="risk-dot" />
|
||||
<div>
|
||||
<div class="list-title">{{ item.title }}</div>
|
||||
<div class="list-sub">{{ item.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<el-empty v-if="!risks.length" description="暂无高风险事项" :image-size="72" />
|
||||
</div>
|
||||
|
||||
<div v-if="visibleBackendLinks.length" class="grid-card">
|
||||
@@ -159,6 +180,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { getBusinessAppsByTier, getSpecialistStats, getVisibleBackendLinks } from '@/config/workbench'
|
||||
import { getSpecialistSummary, listSpecialists } from '@/api/specialist'
|
||||
import { getWorkbenchOverview } from '@/api/workbench'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
@@ -166,23 +188,23 @@ const visibleBackendLinks = computed(() => getVisibleBackendLinks(auth.isAdmin))
|
||||
const remoteGenericApps = ref([])
|
||||
const remoteIndustryApps = ref([])
|
||||
const remoteStats = ref(null)
|
||||
const remoteOverview = ref({
|
||||
todo_count: 0,
|
||||
todo_items: [],
|
||||
recent_artifacts: [],
|
||||
risk_items: [],
|
||||
})
|
||||
|
||||
const genericApps = computed(() => remoteGenericApps.value.length ? remoteGenericApps.value : getBusinessAppsByTier('generic'))
|
||||
const industryApps = computed(() => remoteIndustryApps.value.length ? remoteIndustryApps.value : getBusinessAppsByTier('industry'))
|
||||
const specialistStats = computed(() => remoteStats.value || getSpecialistStats())
|
||||
|
||||
const recentArtifacts = [
|
||||
{ title: '主协议 redline 包', sub: '合同审查 / 华东客户主协议修订', tag: '法务' },
|
||||
{ title: '售前方案 v3', sub: '售前方案 / A 客户智能培训升级方案', tag: '售前' },
|
||||
{ title: '训练营周报', sub: '培训交付 / 新人销售训练营第 3 周', tag: '培训' },
|
||||
{ title: '客户状态报告', sub: '货代履约 / 美西航线本周履约看板', tag: '履约' },
|
||||
]
|
||||
|
||||
const risks = [
|
||||
{ title: '赔偿上限未对齐', sub: '合同审查事项等待法务经理确认' },
|
||||
{ title: '连接器边界待确认', sub: '售前方案仍缺 OA 与 CRM 的回写范围' },
|
||||
{ title: '船司甩柜异常未关闭', sub: '货代履约已升级运营经理处理' },
|
||||
]
|
||||
const overview = computed(() => remoteOverview.value || { todo_count: 0, todo_items: [], recent_artifacts: [], risk_items: [] })
|
||||
const todoItems = computed(() => overview.value.todo_items || [])
|
||||
const recentArtifacts = computed(() => overview.value.recent_artifacts || [])
|
||||
const risks = computed(() => overview.value.risk_items || [])
|
||||
const todoSummaryText = computed(() => buildSummaryText(todoItems.value.map(item => item.title), '当前没有待办事项'))
|
||||
const artifactSummaryText = computed(() => buildSummaryText(recentArtifacts.value.map(item => item.title), '暂无最新交付物'))
|
||||
const riskSummaryText = computed(() => buildSummaryText(risks.value.map(item => item.title), '暂无高风险事项'))
|
||||
|
||||
function normalizeSpecialist(item) {
|
||||
return {
|
||||
@@ -199,11 +221,22 @@ function normalizeSpecialist(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildSummaryText(items, fallback) {
|
||||
const filtered = items.filter(Boolean)
|
||||
return filtered.length ? filtered.slice(0, 4).join('、') : fallback
|
||||
}
|
||||
|
||||
function openRoute(route) {
|
||||
if (!route) return
|
||||
router.push(route)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [listRes, summaryRes] = await Promise.all([
|
||||
const [listRes, summaryRes, overviewRes] = await Promise.all([
|
||||
listSpecialists(),
|
||||
getSpecialistSummary(),
|
||||
getWorkbenchOverview(),
|
||||
])
|
||||
const items = listRes?.data || []
|
||||
remoteGenericApps.value = items.filter(item => item.tier === 'generic').map(normalizeSpecialist)
|
||||
@@ -211,6 +244,9 @@ onMounted(async () => {
|
||||
if (summaryRes?.data) {
|
||||
remoteStats.value = summaryRes.data
|
||||
}
|
||||
if (overviewRes?.data) {
|
||||
remoteOverview.value = overviewRes.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('load specialists failed', error)
|
||||
}
|
||||
@@ -418,6 +454,24 @@ onMounted(async () => {
|
||||
border-bottom: 1px solid rgba(228, 231, 237, 0.72);
|
||||
}
|
||||
|
||||
.home-link-item {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
border: none;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(228, 231, 237, 0.72);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.home-link-item:hover .list-title {
|
||||
color: #1f4d7e;
|
||||
}
|
||||
|
||||
.list-item:last-child,
|
||||
.backend-item:last-child,
|
||||
.risk-item:last-child {
|
||||
|
||||
@@ -25,6 +25,31 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-tabs">
|
||||
<el-button v-if="isAdmin" type="primary" @click="openCreateDialog">新建事项</el-button>
|
||||
<el-button v-if="isAdmin && selectedTaskId" @click="openEditDialog">编辑事项</el-button>
|
||||
<el-button v-if="isAdmin && selectedTaskId" @click="openAssignDialog">指派负责人</el-button>
|
||||
<el-button v-if="isAdmin && selectedTaskId" type="danger" plain @click="removeTask">删除事项</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedTaskId && currentTaskStatus !== '进行中'"
|
||||
:loading="taskStatusLoading === '进行中'"
|
||||
@click="setTaskLifecycle('进行中')"
|
||||
>
|
||||
开始推进
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedTaskId && currentTaskStatus !== '已完成'"
|
||||
:loading="taskStatusLoading === '已完成'"
|
||||
@click="setTaskLifecycle('已完成')"
|
||||
>
|
||||
标记完成
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedTaskId && currentTaskStatus !== '已归档'"
|
||||
:loading="taskStatusLoading === '已归档'"
|
||||
@click="setTaskLifecycle('已归档')"
|
||||
>
|
||||
归档事项
|
||||
</el-button>
|
||||
<span
|
||||
v-for="tab in workspace.tabs"
|
||||
:key="tab"
|
||||
@@ -136,6 +161,19 @@
|
||||
<span>审批:{{ item.approval }}</span>
|
||||
<span>执行者:{{ item.operator }}</span>
|
||||
</div>
|
||||
<div class="action-footer">
|
||||
<el-button
|
||||
v-if="isAdmin"
|
||||
type="primary"
|
||||
size="small"
|
||||
:loading="executingActionKey === item.actionKey"
|
||||
@click="executeAction(item)"
|
||||
>
|
||||
执行动作
|
||||
</el-button>
|
||||
<span v-else class="action-readonly-tip">当前事项仅管理员可推进</span>
|
||||
<span v-if="item.lastRunSummary" class="action-run-summary">{{ item.lastRunSummary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -158,6 +196,16 @@
|
||||
<span>{{ item.meta }}</span>
|
||||
<span>{{ item.derived }}</span>
|
||||
</div>
|
||||
<div class="action-footer">
|
||||
<el-button
|
||||
v-if="item.artifactId"
|
||||
size="small"
|
||||
@click="openArtifactDetail(item.artifactId)"
|
||||
>
|
||||
查看详情
|
||||
</el-button>
|
||||
<span v-if="item.sourceRefsText" class="action-run-summary">{{ item.sourceRefsText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,8 +216,8 @@
|
||||
<div class="panel-title">任务看板</div>
|
||||
<div class="task-grid">
|
||||
<div
|
||||
v-for="task in workspace.taskCards"
|
||||
:key="task.title"
|
||||
v-for="task in taskBoardItems"
|
||||
:key="task.key || task.title"
|
||||
class="task-card"
|
||||
>
|
||||
<div class="task-head">
|
||||
@@ -228,27 +276,225 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="taskDialogVisible" :title="taskDialogMode === 'create' ? '新建事项' : '编辑事项'" width="520px">
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="taskForm.title" placeholder="例如:A 客户售前方案补充说明" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
<el-input v-model="taskForm.summary" type="textarea" :rows="3" placeholder="补充这件事项要处理什么、交付什么" />
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="taskForm.owner" placeholder="默认使用当前登录人" />
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="taskForm.priority" style="width: 100%">
|
||||
<el-option label="P1" value="P1" />
|
||||
<el-option label="P2" value="P2" />
|
||||
<el-option label="P3" value="P3" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="截止时间">
|
||||
<el-date-picker
|
||||
v-model="taskForm.dueAt"
|
||||
type="datetime"
|
||||
value-format="YYYY-MM-DD HH:mm"
|
||||
style="width: 100%"
|
||||
placeholder="可选"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="taskDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingTask" @click="submitTaskForm">{{ taskDialogMode === 'create' ? '创建' : '保存' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="assignDialogVisible" title="指派负责人" width="420px">
|
||||
<el-form label-width="88px">
|
||||
<el-form-item label="负责人">
|
||||
<el-input v-model="assignOwner" placeholder="请输入负责人姓名" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="assigningOwner" @click="submitAssignOwner">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="artifactDialogVisible" title="交付物详情" width="760px">
|
||||
<div v-if="selectedArtifact" class="artifact-detail">
|
||||
<div class="artifact-detail-head">
|
||||
<div>
|
||||
<div class="artifact-title">{{ selectedArtifact.title }}</div>
|
||||
<div class="artifact-meta-line">
|
||||
<span>状态:{{ artifactStatusText(selectedArtifact.status) }}</span>
|
||||
<span>类型:{{ selectedArtifact.artifact_type || 'text' }}</span>
|
||||
<span>事项:{{ selectedArtifactTask?.title || '当前事项' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="tagTypeMap[artifactTone(selectedArtifact.status)] || 'info'" effect="plain">
|
||||
{{ artifactStatusText(selectedArtifact.status) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="artifact-section">
|
||||
<div class="artifact-section-title">内容</div>
|
||||
<div class="artifact-content">{{ selectedArtifact.content_text || '当前交付物暂无正文内容。' }}</div>
|
||||
</div>
|
||||
<div class="artifact-section">
|
||||
<div class="artifact-section-title">来源信源</div>
|
||||
<div class="pill-list">
|
||||
<span v-for="item in selectedArtifactSourceRefs" :key="item" class="info-pill">{{ item }}</span>
|
||||
<span v-if="!selectedArtifactSourceRefs.length" class="artifact-empty">当前未记录来源信源</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="artifact-section">
|
||||
<div class="artifact-section-title">运行上下文</div>
|
||||
<div class="artifact-meta-line">
|
||||
<span>生成时间:{{ formatRunTime(selectedArtifact.created_at) || '待补充' }}</span>
|
||||
<span>运行动作:{{ selectedArtifactRun?.action_title || '当前动作' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="artifactDialogVisible = false">关闭</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedArtifact?.status === 'draft'"
|
||||
type="primary"
|
||||
:loading="artifactStatusLoading === 'ready'"
|
||||
@click="transitionArtifact('ready')"
|
||||
>
|
||||
提交确认
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedArtifact?.status === 'ready'"
|
||||
:loading="artifactStatusLoading === 'draft'"
|
||||
@click="transitionArtifact('draft')"
|
||||
>
|
||||
退回修改
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedArtifact?.status === 'ready'"
|
||||
type="primary"
|
||||
:loading="artifactStatusLoading === 'approved'"
|
||||
@click="transitionArtifact('approved')"
|
||||
>
|
||||
确认通过
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isAdmin && selectedArtifact?.status === 'approved'"
|
||||
type="success"
|
||||
:loading="artifactStatusLoading === 'published'"
|
||||
@click="transitionArtifact('published')"
|
||||
>
|
||||
发布归档
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/store/auth'
|
||||
import { useNotificationStore } from '@/store/notification'
|
||||
import { useWorkbenchStore } from '@/store/workbench'
|
||||
import { useWorkerRuntimeStore } from '@/store/workerRuntime'
|
||||
import { getSpecialistByKey } from '@/api/specialist'
|
||||
import { getDefaultCaseId, getWorkspaceDescriptor, getWorkspaceSnapshot } from '@/config/workbench'
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
const workbench = useWorkbenchStore()
|
||||
const workerRuntime = useWorkerRuntimeStore()
|
||||
const executingActionKey = ref('')
|
||||
const taskDialogVisible = ref(false)
|
||||
const taskDialogMode = ref('create')
|
||||
const savingTask = ref(false)
|
||||
const assignDialogVisible = ref(false)
|
||||
const assigningOwner = ref(false)
|
||||
const assignOwner = ref('')
|
||||
const taskStatusLoading = ref('')
|
||||
const artifactDialogVisible = ref(false)
|
||||
const selectedArtifactId = ref('')
|
||||
const artifactStatusLoading = ref('')
|
||||
const taskForm = reactive({
|
||||
title: '',
|
||||
summary: '',
|
||||
owner: '',
|
||||
priority: 'P2',
|
||||
dueAt: '',
|
||||
})
|
||||
const isAdmin = computed(() => auth.isAdmin)
|
||||
const selectedTaskId = computed(() => (/^\d+$/.test(String(selectedCaseId.value)) ? String(selectedCaseId.value) : ''))
|
||||
const currentTaskStatus = computed(() => currentTaskDetail.value?.task?.status || '')
|
||||
|
||||
const selectedCaseId = computed(() => {
|
||||
const appKey = route.meta.workspaceKey || ''
|
||||
if (!appKey) return ''
|
||||
return workbench.selectedCases[appKey] || getDefaultCaseId(appKey)
|
||||
return workbench.selectedCases[appKey] || currentTasks.value[0]?.id || getDefaultCaseId(appKey)
|
||||
})
|
||||
const currentTasks = computed(() => {
|
||||
const appKey = route.meta.workspaceKey || ''
|
||||
const items = workerRuntime.tasksBySpecialist[appKey] || []
|
||||
return items.map(mapTaskToCase)
|
||||
})
|
||||
const currentTaskDetail = computed(() => workerRuntime.detailByTask[String(selectedCaseId.value)] || null)
|
||||
const selectedArtifactDetail = computed(() => workerRuntime.artifactDetailById[String(selectedArtifactId.value)] || null)
|
||||
const baseWorkspace = computed(() => getWorkspaceDescriptor(route.path) || null)
|
||||
const baseSnapshot = computed(() => getWorkspaceSnapshot(route.path) || {})
|
||||
const currentTask = computed(() => {
|
||||
if (currentTaskDetail.value?.task) return mapTaskToCase(currentTaskDetail.value.task)
|
||||
return currentTasks.value.find(item => item.id === String(selectedCaseId.value)) || null
|
||||
})
|
||||
const workspace = computed(() => {
|
||||
if (!baseWorkspace.value) return null
|
||||
const currentCase = currentTask.value || baseWorkspace.value.currentCase || null
|
||||
return {
|
||||
...baseWorkspace.value,
|
||||
currentCase,
|
||||
caseTitle: currentCase?.title || baseWorkspace.value.caseTitle,
|
||||
subtitle: currentCase?.summary ? `${baseWorkspace.value.subtitle} 当前事项:${currentCase.summary}` : baseWorkspace.value.subtitle,
|
||||
}
|
||||
})
|
||||
const snapshot = computed(() => {
|
||||
const context = parseJSON(currentTaskDetail.value?.task?.context_json, {})
|
||||
const latestArtifacts = (currentTaskDetail.value?.artifacts || []).slice(0, 3)
|
||||
const latestRuns = currentTaskDetail.value?.runs || []
|
||||
const fallbackBottom = baseSnapshot.value?.bottomPanel || {}
|
||||
return {
|
||||
...baseSnapshot.value,
|
||||
evidence: context.evidence || baseSnapshot.value?.evidence || [],
|
||||
outputs: latestArtifacts.length ? latestArtifacts.map(item => item.title) : (baseSnapshot.value?.outputs || []),
|
||||
artifacts: latestArtifacts.length ? latestArtifacts.map(item => item.title) : (baseSnapshot.value?.artifacts || []),
|
||||
risks: context.risks || baseSnapshot.value?.risks || [],
|
||||
relatedObjects: context.relatedObjects || baseSnapshot.value?.relatedObjects || [],
|
||||
bottomPanel: currentTaskDetail.value?.bottom_panel || fallbackBottom,
|
||||
events: latestRuns.length
|
||||
? latestRuns.slice(0, 3).map(run => {
|
||||
const output = parseJSON(run.output_json, {})
|
||||
return `${formatRunTime(run.started_at)} ${output.summary || run.action_title}`
|
||||
})
|
||||
: (baseSnapshot.value?.events || []),
|
||||
}
|
||||
})
|
||||
const workspace = computed(() => getWorkspaceDescriptor(route.path, selectedCaseId.value))
|
||||
const snapshot = computed(() => getWorkspaceSnapshot(route.path, selectedCaseId.value) || {})
|
||||
const specialist = ref(null)
|
||||
const taskBoardItems = computed(() => {
|
||||
if (currentTasks.value.length) {
|
||||
return currentTasks.value.map(item => ({
|
||||
key: item.id,
|
||||
title: item.title,
|
||||
status: item.status,
|
||||
detail: item.summary || '待补充事项摘要',
|
||||
tone: toneFromStatus(item.status),
|
||||
}))
|
||||
}
|
||||
return workspace.value?.taskCards || []
|
||||
})
|
||||
|
||||
const capabilityItems = computed(() => {
|
||||
if (!specialist.value) return []
|
||||
@@ -353,15 +599,21 @@ const inputRecords = computed(() => {
|
||||
const actionRecords = computed(() => {
|
||||
const traces = snapshot.value?.bottomPanel?.trace || []
|
||||
const outputs = snapshot.value?.outputs || []
|
||||
const latestRuns = currentTaskDetail.value?.runs || []
|
||||
const fallback = (workspace.value?.taskCards || []).map((task, index) => {
|
||||
const tone = task.tone || 'info'
|
||||
const actionTitle = task.title
|
||||
const actionKey = normalizeActionKey(actionTitle)
|
||||
const matchedRun = latestRuns.find(run => run.action_key === actionKey || run.action_title === actionTitle)
|
||||
const output = parseJSON(matchedRun?.output_json, {})
|
||||
return {
|
||||
key: `action-${index}-${task.title}`,
|
||||
title: task.title,
|
||||
actionKey,
|
||||
title: actionTitle,
|
||||
type: getActionType(task.title),
|
||||
triggerMode: specialist.value?.worker_type === 'adw' ? '自动 + 人工协同' : '人工触发 + AI辅助',
|
||||
risk: getRiskLevel(tone),
|
||||
status: task.status,
|
||||
status: matchedRun ? statusText(matchedRun.status) : task.status,
|
||||
tone,
|
||||
detail: task.detail,
|
||||
inputs: (snapshot.value?.evidence || []).slice(0, 2).join(' / ') || '待补充输入信源',
|
||||
@@ -369,27 +621,44 @@ const actionRecords = computed(() => {
|
||||
approval: tone === 'warning' || tone === 'danger' ? '需要确认' : '可直接执行',
|
||||
operator: specialist.value?.worker_type === 'adw' ? 'ADW' : 'DW',
|
||||
trace: traces[index] || '',
|
||||
lastRunSummary: output.summary || '',
|
||||
}
|
||||
})
|
||||
const parsed = parseStructuredRecords(specialist.value?.action_records_json).map((item, index) => ({
|
||||
key: `action-${index}-${item.action_name || 'item'}`,
|
||||
actionKey: item.action_key || normalizeActionKey(item.action_name || `动作 ${index + 1}`),
|
||||
title: item.action_name || `动作 ${index + 1}`,
|
||||
type: item.action_type || '执行',
|
||||
triggerMode: item.trigger_mode || '未标注',
|
||||
risk: riskText(item.risk_level),
|
||||
status: statusText(item.status),
|
||||
status: statusText(findLatestRunStatus(latestRuns, item.action_key || normalizeActionKey(item.action_name || `动作 ${index + 1}`), item.action_name) || item.status),
|
||||
tone: actionTone(item.risk_level, item.approval_state),
|
||||
detail: item.detail || `当前动作由 ${item.operator || 'DW'} 执行`,
|
||||
inputs: listText(item.input_sources, '待补充输入信源'),
|
||||
outputs: listText(item.expected_output, '待补充预期结果'),
|
||||
approval: item.approval_state === 'approval_required' ? '需要确认' : '可直接执行',
|
||||
operator: item.operator || (specialist.value?.worker_type === 'adw' ? 'ADW' : 'DW'),
|
||||
lastRunSummary: findLatestRunSummary(latestRuns, item.action_key || normalizeActionKey(item.action_name || `动作 ${index + 1}`), item.action_name),
|
||||
}))
|
||||
return parsed.length ? parsed.slice(0, 6) : fallback
|
||||
})
|
||||
|
||||
const resultRecords = computed(() => {
|
||||
const currentStatus = workspace.value?.currentCase?.status || ''
|
||||
const liveArtifacts = (currentTaskDetail.value?.artifacts || []).map((item, index) => ({
|
||||
key: `artifact-${item.id}`,
|
||||
artifactId: item.id,
|
||||
title: item.title,
|
||||
status: artifactStatusText(item.status),
|
||||
tone: artifactTone(item.status),
|
||||
detail: item.content_text || '当前交付物已生成,可继续确认、发布或引用。',
|
||||
meta: `类型:${item.artifact_type || 'text'} · 创建时间:${formatRunTime(item.created_at)}`,
|
||||
derived: `事项:${workspace.value?.currentCase?.title || '当前事项'}`,
|
||||
sourceRefsText: parseJSON(item.source_refs_json, []).slice(0, 2).join(' / '),
|
||||
}))
|
||||
if (liveArtifacts.length) {
|
||||
return liveArtifacts.slice(0, 6)
|
||||
}
|
||||
const fallback = [
|
||||
...(snapshot.value?.outputs || []).map((item, index) => ({
|
||||
key: `result-output-${index}-${item}`,
|
||||
@@ -436,8 +705,233 @@ async function loadSpecialist() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWorkerTasks() {
|
||||
if (!route.meta.workspaceKey) return
|
||||
try {
|
||||
const tasks = await workerRuntime.loadTasks(route.meta.workspaceKey)
|
||||
if (tasks.length) {
|
||||
const selected = workbench.selectedCases[route.meta.workspaceKey]
|
||||
const current = tasks.find(item => String(item.id) === String(selected))
|
||||
if (!current) {
|
||||
workbench.setSelectedCase(route.meta.workspaceKey, String(tasks[0].id))
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('load worker tasks failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTaskDetail(taskId) {
|
||||
if (!taskId || !/^\d+$/.test(String(taskId))) return
|
||||
try {
|
||||
await workerRuntime.loadTaskDetail(taskId)
|
||||
} catch (error) {
|
||||
console.warn('load worker task detail failed', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function executeAction(item) {
|
||||
if (!isAdmin.value) {
|
||||
ElMessage.warning('只有管理员可以推进事项')
|
||||
return
|
||||
}
|
||||
if (!selectedCaseId.value || !/^\d+$/.test(String(selectedCaseId.value))) {
|
||||
ElMessage.warning('当前事项还未落库,暂时无法执行动作')
|
||||
return
|
||||
}
|
||||
executingActionKey.value = item.actionKey
|
||||
try {
|
||||
await workerRuntime.executeAction(selectedCaseId.value, {
|
||||
action_key: item.actionKey,
|
||||
action_title: item.title,
|
||||
action_type: item.type,
|
||||
action_detail: item.detail,
|
||||
inputs: splitText(item.inputs),
|
||||
outputs: splitText(item.outputs),
|
||||
approval: item.approval,
|
||||
})
|
||||
ElMessage.success('动作已执行,事项和交付物已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '动作执行失败')
|
||||
} finally {
|
||||
executingActionKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
if (!isAdmin.value) return
|
||||
taskDialogMode.value = 'create'
|
||||
taskForm.title = ''
|
||||
taskForm.summary = workspace.value?.currentCase?.summary || ''
|
||||
taskForm.owner = workspace.value?.currentCase?.owner || ''
|
||||
taskForm.priority = 'P2'
|
||||
taskForm.dueAt = ''
|
||||
taskDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog() {
|
||||
if (!isAdmin.value || !currentTaskDetail.value?.task) return
|
||||
taskDialogMode.value = 'edit'
|
||||
taskForm.title = currentTaskDetail.value.task.title || ''
|
||||
taskForm.summary = currentTaskDetail.value.task.summary || ''
|
||||
taskForm.owner = currentTaskDetail.value.task.owner || ''
|
||||
taskForm.priority = currentTaskDetail.value.task.priority || 'P2'
|
||||
taskForm.dueAt = normalizeDueAtForInput(currentTaskDetail.value.task.due_at)
|
||||
taskDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openAssignDialog() {
|
||||
if (!isAdmin.value || !currentTaskDetail.value?.task) return
|
||||
assignOwner.value = currentTaskDetail.value.task.owner || ''
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitTaskForm() {
|
||||
if (!isAdmin.value) {
|
||||
ElMessage.warning('只有管理员可以维护事项')
|
||||
return
|
||||
}
|
||||
if (!route.meta.workspaceKey) {
|
||||
ElMessage.warning('当前专员未配置,暂时无法创建事项')
|
||||
return
|
||||
}
|
||||
if (!taskForm.title.trim()) {
|
||||
ElMessage.warning('请先填写事项标题')
|
||||
return
|
||||
}
|
||||
savingTask.value = true
|
||||
try {
|
||||
const payload = {
|
||||
specialist_key: route.meta.workspaceKey,
|
||||
title: taskForm.title.trim(),
|
||||
summary: taskForm.summary.trim(),
|
||||
owner: taskForm.owner.trim(),
|
||||
priority: taskForm.priority,
|
||||
status: currentTaskDetail.value?.task?.status || workspace.value?.currentCase?.status || '待处理',
|
||||
due_at: taskForm.dueAt || '',
|
||||
context: {
|
||||
evidence: snapshot.value?.evidence || [],
|
||||
risks: snapshot.value?.risks || [],
|
||||
relatedObjects: snapshot.value?.relatedObjects || [],
|
||||
},
|
||||
}
|
||||
const task = taskDialogMode.value === 'create'
|
||||
? await workerRuntime.createTask(payload)
|
||||
: await workerRuntime.updateTask(selectedTaskId.value, payload)
|
||||
if (task?.id) {
|
||||
workbench.setSelectedCase(route.meta.workspaceKey, String(task.id))
|
||||
}
|
||||
taskDialogVisible.value = false
|
||||
notificationStore.refresh()
|
||||
ElMessage.success(taskDialogMode.value === 'create' ? '事项已创建' : '事项已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || (taskDialogMode.value === 'create' ? '事项创建失败' : '事项更新失败'))
|
||||
} finally {
|
||||
savingTask.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAssignOwner() {
|
||||
if (!isAdmin.value || !selectedTaskId.value || !currentTaskDetail.value?.task) {
|
||||
return
|
||||
}
|
||||
assigningOwner.value = true
|
||||
try {
|
||||
const current = currentTaskDetail.value.task
|
||||
const updated = await workerRuntime.updateTask(selectedTaskId.value, {
|
||||
specialist_key: current.specialist_key,
|
||||
title: current.title,
|
||||
summary: current.summary,
|
||||
owner: assignOwner.value.trim(),
|
||||
priority: current.priority,
|
||||
status: current.status,
|
||||
due_at: normalizeDueAtForInput(current.due_at),
|
||||
context: parseJSON(current.context_json, {}),
|
||||
})
|
||||
if (updated?.id) {
|
||||
workbench.setSelectedCase(route.meta.workspaceKey, String(updated.id))
|
||||
}
|
||||
assignDialogVisible.value = false
|
||||
notificationStore.refresh()
|
||||
ElMessage.success('负责人已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '负责人更新失败')
|
||||
} finally {
|
||||
assigningOwner.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setTaskLifecycle(status) {
|
||||
if (!isAdmin.value || !selectedTaskId.value) return
|
||||
taskStatusLoading.value = status
|
||||
try {
|
||||
await workerRuntime.updateTaskStatus(selectedTaskId.value, { status })
|
||||
ElMessage.success(`事项已更新为${status}`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '事项状态更新失败')
|
||||
} finally {
|
||||
taskStatusLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function openArtifactDetail(artifactId) {
|
||||
selectedArtifactId.value = String(artifactId)
|
||||
artifactDialogVisible.value = true
|
||||
try {
|
||||
await workerRuntime.loadArtifactDetail(artifactId)
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '交付物详情加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function transitionArtifact(status) {
|
||||
if (!isAdmin.value || !selectedArtifactId.value) return
|
||||
artifactStatusLoading.value = status
|
||||
try {
|
||||
await workerRuntime.updateArtifactStatus(selectedArtifactId.value, { status })
|
||||
notificationStore.refresh()
|
||||
ElMessage.success(`交付物已更新为${artifactStatusText(status)}`)
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '交付物状态更新失败')
|
||||
} finally {
|
||||
artifactStatusLoading.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTask() {
|
||||
if (!isAdmin.value || !selectedTaskId.value) return
|
||||
try {
|
||||
await ElMessageBox.confirm('删除后会同时移除该事项的运行记录和交付物,是否继续?', '删除事项确认', {
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await workerRuntime.deleteTask(selectedTaskId.value)
|
||||
const remaining = workerRuntime.tasksBySpecialist[route.meta.workspaceKey] || []
|
||||
const nextId = remaining[0] ? String(remaining[0].id) : ''
|
||||
if (nextId) {
|
||||
workbench.setSelectedCase(route.meta.workspaceKey, nextId)
|
||||
await loadTaskDetail(nextId)
|
||||
} else if (route.meta.workspaceKey) {
|
||||
workbench.setSelectedCase(route.meta.workspaceKey, '')
|
||||
}
|
||||
ElMessage.success(res?.deleted ? '事项已删除' : '事项已删除')
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.message || '事项删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSpecialist)
|
||||
watch(() => route.meta.workspaceKey, loadSpecialist)
|
||||
onMounted(loadWorkerTasks)
|
||||
watch(() => route.meta.workspaceKey, () => {
|
||||
loadSpecialist()
|
||||
loadWorkerTasks()
|
||||
})
|
||||
watch(selectedCaseId, loadTaskDetail, { immediate: true })
|
||||
|
||||
const statusTextMap = {
|
||||
completed: '已完成',
|
||||
@@ -530,6 +1024,114 @@ function resultTone(value = '') {
|
||||
if (value === 'trial') return 'info'
|
||||
return 'success'
|
||||
}
|
||||
|
||||
function mapTaskToCase(task) {
|
||||
return {
|
||||
id: String(task.id),
|
||||
title: task.title,
|
||||
owner: task.owner || '待指派',
|
||||
priority: task.priority || 'P2',
|
||||
due: formatRunTime(task.due_at, true),
|
||||
summary: task.summary || '待补充事项摘要',
|
||||
status: task.status || '待处理',
|
||||
}
|
||||
}
|
||||
|
||||
const selectedArtifact = computed(() => {
|
||||
const detail = selectedArtifactDetail.value?.artifact
|
||||
if (detail) return detail
|
||||
return (currentTaskDetail.value?.artifacts || []).find(item => String(item.id) === String(selectedArtifactId.value)) || null
|
||||
})
|
||||
|
||||
const selectedArtifactTask = computed(() => {
|
||||
return selectedArtifactDetail.value?.task || currentTaskDetail.value?.task || null
|
||||
})
|
||||
|
||||
const selectedArtifactRun = computed(() => {
|
||||
return selectedArtifactDetail.value?.run || null
|
||||
})
|
||||
|
||||
const selectedArtifactSourceRefs = computed(() => {
|
||||
return parseJSON(selectedArtifact.value?.source_refs_json, [])
|
||||
})
|
||||
|
||||
function parseJSON(value, fallback) {
|
||||
if (!value) return fallback
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function formatRunTime(value, short = false) {
|
||||
if (!value) return short ? '待设置' : ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return short ? '待设置' : ''
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return short ? `${month}-${day} ${hour}:${minute}` : `${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function normalizeDueAtForInput(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const year = date.getFullYear()
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||
const day = `${date.getDate()}`.padStart(2, '0')
|
||||
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hour}:${minute}`
|
||||
}
|
||||
|
||||
function toneFromStatus(value = '') {
|
||||
if (containsText(value, '归档', '完成')) return 'success'
|
||||
if (containsText(value, '确认', '审批')) return 'warning'
|
||||
if (containsText(value, '阻塞', '异常')) return 'danger'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function normalizeActionKey(value = '') {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s/\\·_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
}
|
||||
|
||||
function findLatestRunStatus(runs = [], actionKey = '', actionTitle = '') {
|
||||
return runs.find(run => run.action_key === actionKey || run.action_title === actionTitle)?.status || ''
|
||||
}
|
||||
|
||||
function findLatestRunSummary(runs = [], actionKey = '', actionTitle = '') {
|
||||
const run = runs.find(item => item.action_key === actionKey || item.action_title === actionTitle)
|
||||
const output = parseJSON(run?.output_json, {})
|
||||
return output.summary || ''
|
||||
}
|
||||
|
||||
function artifactStatusText(value = '') {
|
||||
const map = {
|
||||
draft: '草案',
|
||||
ready: '待确认',
|
||||
approved: '已确认',
|
||||
published: '已发布',
|
||||
}
|
||||
return map[value] || value || '已生成'
|
||||
}
|
||||
|
||||
function artifactTone(value = '') {
|
||||
if (value === 'published') return 'success'
|
||||
if (value === 'approved') return 'success'
|
||||
if (value === 'ready') return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function containsText(value = '', ...parts) {
|
||||
return parts.some(part => value.includes(part))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -741,6 +1343,24 @@ function resultTone(value = '') {
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.action-footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-run-summary {
|
||||
font-size: 12px;
|
||||
color: #63768a;
|
||||
}
|
||||
|
||||
.action-readonly-tip {
|
||||
font-size: 12px;
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.stage-card {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
|
||||
@@ -1,132 +1,889 @@
|
||||
<template>
|
||||
<div class="surface-page">
|
||||
<section class="hero panel">
|
||||
<div>
|
||||
<div class="eyebrow">Knowledge Hub</div>
|
||||
<h2>知识、证据、规则、对象字典</h2>
|
||||
<p>知识库作为固定入口存在,支撑所有数字员工的证据引用、规则约束和对象映射。</p>
|
||||
</div>
|
||||
<div class="hero-tags">
|
||||
<span class="pill">Assets</span>
|
||||
<span class="pill">Evidence</span>
|
||||
<span class="pill">Rules</span>
|
||||
<span class="pill">Objects</span>
|
||||
<span class="pill">Scenarios</span>
|
||||
</div>
|
||||
<div class="knowledge-app-page">
|
||||
<aside class="sidebar">
|
||||
<button class="new-session-btn" @click="createSession">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>新建会话</span>
|
||||
</button>
|
||||
|
||||
<section class="sidebar-section">
|
||||
<div class="section-title">知识空间</div>
|
||||
<button
|
||||
v-for="space in knowledgeSpaces"
|
||||
:key="space.id"
|
||||
class="space-item"
|
||||
:class="{ active: selectedSpaceId === space.id }"
|
||||
@click="selectedSpaceId = space.id"
|
||||
>
|
||||
<span class="space-name">{{ space.name }}</span>
|
||||
<small>{{ space.desc }}</small>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Evidence Explorer</div>
|
||||
<div v-for="item in evidence" :key="item.title" class="list-item">
|
||||
<div class="list-title">{{ item.title }}</div>
|
||||
<div class="list-sub">{{ item.sub }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">对象字典</div>
|
||||
<div v-for="item in objects" :key="item" class="chip">{{ item }}</div>
|
||||
</div>
|
||||
<section class="sidebar-section">
|
||||
<div class="section-title">我的会话</div>
|
||||
<button
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
class="session-item"
|
||||
:class="{ active: session.id === activeSessionId }"
|
||||
@click="activeSessionId = session.id"
|
||||
>
|
||||
<span class="session-title">{{ session.title }}</span>
|
||||
<small>{{ session.updatedAt }}</small>
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="chat-stage">
|
||||
<div ref="chatScrollRef" class="chat-scroll">
|
||||
<div v-if="!activeMessages.length" class="empty-state">
|
||||
<div class="eyebrow">Knowledge Application</div>
|
||||
<h2>你好,我是知识库助手</h2>
|
||||
<p>
|
||||
基于本地知识库回答问题、总结文档、生成结果。你可以直接提问,也可以先上传文件再继续追问。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="chat-layout" :class="{ 'has-messages': activeMessages.length }">
|
||||
<div class="message-stream">
|
||||
<div
|
||||
v-for="message in activeMessages"
|
||||
:key="message.id"
|
||||
class="message-row"
|
||||
:class="message.role"
|
||||
>
|
||||
<div class="avatar">{{ message.role === 'user' ? '我' : '知' }}</div>
|
||||
<div class="bubble-wrap">
|
||||
<div class="bubble">
|
||||
<template v-if="message.content">
|
||||
<div class="message-text">{{ message.content }}</div>
|
||||
</template>
|
||||
<template v-else-if="message.role === 'assistant' && streaming">
|
||||
<div class="thinking-state">
|
||||
<span class="thinking-spinner"></span>
|
||||
<span class="thinking-text">{{ thinkingText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="message.meta?.spaceName || message.meta?.attachments?.length" class="message-meta">
|
||||
<span v-if="message.meta?.spaceName">检索范围:{{ message.meta.spaceName }}</span>
|
||||
<span v-if="message.meta?.attachments?.length">
|
||||
附件:{{ message.meta.attachments.join('、') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-composer" :class="{ hero: !activeMessages.length }">
|
||||
<div class="composer-toolbar">
|
||||
<el-upload
|
||||
action="/api/media/upload"
|
||||
:data="uploadPayload"
|
||||
:headers="uploadHeaders"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadSuccess"
|
||||
:on-error="handleUploadError"
|
||||
>
|
||||
<el-button plain round>
|
||||
<el-icon><Plus /></el-icon>
|
||||
上传资料
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<span class="space-tip">当前检索范围:{{ selectedSpace.name }}</span>
|
||||
</div>
|
||||
<div class="attachment-row" v-if="attachments.length">
|
||||
<span
|
||||
v-for="file in attachments"
|
||||
:key="file.id"
|
||||
class="attachment-chip"
|
||||
>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="composer"
|
||||
type="textarea"
|
||||
:autosize="!activeMessages.length ? { minRows: 3, maxRows: 6 } : { minRows: 2, maxRows: 5 }"
|
||||
resize="none"
|
||||
:placeholder="!activeMessages.length ? '试着问:帮我总结销售培训资料里关于佣金规则的重点' : '继续追问,或让系统基于当前知识库生成结果'"
|
||||
@keydown.enter.exact.prevent="submitMessage"
|
||||
/>
|
||||
<div class="composer-actions" :class="{ compact: activeMessages.length }">
|
||||
<div class="quick-inline" :class="{ cards: !activeMessages.length }">
|
||||
<button
|
||||
v-for="action in quickActionList"
|
||||
:key="action.id"
|
||||
:class="activeMessages.length ? 'quick-link' : 'quick-card'"
|
||||
@click="runQuickAction(action)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
<el-button type="primary" round :loading="streaming" @click="submitMessage">
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<aside class="context-panel">
|
||||
<section class="context-card">
|
||||
<div class="context-title">当前检索范围</div>
|
||||
<div class="context-name">{{ selectedSpace.name }}</div>
|
||||
<div class="context-desc">{{ selectedSpace.desc }}</div>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-title">本次命中层级</div>
|
||||
<div v-if="latestRouting.layer" class="tag-list">
|
||||
<span class="tag">{{ layerLabel(latestRouting.layer) }}</span>
|
||||
<span class="tag">{{ intentLabel(latestRouting.intent) }}</span>
|
||||
</div>
|
||||
<div v-else class="empty-tip">当前会话还没有完成路由判定</div>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-title">命中引用</div>
|
||||
<div v-if="citationLoading" class="empty-tip">正在检索相关知识片段...</div>
|
||||
<div v-else-if="latestCitations.length" class="citation-list">
|
||||
<article
|
||||
v-for="citation in latestCitations"
|
||||
:key="citation.id"
|
||||
class="citation-item"
|
||||
>
|
||||
<div class="citation-head">
|
||||
<strong>{{ citation.title }}</strong>
|
||||
<span>{{ citation.knowledge_space_name }}</span>
|
||||
</div>
|
||||
<p>{{ citation.snippet }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty-tip">当前会话还没有可展示的引用来源</div>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-title">本轮附件</div>
|
||||
<div v-if="attachments.length" class="tag-list">
|
||||
<span v-for="file in attachments" :key="file.id" class="tag">{{ file.name }}</span>
|
||||
</div>
|
||||
<div v-else class="empty-tip">暂未上传附件</div>
|
||||
</section>
|
||||
|
||||
<section class="context-card">
|
||||
<div class="context-title">推荐追问</div>
|
||||
<button
|
||||
v-for="prompt in followupPrompts"
|
||||
:key="prompt"
|
||||
class="followup-item"
|
||||
@click="composer = prompt"
|
||||
>
|
||||
{{ prompt }}
|
||||
</button>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const evidence = [
|
||||
{ title: 'e-2391 合同片段', sub: '已连接 合同审查 / 华东客户主协议修订' },
|
||||
{ title: 'e-5199 调研会议纪要', sub: '已连接 售前方案 / A 客户升级方案' },
|
||||
{ title: 'e-7012 船司异常邮件', sub: '已连接 货代履约 / 美西航线本周履约看板' },
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { quickAction, quickActions, streamChat } from '@/api/ai'
|
||||
import { listKnowledgeSpaces } from '@/api/knowledge'
|
||||
|
||||
const fallbackKnowledgeSpaces = [
|
||||
{ id: 'general', name: '通用知识库', desc: '面向企业通用制度、规则、案例与产品总览' },
|
||||
{ id: 'product', name: '产品知识库', desc: '聚合产品资料、FAQ、对比说明与销售话术' },
|
||||
{ id: 'training', name: '培训资料库', desc: '聚合培训课程、训练营资料与学习内容' },
|
||||
{ id: 'policy', name: '规则制度库', desc: '聚合制度文件、审批规则与合规要求' },
|
||||
]
|
||||
|
||||
const objects = ['Task', 'Artifact', 'Evidence', 'Scenario', 'PolicyRule', 'ConnectorBinding', 'Checkpoint']
|
||||
const allKnowledgeSpace = {
|
||||
id: 'all',
|
||||
name: '全部知识库',
|
||||
desc: '默认全局检索所有知识空间;主动选择左侧知识库时再收窄范围',
|
||||
}
|
||||
|
||||
const followupPrompts = [
|
||||
'帮我把这轮回答整理成摘要',
|
||||
'把相关规则按条目列出来',
|
||||
'生成一个培训提纲',
|
||||
'告诉我还缺哪些材料',
|
||||
]
|
||||
|
||||
const sessions = ref([])
|
||||
const activeSessionId = ref('')
|
||||
const selectedSpaceId = ref('all')
|
||||
const composer = ref('')
|
||||
const attachments = ref([])
|
||||
const quickActionList = ref([])
|
||||
const streaming = ref(false)
|
||||
const citationLoading = ref(false)
|
||||
const knowledgeSpaces = ref([...fallbackKnowledgeSpaces])
|
||||
const chatScrollRef = ref(null)
|
||||
|
||||
const uploadHeaders = computed(() => {
|
||||
const token = localStorage.getItem('token') || ''
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
const uploadPayload = computed(() => ({
|
||||
knowledge_space_key: selectedSpaceId.value === 'all' ? 'general' : (selectedSpaceId.value || 'general'),
|
||||
}))
|
||||
|
||||
const selectedSpace = computed(() => {
|
||||
return knowledgeSpaces.value.find((item) => item.id === selectedSpaceId.value) || knowledgeSpaces.value[0] || allKnowledgeSpace
|
||||
})
|
||||
|
||||
const activeSession = computed(() => {
|
||||
return sessions.value.find((item) => item.id === activeSessionId.value) || null
|
||||
})
|
||||
|
||||
const activeMessages = computed(() => activeSession.value?.messages || [])
|
||||
const latestCitations = computed(() => {
|
||||
const messages = [...activeMessages.value].reverse()
|
||||
const latest = messages.find((item) => item.role === 'assistant' && item.meta?.citations?.length)
|
||||
return latest?.meta?.citations || []
|
||||
})
|
||||
const thinkingText = computed(() => {
|
||||
return citationLoading.value ? '正在检索知识库,请稍候...' : '正在整理回答,请稍候...'
|
||||
})
|
||||
const latestRouting = computed(() => {
|
||||
const messages = [...activeMessages.value].reverse()
|
||||
const latest = messages.find((item) => item.role === 'assistant' && (item.meta?.layer || item.meta?.intent))
|
||||
return latest?.meta || {}
|
||||
})
|
||||
|
||||
function createSession() {
|
||||
const id = `session-${Date.now()}`
|
||||
sessions.value.unshift({
|
||||
id,
|
||||
title: '新会话',
|
||||
updatedAt: formatNow(),
|
||||
messages: [],
|
||||
})
|
||||
activeSessionId.value = id
|
||||
attachments.value = []
|
||||
}
|
||||
|
||||
function scrollChatToBottom(smooth = true) {
|
||||
nextTick(() => {
|
||||
if (!chatScrollRef.value) return
|
||||
chatScrollRef.value.scrollTo({
|
||||
top: chatScrollRef.value.scrollHeight,
|
||||
behavior: smooth ? 'smooth' : 'auto',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function ensureSession() {
|
||||
if (!activeSession.value) createSession()
|
||||
}
|
||||
|
||||
function updateSessionTitle(message) {
|
||||
if (!activeSession.value) return
|
||||
if (activeSession.value.title === '新会话') {
|
||||
activeSession.value.title = message.slice(0, 16) || '新会话'
|
||||
}
|
||||
activeSession.value.updatedAt = formatNow()
|
||||
}
|
||||
|
||||
function appendMessage(role, content, meta = {}) {
|
||||
ensureSession()
|
||||
const message = {
|
||||
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
role,
|
||||
content,
|
||||
meta,
|
||||
}
|
||||
activeSession.value.messages.push(message)
|
||||
scrollChatToBottom(false)
|
||||
return message
|
||||
}
|
||||
|
||||
function buildContext() {
|
||||
return {
|
||||
module: 'knowledge-application',
|
||||
knowledge_space_key: selectedSpaceId.value,
|
||||
knowledge_space: selectedSpace.value.name,
|
||||
attachments: attachments.value.map((item) => item.name),
|
||||
}
|
||||
}
|
||||
|
||||
function buildHistory() {
|
||||
return activeMessages.value.map((item) => ({
|
||||
role: item.role === 'assistant' ? 'assistant' : 'user',
|
||||
content: item.content,
|
||||
}))
|
||||
}
|
||||
|
||||
async function submitMessage() {
|
||||
const message = composer.value.trim()
|
||||
if (!message || streaming.value) return
|
||||
const previousHistory = buildHistory()
|
||||
|
||||
appendMessage('user', message, {
|
||||
spaceName: selectedSpace.value.name,
|
||||
attachments: attachments.value.map((item) => item.name),
|
||||
})
|
||||
updateSessionTitle(message)
|
||||
composer.value = ''
|
||||
|
||||
const assistantMessage = appendMessage('assistant', '', {
|
||||
citations: [],
|
||||
layer: '',
|
||||
intent: '',
|
||||
})
|
||||
citationLoading.value = true
|
||||
streaming.value = true
|
||||
try {
|
||||
await streamChat(message, buildContext(), previousHistory, {
|
||||
onMeta(meta) {
|
||||
assistantMessage.meta = {
|
||||
...assistantMessage.meta,
|
||||
citations: meta.citations || [],
|
||||
layer: meta.layer || '',
|
||||
intent: meta.intent || '',
|
||||
}
|
||||
citationLoading.value = false
|
||||
},
|
||||
onChunk(chunk) {
|
||||
assistantMessage.content += chunk
|
||||
},
|
||||
onDone() {
|
||||
citationLoading.value = false
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
assistantMessage.content = error.message || '知识库助手暂时不可用,请稍后重试。'
|
||||
citationLoading.value = false
|
||||
} finally {
|
||||
streaming.value = false
|
||||
updateSessionTitle(message)
|
||||
}
|
||||
}
|
||||
|
||||
async function runQuickAction(action) {
|
||||
if (!action?.id || streaming.value) return
|
||||
ensureSession()
|
||||
appendMessage('user', `快捷动作:${action.label}`, {
|
||||
spaceName: selectedSpace.value.name,
|
||||
attachments: attachments.value.map((item) => item.name),
|
||||
})
|
||||
const assistantMessage = appendMessage('assistant', '', {
|
||||
citations: [],
|
||||
layer: '',
|
||||
intent: '',
|
||||
})
|
||||
streaming.value = true
|
||||
citationLoading.value = true
|
||||
try {
|
||||
const res = await quickAction(action.id, buildContext())
|
||||
assistantMessage.meta = {
|
||||
...assistantMessage.meta,
|
||||
citations: res?.data?.citations || [],
|
||||
layer: res?.data?.layer || '',
|
||||
intent: res?.data?.intent || '',
|
||||
}
|
||||
assistantMessage.content = res?.data?.result || '已执行快捷动作,但暂未返回内容。'
|
||||
} catch (error) {
|
||||
assistantMessage.content = error.message || '快捷动作执行失败,请稍后重试。'
|
||||
} finally {
|
||||
citationLoading.value = false
|
||||
streaming.value = false
|
||||
activeSession.value.updatedAt = formatNow()
|
||||
}
|
||||
}
|
||||
|
||||
function handleUploadSuccess(response) {
|
||||
const file = response?.data
|
||||
if (!file) return
|
||||
attachments.value.unshift({
|
||||
id: file.id || `file-${Date.now()}`,
|
||||
name: file.filename || file.original_name || '未命名资料',
|
||||
})
|
||||
ElMessage.success('资料上传成功,可继续在当前会话中提问')
|
||||
}
|
||||
|
||||
function handleUploadError() {
|
||||
ElMessage.error('资料上传失败')
|
||||
}
|
||||
|
||||
function layerLabel(layer) {
|
||||
return {
|
||||
bert_classifier: 'Bert 分类器',
|
||||
faq: 'FAQ 标准答案',
|
||||
vector: '向量检索直返',
|
||||
llm_fallback: 'LLM 兜底',
|
||||
}[layer] || '未知层级'
|
||||
}
|
||||
|
||||
function intentLabel(intent) {
|
||||
return {
|
||||
faq: 'FAQ 类问题',
|
||||
document: '文档检索问题',
|
||||
smalltalk: '闲聊拦截',
|
||||
out_of_scope: '越域拦截',
|
||||
invalid: '无效提问',
|
||||
}[intent] || '未识别意图'
|
||||
}
|
||||
|
||||
function formatNow() {
|
||||
const now = new Date()
|
||||
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
createSession()
|
||||
try {
|
||||
const spaceRes = await listKnowledgeSpaces()
|
||||
const items = spaceRes?.data?.items || []
|
||||
if (items.length) {
|
||||
knowledgeSpaces.value = items.map((item) => ({
|
||||
id: item.key,
|
||||
name: item.name,
|
||||
desc: item.description,
|
||||
}))
|
||||
knowledgeSpaces.value = [allKnowledgeSpace, ...knowledgeSpaces.value]
|
||||
} else {
|
||||
knowledgeSpaces.value = [allKnowledgeSpace, ...fallbackKnowledgeSpaces]
|
||||
}
|
||||
} catch (error) {
|
||||
knowledgeSpaces.value = [allKnowledgeSpace, ...fallbackKnowledgeSpaces]
|
||||
}
|
||||
if (!knowledgeSpaces.value.some((item) => item.id === selectedSpaceId.value)) {
|
||||
selectedSpaceId.value = 'all'
|
||||
}
|
||||
try {
|
||||
const res = await quickActions()
|
||||
quickActionList.value = res?.data?.actions || []
|
||||
} catch (error) {
|
||||
quickActionList.value = [
|
||||
{ id: 'scenario', label: '客户情景演练' },
|
||||
{ id: 'commission', label: '查询佣金/规则' },
|
||||
{ id: 'compare', label: '产品对比' },
|
||||
]
|
||||
}
|
||||
scrollChatToBottom(false)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => activeMessages.value.map((item) => item.content).join('\n'),
|
||||
() => {
|
||||
scrollChatToBottom(false)
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.surface-page {
|
||||
.knowledge-app-page {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr) 300px;
|
||||
gap: 16px;
|
||||
min-height: calc(100vh - 220px);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.context-panel,
|
||||
.chat-stage {
|
||||
border-radius: 24px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(228, 231, 237, 0.9);
|
||||
box-shadow: 0 18px 36px rgba(31, 35, 41, 0.05);
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.context-panel {
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.new-session-btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #f5f8ff 0%, #eef6ff 100%);
|
||||
color: #2b63d9;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.3px;
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.space-item,
|
||||
.session-item,
|
||||
.followup-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(228, 231, 237, 0.85);
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.space-item.active,
|
||||
.session-item.active {
|
||||
border-color: rgba(64, 158, 255, 0.4);
|
||||
background: rgba(64, 158, 255, 0.06);
|
||||
}
|
||||
|
||||
.space-name,
|
||||
.session-title,
|
||||
.context-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.space-item small,
|
||||
.session-item small,
|
||||
.context-desc,
|
||||
.empty-tip {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #7a8694;
|
||||
}
|
||||
|
||||
.chat-stage {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0 auto 28px;
|
||||
width: min(860px, 100%);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 10px 0 12px;
|
||||
font-size: 40px;
|
||||
color: #172533;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
max-width: 680px;
|
||||
margin: 0 auto 24px;
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #5f6b7a;
|
||||
}
|
||||
|
||||
.chat-composer {
|
||||
border: 1px solid rgba(228, 231, 237, 0.85);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
||||
padding: 16px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.chat-composer.hero {
|
||||
width: min(860px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.composer-toolbar,
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.composer-toolbar {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.space-tip {
|
||||
font-size: 12px;
|
||||
color: #7a8694;
|
||||
}
|
||||
|
||||
.attachment-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
|
||||
.attachment-chip,
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
padding: 7px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
color: #2b63d9;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.composer-actions.compact {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.quick-card,
|
||||
.quick-link {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
background: rgba(245, 247, 250, 0.92);
|
||||
color: #4b5968;
|
||||
}
|
||||
|
||||
.quick-card {
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.quick-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quick-inline.cards {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quick-link {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
width: min(860px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.chat-layout.has-messages {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.message-stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-row.user .avatar {
|
||||
order: 2;
|
||||
background: #2b63d9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-row.user .bubble-wrap {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #eef5ff;
|
||||
color: #2b63d9;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bubble-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-width: min(78%, 780px);
|
||||
}
|
||||
|
||||
.bubble {
|
||||
border-radius: 20px;
|
||||
background: #f6f8fb;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.message-row.user .bubble {
|
||||
background: linear-gradient(135deg, #2b63d9 0%, #4f8fff 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.75;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.thinking-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 24px;
|
||||
color: #5f6b7a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.thinking-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(43, 99, 217, 0.18);
|
||||
border-top-color: #2b63d9;
|
||||
animation: thinking-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thinking-text {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@keyframes thinking-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.context-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 18px;
|
||||
.context-card {
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(228, 231, 237, 0.9);
|
||||
box-shadow: 0 14px 28px rgba(31, 35, 41, 0.05);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
||||
border: 1px solid rgba(228, 231, 237, 0.85);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
.context-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
color: #8a96a3;
|
||||
}
|
||||
|
||||
.hero h2 {
|
||||
margin: 6px 0 10px;
|
||||
font-size: 28px;
|
||||
color: #172533;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #526071;
|
||||
max-width: 740px;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
.citation-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pill,
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin: 0 8px 8px 0;
|
||||
.citation-item {
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(245, 247, 250, 0.88);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 1fr;
|
||||
gap: 16px;
|
||||
.citation-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.panel-title,
|
||||
.list-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
.citation-head strong {
|
||||
font-size: 13px;
|
||||
color: #1f2d3d;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid rgba(228, 231, 237, 0.8);
|
||||
.citation-head span,
|
||||
.citation-item p {
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
color: #6b7785;
|
||||
}
|
||||
|
||||
.list-item:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
.citation-item p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: #63768a;
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.knowledge-app-page {
|
||||
grid-template-columns: 220px minmax(0, 1fr) 280px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.knowledge-app-page {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.context-panel {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,385 @@
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import faiss
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from transformers import AutoModel, AutoModelForSequenceClassification, AutoTokenizer
|
||||
|
||||
|
||||
app = FastAPI(title="Bosun Knowledge Service", version="1.0.0")
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
query: str
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
query: str
|
||||
space_key: str = "general"
|
||||
top_k: int = 5
|
||||
|
||||
|
||||
class IndexItem(BaseModel):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
chunk_index: int = 0
|
||||
source_type: str = ""
|
||||
source_id: str = ""
|
||||
knowledge_space_key: str = "general"
|
||||
knowledge_space_name: str = "通用知识库"
|
||||
|
||||
|
||||
class RebuildIndexRequest(BaseModel):
|
||||
index_dir: str
|
||||
items: list[IndexItem] = Field(default_factory=list)
|
||||
|
||||
|
||||
class KnowledgeEngine:
|
||||
def __init__(self) -> None:
|
||||
self.device = "cuda" if os.getenv("KNOWLEDGE_DEVICE", "cpu") == "cuda" and torch.cuda.is_available() else "cpu"
|
||||
self.classifier_encoder_name = os.getenv("KNOWLEDGE_BERT_MODEL", "bert-base-chinese")
|
||||
self.classifier_model_name = os.getenv("KNOWLEDGE_BERT_CLASSIFIER_MODEL", "")
|
||||
self.embedding_model_name = os.getenv("KNOWLEDGE_BGE_MODEL", "BAAI/bge-small-zh-v1.5")
|
||||
self.lock = threading.RLock()
|
||||
self.classifier_tokenizer = None
|
||||
self.classifier_encoder = None
|
||||
self.classifier_head_tokenizer = None
|
||||
self.classifier_head_model = None
|
||||
self.embedding_tokenizer = None
|
||||
self.embedding_model = None
|
||||
self.index = None
|
||||
self.metadata: list[dict[str, Any]] = []
|
||||
self.index_dir = Path(os.getenv("KNOWLEDGE_INDEX_DIR", Path(__file__).resolve().parent / "data" / "faiss"))
|
||||
self.index_path = self.index_dir / "knowledge.index"
|
||||
self.meta_path = self.index_dir / "knowledge_meta.json"
|
||||
self.label_descriptions = {
|
||||
"faq": "标准业务问答、高频固定问题、制度规则查询、功能操作说明",
|
||||
"document": "需要查阅资料、归纳文档、分析内容、总结合同与报告的复杂问题",
|
||||
"smalltalk": "闲聊、故事、笑话、天气、娱乐、无业务价值的聊天问题",
|
||||
"out_of_scope": "股票、医疗、法律、旅游、影视等超出企业知识库范围的问题",
|
||||
"invalid": "信息过少、语义不完整、无法判断意图的无效提问",
|
||||
}
|
||||
self._load_index_if_exists()
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
return {
|
||||
"device": self.device,
|
||||
"classifier_encoder": self.classifier_encoder_name,
|
||||
"classifier_model": self.classifier_model_name or "prototype-bert",
|
||||
"embedding_model": self.embedding_model_name,
|
||||
"index_dir": str(self.index_dir),
|
||||
"indexed_items": len(self.metadata),
|
||||
}
|
||||
|
||||
def classify(self, query: str) -> dict[str, Any]:
|
||||
query = (query or "").strip()
|
||||
if len(query) < 2:
|
||||
return {"intent": "invalid", "score": 1.0, "reason": "query_too_short"}
|
||||
|
||||
if self.classifier_model_name:
|
||||
try:
|
||||
return self._classify_with_sequence_model(query)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
return self._classify_with_prototypes(query)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
rule_result = self._rule_classify(query)
|
||||
if rule_result:
|
||||
return rule_result
|
||||
|
||||
return {"intent": "document", "score": 0.0, "reason": "bert_classifier_unavailable"}
|
||||
|
||||
def _rule_classify(self, query: str) -> dict[str, Any] | None:
|
||||
text = query.strip().lower()
|
||||
if not text:
|
||||
return {"intent": "invalid", "score": 1.0, "reason": "empty_query"}
|
||||
|
||||
smalltalk_keywords = [
|
||||
"讲个故事",
|
||||
"小故事",
|
||||
"笑话",
|
||||
"闲聊",
|
||||
"聊天",
|
||||
"夸夸我",
|
||||
"今天天气",
|
||||
"天气",
|
||||
"星座",
|
||||
"你好",
|
||||
"hi",
|
||||
"hello",
|
||||
"你是谁",
|
||||
"你能做什么",
|
||||
"你是怎么工作的",
|
||||
]
|
||||
if any(keyword in text for keyword in smalltalk_keywords):
|
||||
return {"intent": "smalltalk", "score": 0.98, "reason": "rule_smalltalk"}
|
||||
|
||||
out_of_scope_keywords = [
|
||||
"股票",
|
||||
"彩票",
|
||||
"电影",
|
||||
"明星",
|
||||
"旅游",
|
||||
"菜谱",
|
||||
"医学",
|
||||
"法律咨询",
|
||||
"写诗",
|
||||
"翻译成英文",
|
||||
]
|
||||
if any(keyword in text for keyword in out_of_scope_keywords):
|
||||
return {"intent": "out_of_scope", "score": 0.98, "reason": "rule_out_of_scope"}
|
||||
|
||||
document_keywords = [
|
||||
"总结",
|
||||
"梳理",
|
||||
"分析",
|
||||
"对比",
|
||||
"归纳",
|
||||
"提纲",
|
||||
"解读",
|
||||
"起草",
|
||||
"生成",
|
||||
"合同",
|
||||
"报告",
|
||||
"方案",
|
||||
"条款",
|
||||
"根据资料",
|
||||
"根据文档",
|
||||
"根据知识库",
|
||||
"整理",
|
||||
"审批要点",
|
||||
]
|
||||
if any(keyword in text for keyword in document_keywords):
|
||||
return {"intent": "document", "score": 0.97, "reason": "rule_document"}
|
||||
|
||||
faq_keywords = [
|
||||
"如何",
|
||||
"怎么",
|
||||
"哪里",
|
||||
"在哪",
|
||||
"是否",
|
||||
"有没有",
|
||||
"可以",
|
||||
"支持",
|
||||
"密码",
|
||||
"登录",
|
||||
"佣金",
|
||||
"规则",
|
||||
"流程",
|
||||
"审批",
|
||||
"上传",
|
||||
"删除",
|
||||
"新建",
|
||||
]
|
||||
if any(keyword in text for keyword in faq_keywords):
|
||||
return {"intent": "faq", "score": 0.96, "reason": "rule_faq"}
|
||||
|
||||
if len(query) <= 18:
|
||||
return {"intent": "faq", "score": 0.75, "reason": "rule_short_query"}
|
||||
|
||||
return None
|
||||
|
||||
def search(self, query: str, space_key: str, top_k: int) -> list[dict[str, Any]]:
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return []
|
||||
with self.lock:
|
||||
if self.index is None or not self.metadata:
|
||||
self._load_index_if_exists()
|
||||
if self.index is None or not self.metadata:
|
||||
return []
|
||||
|
||||
query_vec = self._encode_embedding([query])[0].astype("float32")
|
||||
query_vec = np.expand_dims(query_vec, axis=0)
|
||||
fetch_k = min(len(self.metadata), max(top_k * 8, 32))
|
||||
scores, indices = self.index.search(query_vec, fetch_k)
|
||||
items: list[dict[str, Any]] = []
|
||||
for score, idx in zip(scores[0].tolist(), indices[0].tolist()):
|
||||
if idx < 0 or idx >= len(self.metadata):
|
||||
continue
|
||||
meta = self.metadata[idx]
|
||||
if not self._match_space(space_key, meta.get("knowledge_space_key", "general")):
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
**meta,
|
||||
"snippet": self._build_snippet(meta.get("content", ""), query),
|
||||
"score": float(score),
|
||||
}
|
||||
)
|
||||
if len(items) >= top_k:
|
||||
break
|
||||
return items
|
||||
|
||||
def rebuild_index(self, index_dir: str, items: list[IndexItem]) -> dict[str, Any]:
|
||||
target_dir = Path(index_dir or self.index_dir)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
records = [item.model_dump() for item in items if item.content.strip()]
|
||||
|
||||
if not records:
|
||||
empty_index = faiss.IndexFlatIP(384)
|
||||
faiss.write_index(empty_index, str(target_dir / "knowledge.index"))
|
||||
(target_dir / "knowledge_meta.json").write_text("[]", encoding="utf-8")
|
||||
with self.lock:
|
||||
self.index_dir = target_dir
|
||||
self.index_path = target_dir / "knowledge.index"
|
||||
self.meta_path = target_dir / "knowledge_meta.json"
|
||||
self.index = empty_index
|
||||
self.metadata = []
|
||||
return {"indexed_items": 0}
|
||||
|
||||
embeddings = self._encode_embedding([item["content"] for item in records]).astype("float32")
|
||||
dim = int(embeddings.shape[1])
|
||||
index = faiss.IndexFlatIP(dim)
|
||||
index.add(embeddings)
|
||||
faiss.write_index(index, str(target_dir / "knowledge.index"))
|
||||
(target_dir / "knowledge_meta.json").write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
with self.lock:
|
||||
self.index_dir = target_dir
|
||||
self.index_path = target_dir / "knowledge.index"
|
||||
self.meta_path = target_dir / "knowledge_meta.json"
|
||||
self.index = index
|
||||
self.metadata = records
|
||||
return {"indexed_items": len(records)}
|
||||
|
||||
def _load_index_if_exists(self) -> None:
|
||||
with self.lock:
|
||||
if self.index_path.exists() and self.meta_path.exists():
|
||||
self.index = faiss.read_index(str(self.index_path))
|
||||
self.metadata = json.loads(self.meta_path.read_text(encoding="utf-8"))
|
||||
|
||||
def _classify_with_sequence_model(self, query: str) -> dict[str, Any]:
|
||||
if self.classifier_head_tokenizer is None or self.classifier_head_model is None:
|
||||
self.classifier_head_tokenizer = AutoTokenizer.from_pretrained(self.classifier_model_name)
|
||||
self.classifier_head_model = AutoModelForSequenceClassification.from_pretrained(self.classifier_model_name).to(self.device)
|
||||
self.classifier_head_model.eval()
|
||||
encoded = self.classifier_head_tokenizer(query, return_tensors="pt", truncation=True, max_length=256).to(self.device)
|
||||
with torch.no_grad():
|
||||
logits = self.classifier_head_model(**encoded).logits
|
||||
probs = F.softmax(logits, dim=-1)[0].detach().cpu().numpy()
|
||||
labels = self.classifier_head_model.config.id2label or {}
|
||||
best_idx = int(np.argmax(probs))
|
||||
intent = str(labels.get(best_idx, "document")).lower()
|
||||
if intent not in self.label_descriptions:
|
||||
intent = "document"
|
||||
return {"intent": intent, "score": float(probs[best_idx]), "reason": "sequence_classifier"}
|
||||
|
||||
def _classify_with_prototypes(self, query: str) -> dict[str, Any]:
|
||||
texts = [query] + list(self.label_descriptions.values())
|
||||
vectors = self._encode_classifier(texts)
|
||||
query_vec = vectors[0]
|
||||
label_vecs = vectors[1:]
|
||||
scores = np.dot(label_vecs, query_vec)
|
||||
labels = list(self.label_descriptions.keys())
|
||||
best_idx = int(np.argmax(scores))
|
||||
return {
|
||||
"intent": labels[best_idx],
|
||||
"score": float(scores[best_idx]),
|
||||
"reason": "prototype_bert",
|
||||
}
|
||||
|
||||
def _encode_classifier(self, texts: list[str]) -> np.ndarray:
|
||||
if self.classifier_tokenizer is None or self.classifier_encoder is None:
|
||||
self.classifier_tokenizer = AutoTokenizer.from_pretrained(self.classifier_encoder_name)
|
||||
self.classifier_encoder = AutoModel.from_pretrained(self.classifier_encoder_name).to(self.device)
|
||||
self.classifier_encoder.eval()
|
||||
return self._encode(texts, self.classifier_tokenizer, self.classifier_encoder)
|
||||
|
||||
def _encode_embedding(self, texts: list[str]) -> np.ndarray:
|
||||
if self.embedding_tokenizer is None or self.embedding_model is None:
|
||||
self.embedding_tokenizer = AutoTokenizer.from_pretrained(self.embedding_model_name)
|
||||
self.embedding_model = AutoModel.from_pretrained(self.embedding_model_name).to(self.device)
|
||||
self.embedding_model.eval()
|
||||
return self._encode(texts, self.embedding_tokenizer, self.embedding_model)
|
||||
|
||||
def _encode(self, texts: list[str], tokenizer: Any, model: Any) -> np.ndarray:
|
||||
batches: list[np.ndarray] = []
|
||||
batch_size = 16
|
||||
for start in range(0, len(texts), batch_size):
|
||||
batch = texts[start : start + batch_size]
|
||||
encoded = tokenizer(
|
||||
batch,
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=512,
|
||||
return_tensors="pt",
|
||||
).to(self.device)
|
||||
with torch.no_grad():
|
||||
output = model(**encoded)
|
||||
pooled = self._mean_pool(output.last_hidden_state, encoded["attention_mask"])
|
||||
pooled = F.normalize(pooled, p=2, dim=1)
|
||||
batches.append(pooled.detach().cpu().numpy())
|
||||
return np.vstack(batches)
|
||||
|
||||
@staticmethod
|
||||
def _mean_pool(last_hidden_state: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
|
||||
mask = attention_mask.unsqueeze(-1).expand(last_hidden_state.size()).float()
|
||||
summed = torch.sum(last_hidden_state * mask, dim=1)
|
||||
counts = torch.clamp(mask.sum(dim=1), min=1e-9)
|
||||
return summed / counts
|
||||
|
||||
@staticmethod
|
||||
def _match_space(selected: str, current: str) -> bool:
|
||||
selected = (selected or "general").strip().lower()
|
||||
current = (current or "general").strip().lower()
|
||||
if selected in {"", "general", "all"}:
|
||||
return True
|
||||
return current in {selected, "general"}
|
||||
|
||||
@staticmethod
|
||||
def _build_snippet(content: str, query: str) -> str:
|
||||
text = " ".join(content.split())
|
||||
if not text:
|
||||
return ""
|
||||
terms = [term for term in query.replace(",", " ").replace("。", " ").split() if term]
|
||||
hit = next((term for term in terms if term in text), "")
|
||||
if not hit:
|
||||
return text[:180]
|
||||
pos = text.find(hit)
|
||||
start = max(0, pos - 60)
|
||||
end = min(len(text), pos + 120)
|
||||
return text[start:end]
|
||||
|
||||
|
||||
engine = KnowledgeEngine()
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict[str, Any]:
|
||||
return {"status": "ok", "data": engine.health()}
|
||||
|
||||
|
||||
@app.post("/classify")
|
||||
def classify(req: ClassifyRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"data": engine.classify(req.query)}
|
||||
except Exception as exc: # pragma: no cover - runtime guard
|
||||
raise HTTPException(status_code=500, detail=f"classify failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/search")
|
||||
def search(req: SearchRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"data": {"items": engine.search(req.query, req.space_key, max(1, min(req.top_k, 20))) }}
|
||||
except Exception as exc: # pragma: no cover - runtime guard
|
||||
raise HTTPException(status_code=500, detail=f"search failed: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/index/rebuild")
|
||||
def rebuild_index(req: RebuildIndexRequest) -> dict[str, Any]:
|
||||
try:
|
||||
return {"data": engine.rebuild_index(req.index_dir, req.items)}
|
||||
except Exception as exc: # pragma: no cover - runtime guard
|
||||
raise HTTPException(status_code=500, detail=f"rebuild failed: {exc}") from exc
|
||||
@@ -0,0 +1,6 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn==0.35.0
|
||||
numpy==2.3.2
|
||||
faiss-cpu==1.12.0
|
||||
torch==2.8.0
|
||||
transformers==4.55.4
|
||||
@@ -0,0 +1,299 @@
|
||||
param(
|
||||
[switch]$Force,
|
||||
[switch]$SkipPreflight,
|
||||
[switch]$SkipSmoke,
|
||||
[switch]$SkipBackendBuild,
|
||||
[switch]$SkipFrontendInstall,
|
||||
[switch]$SkipKnowledgeService,
|
||||
[string]$BindHost = "127.0.0.1"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$BackendPort = 10232
|
||||
$FrontendPort = 10231
|
||||
$KnowledgeServicePort = 10233
|
||||
$RepoDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$AppDir = Join-Path $RepoDir "eai_ap_app"
|
||||
$BackendDir = Join-Path $AppDir "backend-go"
|
||||
$FrontendDir = Join-Path $AppDir "frontend"
|
||||
$KnowledgeServiceDir = Join-Path $AppDir "knowledge_service"
|
||||
$BackendBin = Join-Path $BackendDir "bin\eaisalestrain-server.exe"
|
||||
$DebugLogDir = Join-Path $RepoDir "debuglog"
|
||||
$null = New-Item -ItemType Directory -Force -Path $DebugLogDir
|
||||
|
||||
$RunId = Get-Date -Format "yyyyMMdd_HHmmss"
|
||||
$BackendLog = Join-Path $DebugLogDir "backend_$RunId.log"
|
||||
$BackendErrLog = Join-Path $DebugLogDir "backend_$RunId.err.log"
|
||||
$FrontendLog = Join-Path $DebugLogDir "frontend_$RunId.log"
|
||||
$FrontendErrLog = Join-Path $DebugLogDir "frontend_$RunId.err.log"
|
||||
$KnowledgeLog = Join-Path $DebugLogDir "knowledge_$RunId.log"
|
||||
$KnowledgeErrLog = Join-Path $DebugLogDir "knowledge_$RunId.err.log"
|
||||
$BackendPidFile = Join-Path $DebugLogDir "backend.pid"
|
||||
$FrontendPidFile = Join-Path $DebugLogDir "frontend.pid"
|
||||
$KnowledgePidFile = Join-Path $DebugLogDir "knowledge.pid"
|
||||
|
||||
$KnownProcessNames = @("eaisalestrain-server", "node", "npm", "npm.cmd", "python", "python3")
|
||||
$StartedProcesses = @()
|
||||
|
||||
function Write-Step {
|
||||
param([string]$Message)
|
||||
Write-Host ""
|
||||
Write-Host "==> $Message" -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
function Test-CommandExists {
|
||||
param([string]$Name)
|
||||
return $null -ne (Get-Command $Name -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
function Get-ProcessNameSafe {
|
||||
param([object]$Process)
|
||||
if ($null -ne $Process) {
|
||||
return $Process.ProcessName
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
function Get-PortPids {
|
||||
param([int]$Port)
|
||||
try {
|
||||
$connections = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop
|
||||
return @($connections | Select-Object -ExpandProperty OwningProcess -Unique)
|
||||
} catch {
|
||||
return @()
|
||||
}
|
||||
}
|
||||
|
||||
function Resolve-Port {
|
||||
param(
|
||||
[int]$Port,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
$pids = @(Get-PortPids -Port $Port)
|
||||
if ($pids.Count -eq 0) {
|
||||
Write-Host (" [OK] {0} :{1} is free" -f $Label, $Port)
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host (" [!] {0} :{1} is in use:" -f $Label, $Port)
|
||||
foreach ($portPid in $pids) {
|
||||
$proc = Get-Process -Id $portPid -ErrorAction SilentlyContinue
|
||||
$name = Get-ProcessNameSafe -Process $proc
|
||||
Write-Host (" - {0} (PID {1})" -f $name, $portPid)
|
||||
}
|
||||
|
||||
if (-not $Force) {
|
||||
throw ("{0} port :{1} is in use. Stop it manually or rerun with -Force." -f $Label, $Port)
|
||||
}
|
||||
|
||||
foreach ($portPid in $pids) {
|
||||
$proc = Get-Process -Id $portPid -ErrorAction SilentlyContinue
|
||||
$name = Get-ProcessNameSafe -Process $proc
|
||||
if (($null -ne $proc) -and (($KnownProcessNames -contains $proc.ProcessName) -or ($name -like "eaisalestrain-server*"))) {
|
||||
Stop-Process -Id $portPid -Force
|
||||
Write-Host (" stopped {0} (PID {1})" -f $name, $portPid)
|
||||
} else {
|
||||
throw ("Port :{0} is used by a non-project process ({1} / PID {2}). Aborting." -f $Port, $name, $portPid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Wait-Http {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$Label,
|
||||
[int]$TimeoutSec = 60
|
||||
)
|
||||
|
||||
$deadline = (Get-Date).AddSeconds($TimeoutSec)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $Url -TimeoutSec 2 | Out-Null
|
||||
Write-Host (" [OK] {0} is ready: {1}" -f $Label, $Url)
|
||||
return
|
||||
} catch {
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
}
|
||||
|
||||
throw ("{0} did not become ready within {1}s: {2}" -f $Label, $TimeoutSec, $Url)
|
||||
}
|
||||
|
||||
function Start-TrackedProcess {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$FilePath,
|
||||
[Parameter(Mandatory = $true)][string]$WorkingDirectory,
|
||||
[string[]]$ArgumentList = @(),
|
||||
[Parameter(Mandatory = $true)][string]$StdoutPath,
|
||||
[Parameter(Mandatory = $true)][string]$StderrPath
|
||||
)
|
||||
|
||||
$startParams = @{
|
||||
FilePath = $FilePath
|
||||
WorkingDirectory = $WorkingDirectory
|
||||
RedirectStandardOutput = $StdoutPath
|
||||
RedirectStandardError = $StderrPath
|
||||
PassThru = $true
|
||||
}
|
||||
if ($null -ne $ArgumentList -and $ArgumentList.Count -gt 0) {
|
||||
$startParams.ArgumentList = $ArgumentList
|
||||
}
|
||||
|
||||
$proc = Start-Process @startParams
|
||||
|
||||
$script:StartedProcesses += $proc
|
||||
return $proc
|
||||
}
|
||||
|
||||
function Cleanup-StartedProcesses {
|
||||
foreach ($proc in $StartedProcesses) {
|
||||
if (($null -ne $proc) -and (-not $proc.HasExited)) {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "==> eaisalestrain Windows dev launcher"
|
||||
Write-Host (" repo : {0}" -f $RepoDir)
|
||||
Write-Host (" backend : {0} (port {1})" -f $BackendDir, $BackendPort)
|
||||
Write-Host (" frontend : {0} (port {1})" -f $FrontendDir, $FrontendPort)
|
||||
Write-Host (" knowledge: {0} (port {1})" -f $KnowledgeServiceDir, $KnowledgeServicePort)
|
||||
Write-Host (" bind host: {0}" -f $BindHost)
|
||||
|
||||
if (-not $SkipPreflight) {
|
||||
Write-Step "Preflight"
|
||||
if (-not (Test-Path $BackendDir)) { throw ("Missing backend directory: {0}" -f $BackendDir) }
|
||||
if (-not (Test-Path $FrontendDir)) { throw ("Missing frontend directory: {0}" -f $FrontendDir) }
|
||||
if (-not (Test-CommandExists "go")) { throw "Missing go on PATH." }
|
||||
if (-not (Test-CommandExists "node")) { throw "Missing node on PATH." }
|
||||
if (-not (Test-CommandExists "npm.cmd")) { throw "Missing npm on PATH." }
|
||||
if (-not $SkipKnowledgeService) {
|
||||
if (-not (Test-CommandExists "python")) { throw "Missing python on PATH." }
|
||||
if (-not (Test-Path $KnowledgeServiceDir)) { throw ("Missing knowledge service directory: {0}" -f $KnowledgeServiceDir) }
|
||||
}
|
||||
Write-Host " [OK] Preflight passed"
|
||||
} else {
|
||||
Write-Step "Preflight skipped"
|
||||
}
|
||||
|
||||
Write-Step "Prepare dependencies"
|
||||
if (-not $SkipBackendBuild) {
|
||||
Write-Host " [.] Build backend"
|
||||
Push-Location $BackendDir
|
||||
try {
|
||||
& go build -o $BackendBin ".\cmd\server"
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} elseif (-not (Test-Path $BackendBin)) {
|
||||
throw ("Backend binary not found: {0}" -f $BackendBin)
|
||||
}
|
||||
|
||||
$NodeModulesDir = Join-Path $FrontendDir "node_modules"
|
||||
if (-not (Test-Path $NodeModulesDir)) {
|
||||
if ($SkipFrontendInstall) {
|
||||
throw "node_modules is missing and frontend install was skipped."
|
||||
}
|
||||
|
||||
Write-Host " [.] Install frontend dependencies"
|
||||
Push-Location $FrontendDir
|
||||
try {
|
||||
& npm.cmd install
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Host " [OK] Frontend dependencies already installed"
|
||||
}
|
||||
|
||||
Write-Step "Check ports"
|
||||
if (-not $SkipKnowledgeService) {
|
||||
Resolve-Port -Port $KnowledgeServicePort -Label "knowledge service"
|
||||
}
|
||||
Resolve-Port -Port $BackendPort -Label "backend"
|
||||
Resolve-Port -Port $FrontendPort -Label "frontend"
|
||||
|
||||
Write-Step "Launch services"
|
||||
if (-not $SkipKnowledgeService) {
|
||||
$env:KNOWLEDGE_SERVICE_URL = ("http://127.0.0.1:{0}" -f $KnowledgeServicePort)
|
||||
$env:KNOWLEDGE_INDEX_DIR = Join-Path $BackendDir "data\faiss"
|
||||
if (-not $env:KNOWLEDGE_BERT_MODEL) {
|
||||
$env:KNOWLEDGE_BERT_MODEL = "bert-base-chinese"
|
||||
}
|
||||
$knowledgeProc = Start-TrackedProcess `
|
||||
-FilePath "python" `
|
||||
-WorkingDirectory $KnowledgeServiceDir `
|
||||
-ArgumentList @("-m", "uvicorn", "main:app", "--host", "127.0.0.1", "--port", [string]$KnowledgeServicePort) `
|
||||
-StdoutPath $KnowledgeLog `
|
||||
-StderrPath $KnowledgeErrLog
|
||||
Set-Content -Path $KnowledgePidFile -Value $knowledgeProc.Id
|
||||
Wait-Http -Url ("http://127.0.0.1:{0}/health" -f $KnowledgeServicePort) -Label "knowledge service"
|
||||
}
|
||||
|
||||
$env:PORT = [string]$BackendPort
|
||||
$backendProc = Start-TrackedProcess `
|
||||
-FilePath $BackendBin `
|
||||
-WorkingDirectory $BackendDir `
|
||||
-StdoutPath $BackendLog `
|
||||
-StderrPath $BackendErrLog
|
||||
Set-Content -Path $BackendPidFile -Value $backendProc.Id
|
||||
Wait-Http -Url ("http://127.0.0.1:{0}/api/health" -f $BackendPort) -Label "backend"
|
||||
|
||||
$frontendProc = Start-TrackedProcess `
|
||||
-FilePath "npm.cmd" `
|
||||
-WorkingDirectory $FrontendDir `
|
||||
-ArgumentList @("run", "dev", "--", "--host", $BindHost, "--port", [string]$FrontendPort) `
|
||||
-StdoutPath $FrontendLog `
|
||||
-StderrPath $FrontendErrLog
|
||||
Set-Content -Path $FrontendPidFile -Value $frontendProc.Id
|
||||
|
||||
if ($BindHost -eq "0.0.0.0") {
|
||||
$frontendHealthUrl = ("http://localhost:{0}/" -f $FrontendPort)
|
||||
} else {
|
||||
$frontendHealthUrl = ("http://{0}:{1}/" -f $BindHost, $FrontendPort)
|
||||
}
|
||||
Wait-Http -Url $frontendHealthUrl -Label "frontend"
|
||||
|
||||
if (-not $SkipSmoke) {
|
||||
Write-Step "Smoke login"
|
||||
$payload = @{ username = "admin"; password = "admin123" } | ConvertTo-Json
|
||||
try {
|
||||
$null = Invoke-RestMethod `
|
||||
-Uri ("http://127.0.0.1:{0}/api/auth/login" -f $BackendPort) `
|
||||
-Method Post `
|
||||
-ContentType "application/json" `
|
||||
-Body $payload `
|
||||
-TimeoutSec 5
|
||||
Write-Host " [OK] admin login passed"
|
||||
} catch {
|
||||
Write-Warning ("admin login failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Done"
|
||||
Write-Host ("Frontend : http://localhost:{0}/" -f $FrontendPort)
|
||||
Write-Host ("Backend : http://127.0.0.1:{0}/" -f $BackendPort)
|
||||
Write-Host ("Backend log : {0}" -f $BackendLog)
|
||||
Write-Host ("Backend err : {0}" -f $BackendErrLog)
|
||||
Write-Host ("Frontend log : {0}" -f $FrontendLog)
|
||||
Write-Host ("Frontend err : {0}" -f $FrontendErrLog)
|
||||
if (-not $SkipKnowledgeService) {
|
||||
Write-Host ("Knowledge log: {0}" -f $KnowledgeLog)
|
||||
Write-Host ("Knowledge err: {0}" -f $KnowledgeErrLog)
|
||||
Write-Host ("PIDs : knowledge={0} backend={1} frontend={2}" -f $knowledgeProc.Id, $backendProc.Id, $frontendProc.Id)
|
||||
} else {
|
||||
Write-Host ("PIDs : backend={0} frontend={1}" -f $backendProc.Id, $frontendProc.Id)
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host "Examples:" -ForegroundColor Yellow
|
||||
Write-Host " powershell -ExecutionPolicy Bypass -File .\start_dev_10231_10232.ps1"
|
||||
Write-Host " powershell -ExecutionPolicy Bypass -File .\start_dev_10231_10232.ps1 -Force"
|
||||
Write-Host " powershell -ExecutionPolicy Bypass -File .\start_dev_10231_10232.ps1 -SkipSmoke"
|
||||
} catch {
|
||||
Cleanup-StartedProcesses
|
||||
throw
|
||||
}
|
||||
Reference in New Issue
Block a user