feat: 同步知识库与工作台相关改动

This commit is contained in:
Jackzhou
2026-08-24 10:40:15 +08:00
parent 8d0588827d
commit 4f33b036d1
50 changed files with 8034 additions and 273 deletions
+52
View File
@@ -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)
+2
View File
@@ -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 的自由度,再想办法收回来。**
而应该是:
**先把数字员工做成标准化产品包,再在这个底座上逐步开放行业包和企业定制。**
因此,当前阶段的产品主线正式定为:
> **先通用,后定制;先产品包,后自由平台。**
这是后续所有页面、模型、任务系统、交付系统、模板系统的上位约束。
+4
View File
@@ -8,6 +8,10 @@ backend-go/data/
frontend/node_modules/
frontend/dist/
# 本地知识服务
knowledge_service/data/
knowledge_service/__pycache__/
# 环境变量
.env
backend-go/.env
+4 -2
View File
@@ -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
}
+92 -1
View File
@@ -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"
}
// ──────────────────────────────────────────────
// 配置解析(兼容旧接口)
// ──────────────────────────────────────────────
+242 -28
View File
@@ -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
}
+20 -1
View File
@@ -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
}
+36 -1
View File
@@ -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: 演示应优先使用已审批且口径统一的资料。
+51 -3
View File
@@ -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
}
}
+40
View File
@@ -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')
}
+5
View File
@@ -0,0 +1,5 @@
import http from './http'
export function getWorkbenchOverview() {
return http.get('/workbench/overview')
}
+37
View File
@@ -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)
}
+44 -18
View File
@@ -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']
}
+71 -4
View File
@@ -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>
+1
View File
@@ -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,
}
})
+18 -2
View File
@@ -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) {
+80 -26
View File
@@ -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
+385
View File
@@ -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
+299
View File
@@ -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
}