Compare commits

...

200 Commits

Author SHA1 Message Date
Coltea 1c30e16fd6
Merge pull request #1576 from KuaiYu95/fe/fallback
fix: 修复 diff 组件导致前台页面无法展示的问题
2025-11-28 19:08:20 +08:00
yu.kuai cd804dbddd fix: 修复 diff 组件导致前台页面无法展示的问题 2025-11-28 19:06:41 +08:00
Coltea 574fc999a4
Merge pull request #1575 from KuaiYu95/fe/iframe
fix: 修复 iframe 渲染问题
2025-11-28 18:32:06 +08:00
yu.kuai ab24f7b78b fix: 修复 iframe 渲染问题 2025-11-28 18:27:41 +08:00
Coltea 5347726535
Merge pull request #1574 from coltea/fix-update-app
fix app update copyrightInfo
2025-11-28 18:10:18 +08:00
coltea 7c03aa7f6d fix app update copyrightInfo 2025-11-28 18:08:50 +08:00
Coltea a5e649a1bf
Merge pull request #1573 from KuaiYu95/fix/table
修复 iframe 水平对齐不生效的问题、解决控制台editor diff 组件重复扩展的警告
2025-11-28 17:50:40 +08:00
Coltea 59afc05284
Merge pull request #1572 from guanweiwang/feature/weichat_bot
fix: 侧边栏展示不齐问题, 重置提示词样式穿透问题
2025-11-28 17:50:31 +08:00
yu.kuai 1dd222f759 fix: 修复 iframe 水平对齐不生效的问题
fix: 解决控制台editor diff 组件重复扩展的警告
2025-11-28 17:48:34 +08:00
Gavan 409b69dda1 fix: 侧边栏展示不齐问题, 重置提示词样式穿透问题 2025-11-28 17:40:57 +08:00
Coltea a76dbc4d62
Merge pull request #1570 from coltea/feat-wecom-setting
feat 企微机器人高级配置
2025-11-28 17:17:29 +08:00
coltea 94da5431ad feat wecom advanced setting 2025-11-28 17:14:15 +08:00
Coltea 4e2db886e2
Merge pull request #1568 from jiangwel/feat-more-date-report
feat(telemetry): 添加每日数据上报功能并扩展客户端配置
2025-11-28 17:09:43 +08:00
jiangwel 5c901aa6b3 feat(telemetry): 添加每日数据上报功能并扩展客户端配置
扩展telemetry客户端以支持每日数据上报,包括知识库数量、模型配置模式、管理员登录状态、文档数量、各类型对话次数等指标
新增MCPRepository用于获取MCP调用次数
修改配置添加pandawiki_env字段用于区分环境
在NodeRepository和ConversationRepository中添加新方法支持数据统计
2025-11-28 17:04:00 +08:00
Coltea f80d240944
Merge pull request #1569 from guanweiwang/feature/weichat_bot
feat: 企微机器人问答设置
2025-11-28 17:00:51 +08:00
Coltea 189aa2e286
Merge pull request #1571 from KuaiYu95/fix/table
修复嵌套表格问题、优化 iframe 体验、支持插入 B 站视频
2025-11-28 16:34:58 +08:00
yu.kuai a8999c3dba fix: 修复了嵌套表格 handle & selection overlay 不显示的问题
feat: 支持删除表格
feat: 添加插入 bilibili 视频入口
pref: Iframe 编辑体验优化
2025-11-28 16:09:05 +08:00
Gavan 5ba454e084 feat: 企微机器人问答设置 2025-11-28 15:32:18 +08:00
Coltea 24a5a1574b
Merge pull request #1566 from KuaiYu95/fe/stopPropagation
fix: 编辑器快捷键阻止冒泡事件问题
2025-11-26 18:35:45 +08:00
yu.kuai 9f9e6388fe fix: 编辑器快捷键阻止冒泡事件问题 2025-11-26 18:32:35 +08:00
Coltea 5ba7c6b73a
Merge pull request #1565 from KuaiYu95/fix/editor
修复了编辑器的一些问题
2025-11-26 17:57:41 +08:00
yu.kuai 9350e631a4 fix: 表格中已合并单元格中间无法正常插入行/列的问题
fix: 移除编辑器扩展不必要的 z-index
fix: 修复无序列表图标移位的问题
fix: 修复任务列表中子任务受父任务影响的问题
fix: 代码块样式更新
2025-11-26 17:52:45 +08:00
Coltea 039b6b3061
Merge pull request #1563 from coltea/update-pro2
pro update
2025-11-26 15:26:51 +08:00
coltea 9fa8ad5177 pro update 2025-11-26 15:24:08 +08:00
Coltea 2d7c20c799
Merge pull request #1560 from coltea/feat-pv
feat: pv 支持浏览量展示
2025-11-26 14:56:01 +08:00
coltea 4a980b92c5 feat: pv 2025-11-26 14:35:59 +08:00
Coltea fe2d70bfa9
Merge pull request #1559 from KuaiYu95/fix/editor
修复了编辑器一些问题
2025-11-25 18:15:05 +08:00
yu.kuai c9f3a61e86 fix: 导出 markdown 错误
fix: 图片预览底部呈现工具栏
fix: 表格 100% 宽度出现滚动条的问题
2025-11-25 18:04:29 +08:00
Coltea 7b75f1bf55
Merge pull request #1558 from jiangwel/chore-mcp-placeholder
docs(设置页面): 更新MCP Tool输入框的默认值提示文本
2025-11-25 17:36:12 +08:00
jiangwel 07646f0823 chore: 设置默认的tool_name和tool_desc值
设置CardMCP组件中tool_name和tool_desc的默认值,确保功能正常运行
2025-11-25 16:26:52 +08:00
Coltea 63a8d8a743
Merge pull request #1557 from guanweiwang/feature/create_wiki_model
pref: 创建wiki站点,模型配置保存按钮合并到下一步按钮
2025-11-25 16:10:19 +08:00
Coltea 587c2842be
Merge pull request #1555 from coltea/feat-sitemap
feat: auto sitemap
2025-11-25 16:09:44 +08:00
coltea fd34bb4c55 feat: auto sitemap 2025-11-25 16:04:09 +08:00
Gavan 46afba778f pref: 创建wiki站点,模型配置保存按钮合并到下一步按钮 2025-11-25 11:38:55 +08:00
Coltea 79234c2394
Merge pull request #1547 from jiangwel/feat-mcp
feat: mcp server
2025-11-24 20:40:36 +08:00
jiangwel 67e796dbb7 feat(chat): 添加 ChatRagOnlyRequset 结构并重构 ChatRagOnly 逻辑
重构 ChatRagOnly 方法,使用新的 ChatRagOnlyRequset 结构作为参数
简化聊天逻辑,直接获取并返回相关文档内容
移除不必要的会话管理代码
2025-11-24 20:21:17 +08:00
Coltea 55c563cc48
Merge pull request #1554 from KuaiYu95/fix/key
fix: 修复ctrlKey+b 富文本模式下加粗同时收起展示目录的问题
2025-11-24 19:02:33 +08:00
yu.kuai cfdc546d20 fix: 修复ctrlKey+b 富文本模式下加粗同时收起展示目录的问题 2025-11-24 18:56:45 +08:00
Coltea 7f58308dae
Merge pull request #1553 from KuaiYu95/fe/editor-update
表格功能丰富,优化编辑器体验
2025-11-24 18:25:13 +08:00
yu.kuai f34864621a feat: 表格功能丰富
----
1. hover 展示 table handle,点击可对表格的行/列进行操作
2. hover 展示 insert button,点击可相对当前行左右插入行,当前列左右插入列
3. 多选选中单元格支持对选中单元格设置
4. hover 最后一行/列展示 insert handle,点击可最后一行/列后面插入行/列
----

fix: 修复了编辑器的一些问题
----
1. 修复了编辑模式图片不能预览的问题
2. 修复了设置文字大小后,行高未能自动变化的问题
3. 修复了创建标题,输入拼音被打断 IME 问题
4. 修复了编辑页面编辑器聚焦时 cmd+s 保存无反应
5. 修复了空格缩进导致代码块不展示的问题
6. 视频支持设置自适应宽度,四角拖拽改变宽高,支持水平对齐设置
----
2025-11-24 18:14:14 +08:00
jiangwel da9039ff37 feat: mcp server 2025-11-24 15:48:47 +08:00
Coltea 797e0c033d
Merge pull request #1549 from guanweiwang/feature/pref
pref: 优化统计 tab 展示
2025-11-24 14:18:58 +08:00
Gavan ea6f958d24 pref: 优化统计 tab 展示 2025-11-21 17:31:50 +08:00
Coltea d3502e105a
Merge pull request #1546 from coltea/fix-conversation-copyright
fix: conversation copyright valid
2025-11-21 15:25:41 +08:00
coltea df4937aeb2 fix: conversation copyright valid 2025-11-21 15:22:57 +08:00
Coltea d8c869198e
Merge pull request #1542 from coltea/feat-copyright
Feat 前台问答版权配置 && 支持超级管理员修改普通用户密码
2025-11-20 18:19:44 +08:00
Coltea f04f96d894
Merge pull request #1544 from guanweiwang/feature/pref
feat: 添加智能问答版权信息, 修改超级管理员权限, 优化 banner 移动端样式, 优化 header 移动端样式
2025-11-20 18:19:28 +08:00
Gavan 74e87540e0 feat: 添加智能问答版权信息, 修改超级管理员权限, 优化 banner 移动端样式, 优化 header 移动端样式 2025-11-20 18:15:30 +08:00
coltea 4a011aa1d2 feat conversation setting 2025-11-20 16:12:03 +08:00
coltea acf17e94b2 feat admin reset user password 2025-11-20 15:46:44 +08:00
Coltea 59ca885518
Merge pull request #1539 from KuaiYu95/fe/doc
文档前台查看功能
2025-11-19 18:48:14 +08:00
Coltea 0e64ff946f
Merge pull request #1540 from coltea/feat-node-list-publisher
feat 文档相关页面支持直接跳转至前台
2025-11-19 18:47:43 +08:00
coltea 19e6a66809 feat node list publish id 2025-11-19 18:43:10 +08:00
yu.kuai c15272aeb2 feat: 分享文档 2025-11-19 18:39:18 +08:00
yu.kuai 32ed999b48 fix: 挂件 esc 快捷关闭 2025-11-19 17:24:15 +08:00
Coltea 98e4a917e0
Merge pull request #1538 from guanweiwang/feature/icon
pref: 统一图标地址, 去除无用文件
2025-11-19 17:13:59 +08:00
Gavan 6e5f780771 pref: 统一图标地址, 去除无用文件 2025-11-19 17:08:28 +08:00
Coltea 95bd31b8ed
Merge pull request #1535 from KuaiYu95/fe/widget4
feat: 添加版本控制
2025-11-18 18:16:13 +08:00
yu.kuai 8457544a30 feat: 添加版本控制 2025-11-18 18:09:47 +08:00
Coltea 55abd7452c
Merge pull request #1532 from KuaiYu95/fe/widget3
挂件优化
2025-11-18 17:18:04 +08:00
Coltea 6224d713b6
Merge pull request #1534 from coltea/feat-widget-copyright
feat widget copyright
2025-11-18 17:17:14 +08:00
coltea 3f93246d85 feat widget copyright 2025-11-18 17:02:41 +08:00
yu.kuai 3196f2d130 feat: 挂件新增版权配置 2025-11-18 16:38:10 +08:00
yu.kuai 4a9a1ff78b fix: 修复挂件点击位置偏移的问题
fix: markdown 格式下适配 附件和链接
2025-11-18 10:46:18 +08:00
Coltea e80cbf9f47
Merge pull request #1530 from guanweiwang/hotfix/bug
fix: 暗黑模式样式, 优化首屏渲染闪烁
2025-11-17 18:56:54 +08:00
Gavan 76f9878d55 fix: 暗黑模式样式, 优化首屏渲染闪烁 2025-11-17 18:51:05 +08:00
Coltea eb662208dc
Merge pull request #1528 from guanweiwang/feature/catalog
feat: 支持文档目录页
2025-11-17 18:23:38 +08:00
Coltea 0dcd65961b
Merge pull request #1529 from KuaiYu95/fe/widget-2
feat: 挂件支持暗黑模式
2025-11-17 18:16:37 +08:00
Coltea dafb8de41e
Merge pull request #1527 from coltea/fix-folder-detail
feat 前台文档目录页 && 前台问答支持流程图
2025-11-17 18:12:26 +08:00
coltea de86adf90f feat html2md data-code 2025-11-17 18:11:35 +08:00
yu.kuai 4c0990df8c feat: 挂件支持暗黑模式
fix: 粘贴 excel 复制的 表格被认为是图片
2025-11-17 17:43:31 +08:00
Gavan 87d24e06c4 feat: 目录可以点击 2025-11-17 17:21:20 +08:00
coltea b1b354b785 fix share folder detail 2025-11-17 14:16:46 +08:00
xiaomakuaiz e9d30eb3d4
Merge pull request #1512 from xiaomakuaiz/fix/openai-api-compatibility
修复 /share/v1/chat/completions 接口 OpenAI 兼容性问题
2025-11-14 20:36:32 +08:00
xiaomakuaiz b8f2b95f22 完善 OpenAI API 兼容性:增强 OpenAIContentPart 结构体
- 添加 ImageURL 字段支持 image_url 类型的内容部分
- 新增 OpenAIContentPartURL 结构体处理嵌套的 URL 对象
- 修复 MarshalJSON 使用指针接收器以保持与 UnmarshalJSON 的一致性
- 修复 String 方法的空格逻辑,使用 builder.Len() 替代索引判断

修复了测试用例中 image_url 数据被静默忽略的问题,确保正确解析包含图片 URL 的混合内容。

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:41:37 +08:00
xiaomakuaiz 98c602819f lint: make golangci-lint happy
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:41:37 +08:00
xiaomakuaiz be163a5f80 优化 OpenAI API 兼容性实现
根据 PR #1512 代码审查意见,进行以下优化:

**安全性修复 (P0)**
- 修复 JSON 注入安全漏洞:移除不安全的字符串拼接构造 JSON 的方式
- 添加 NewStringContent 和 NewArrayContent 构造函数,直接构造对象而非通过 JSON 序列化

**性能优化**
- String() 方法使用 strings.Builder 替代字符串拼接,提升性能
- 多个 text 部分之间添加空格分隔符,避免语义错误

**测试覆盖**
- 添加完整的单元测试覆盖 MessageContent 类型
- 测试字符串格式解析(包括特殊字符、Unicode、换行符等)
- 测试数组格式解析(单个/多个 text 部分、混合类型)
- 测试无效输入处理
- 测试序列化/反序列化往返

**代码改进**
- 更新 handler/share/chat.go 使用安全的构造函数
- 所有测试通过验证

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>

Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:26:29 +08:00
xiaomakuaiz 4c03078103 修复 /share/v1/chat/completions 接口 OpenAI 兼容性问题
现有的 OpenAI API 兼容接口不支持标准的 OpenAI messages 格式,特别是当 content 字段为数组格式时会解析失败。

1. **扩展 MessageContent 类型**:实现自定义的 JSON 序列化/反序列化,支持 content 既可以是字符串,也可以是包含 text/type 的对象数组
2. **添加 stream_options 支持**:支持 OpenAI 标准的 stream_options 参数(如 include_usage)
3. **更新响应格式**:在流式响应中添加 usage 字段支持,符合 OpenAI 标准

- `domain/openai.go`:
  - 新增 `MessageContent` 类型及其 JSON 序列化方法
  - 新增 `OpenAIStreamOptions` 结构体
  - 更新 `OpenAIMessage.Content` 类型从 string 改为 *MessageContent
  - 在流式响应中添加 usage 字段

- `handler/share/chat.go`:
  - 更新消息内容提取逻辑,使用 MessageContent.String() 方法
  - 修复流式和非流式响应中的 content 序列化

- 已通过单元测试验证 MessageContent 可以正确解析字符串和数组格式
- 编译通过,无语法错误

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>

Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:26:29 +08:00
Coltea 2b372fd81d
Merge pull request #1521 from coltea/fix-widget-search-path
fix widget search path
2025-11-14 19:01:45 +08:00
Coltea 74c6ba131d
Merge pull request #1520 from guanweiwang/hotfix/bug
fix: 修复 浏览器将http自动升级为 https
2025-11-14 19:01:32 +08:00
Coltea 6c77246e34
Merge pull request #1522 from KuaiYu95/fe/apiurl
fix: api url
2025-11-14 19:01:09 +08:00
coltea d347482d31 fix widget search path 2025-11-14 19:00:15 +08:00
yu.kuai 92443dfafe fix: api url 2025-11-14 18:59:52 +08:00
Gavan 1dd2d93010 fix: 修复 浏览器将http自动升级为 https 2025-11-14 18:49:25 +08:00
Coltea 54bf1fd108
Merge pull request #1518 from guanweiwang/feature/more_feat
feat: 退出登录 & 对话记录 & md 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖
2025-11-14 18:34:28 +08:00
Coltea c23ce398d7
Merge pull request #1517 from coltea/feat-widget
feat 新版挂件机器人 && 用户登出 &&  文档目录页
2025-11-14 18:34:14 +08:00
coltea 2c90932f60 feat node detail folder 2025-11-14 18:33:30 +08:00
Gavan 035ce0284d feat: 退出登录 & 对话记录 & d 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖 2025-11-14 18:30:49 +08:00
Coltea 93559125c2
Merge pull request #1519 from KuaiYu95/fe/widget
网页挂件支持三种模式
2025-11-14 18:23:04 +08:00
yu.kuai 6c5cf256ac feat: 后台网页挂件新增配置项
feat: 前台网页挂件支持三种模式:悬浮球,侧边吸附,自定义按钮
fix: markdown 编辑器新增配置项:showAutocomplete,highlightActiveLine
fix: markdown 编辑器输入中午拼音与 placeholder 重叠
fix: 使用官方修复后的 migrateMathStrings.ts
fix: 使用官方修复后的 table-of-contents
2025-11-14 17:54:37 +08:00
coltea 23bdfc3d50 feat widget search 2025-11-14 15:53:21 +08:00
coltea 9008c537cd feat logout 2025-11-14 10:44:00 +08:00
Coltea 63a4a964b1
Merge pull request #1516 from guanweiwang/feature/perm
feat: 权限
2025-11-13 19:00:28 +08:00
Coltea 62f2b2eaf5
Merge pull request #1514 from guanweiwang/feature/ai_search
feat: 优化 ai 搜索弹窗
2025-11-13 18:59:54 +08:00
Gavan 5c1c6368b8 feat: add expandable sections for results and thinking content in AiQaContent; update styles and remove unused hooks 2025-11-13 18:59:13 +08:00
Gavan 7282503acf feat: 权限 2025-11-13 18:55:25 +08:00
Coltea 60a4177229
Merge pull request #1511 from coltea/feat-edition
feat edition
2025-11-13 18:52:20 +08:00
coltea 2f706a6100 fix share nodes position 2025-11-13 18:40:33 +08:00
coltea febcb06654 feat edition 2025-11-13 15:50:05 +08:00
xiaomakuaiz ca323de9b2
Merge pull request #1485 from jiangwel/feat-modelsetting3
实现模型设置模式(手动/自动)切换功能
2025-11-12 17:26:06 +08:00
jiangwel 5c81d714b1 fix: 修复前端样式问题 2025-11-12 17:15:44 +08:00
jiangwel 2a123cf8b1 chore: 更新模型设置模式及相关配置,移除冗余迁移功能 2025-11-12 17:15:44 +08:00
jiangwel b1074f0956 chore: 添加模型设置模式及相关配置 2025-11-12 17:15:44 +08:00
jiangwel b98bf6664c feat: 添加关闭弹窗时未应用更改的确认提示 2025-11-12 17:15:44 +08:00
jiangwel 2b02d85fb3 feat(model): 添加模型设置模式切换功能
实现模型设置模式(手动/自动)切换功能,包括:
1. 新增SettingRepository及相关数据库操作
2. 添加模型模式相关常量定义
3. 实现模式切换API接口及业务逻辑
4. 添加数据库迁移脚本初始化模型设置
5. 更新swagger文档

feat(模型): 添加百智云自动模式配置功能

实现百智云模型自动模式的API Key和对话模型配置功能
重构相关代码,将BaiZhiCloud重命名为AutoMode
更新迁移脚本和模型设置接口

feat: 添加模型模式设置和自动模式支持

feat(系统设置): 重构系统设置模块,迁移至新表结构

- 新增system_settings表及相关迁移文件
- 将原setting表功能迁移至system_setting表
- 更新模型设置相关逻辑,支持自动模式配置
- 优化Makefile构建目标,分离pro和pro_consumer
- 更新API文档,修正模型设置相关接口路径

feat: 前端支持自动配置模型

feat: 在教程添加模型配置

feat: 在教程中添加配置模型

chore

feat: 修改前端样式

feat: 修改前端样式

feat: 前端流程跑通

feat: 跑通前端流程

feat: 优化接口
2025-11-12 17:15:44 +08:00
xiaomakuaiz 21f5a776df
Merge pull request #1509 from KuaiYu95/fe/start
修复了一些问题
2025-11-12 16:00:14 +08:00
yu.kuai 3999621981 feat: markdown 编辑模式支持拖拽/粘贴文件资源 2025-11-12 15:46:12 +08:00
yu.kuai da17b21387 fix: ts 报错问题修复 2025-11-12 11:43:07 +08:00
yu.kuai e361644f01 feat: 编辑页添加快捷键 ctrl+B 控制展开和收起目录 2025-11-12 11:23:56 +08:00
yu.kuai b8a1a130ac fix: 保存 markdown 时提交 ace 编辑器文本内容 2025-11-12 11:08:17 +08:00
yu.kuai 385c21a36c feat: 批量处理待学习文档 2025-11-12 10:39:08 +08:00
xiaomakuaiz a5c99fca95
Merge pull request #1507 from KuaiYu95/fe/table
fix: 文档页超宽可以横向滚动的问题
2025-11-11 19:37:15 +08:00
yu.kuai 7b0d71b4c5 fix: 文档页超宽可以横向滚动的问题 2025-11-11 19:33:04 +08:00
xiaomakuaiz 61688c86c9
Merge pull request #1506 from coltea/chore-pro-update
update pro
2025-11-11 18:34:13 +08:00
coltea c69e74d15d update pro 2025-11-11 18:28:59 +08:00
xiaomakuaiz 26e06e69a7
Merge pull request #1503 from coltea/fix-wecom-app-auth
feat 企业微信应用内自动登录
2025-11-11 18:22:29 +08:00
xiaomakuaiz 74e8b03975
Merge pull request #1505 from KuaiYu95/fe/error
fix: 修复无法插入链接的 bug
2025-11-11 18:22:04 +08:00
xiaomakuaiz f91a8fb38f
Merge pull request #1504 from guanweiwang/hotfix/bug
fix: 兼容企业微信客户端打开授权
2025-11-11 18:21:46 +08:00
Gavan 8e6f7ae77c fix: 兼容企业微信客户端打开授权 2025-11-11 18:13:21 +08:00
yu.kuai cefd3fe3a2 fix: 修复无法插入链接的 bug
feat: 支持 markdown 编辑器新增配置项
2025-11-11 18:05:22 +08:00
coltea 8d70727d0a fix wecom app auth 2025-11-11 17:37:26 +08:00
xiaomakuaiz 4a787a3a6c
Merge pull request #1502 from guanweiwang/pref/feat
pref: 优化评论
2025-11-11 16:18:52 +08:00
xiaomakuaiz da16f5b335
Merge pull request #1501 from guanweiwang/hotfix/bug
优化和修复bug
2025-11-11 16:18:41 +08:00
xiaomakuaiz 7e770de4df
Merge pull request #1500 from KuaiYu95/fe/mdeidtor
feat: markdwon 工具栏点击插入逻辑优化
2025-11-11 16:14:33 +08:00
Gavan 681b250296 pref: 优化评论 2025-11-11 15:52:06 +08:00
yu.kuai 3597afcc2b feat: 支持文件上传,显示上传进度
chore: 更新 tiptap 版本
2025-11-11 15:50:06 +08:00
Gavan 284392c379 pref: 添加对缓存的清理 2025-11-11 12:02:59 +08:00
Gavan 4b54cdf4ac fix: 修复无缓存图片闪烁问题, 修复图片引起的不自动滚动问题, 优化手动小距离滚动抖动问题, footer 移动端样式问题 2025-11-11 11:54:05 +08:00
yu.kuai c48b13366d feat: markdwon 工具栏点击插入逻辑优化 2025-11-11 10:38:18 +08:00
xiaomakuaiz c31f229483
Merge pull request #1499 from KuaiYu95/fe/editor-li-maker
feat: 列表项maker 左对齐
2025-11-10 21:50:59 +08:00
yu.kuai 8fad4d6262 fix: 支持上传‘’
fix: 支持上传
2025-11-10 21:36:27 +08:00
yu.kuai 712e2f8af8 feat: 列表项maker 左对齐 2025-11-10 21:23:12 +08:00
xiaomakuaiz b990b00df0
Merge pull request #1498 from KuaiYu95/fe/markdown-pref
feat: 优化 markdown 模式样式
2025-11-10 21:07:48 +08:00
yu.kuai bb8337a33e fix: ts error 2025-11-10 20:11:11 +08:00
holly 2f56ad7f6b
Update web/admin/src/pages/document/editor/edit/Wrap.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 20:04:50 +08:00
holly d7948ddecc
Update web/admin/src/pages/document/editor/edit/Wrap.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 20:03:59 +08:00
yu.kuai 9d329d21fb feat: 优化 markdown 模式样式
fix: 修复了一些编辑器问题
2025-11-10 19:37:31 +08:00
xiaomakuaiz e4dbfcb9fb
Merge pull request #1492 from KuaiYu95/fe/contribute
feat: 支持批量重新学习失败的文档
2025-11-10 19:33:56 +08:00
xiaomakuaiz 40c395400d
Merge pull request #1483 from coltea/feat-restudy
feat restudy
2025-11-10 19:33:43 +08:00
coltea b7cdec0d4a feat restudy 2025-11-10 14:13:09 +08:00
xiaomakuaiz 44126e0a11
Merge pull request #1493 from guanweiwang/hotfix/bug
fix: 拖拽组件输入文字 失焦问题
2025-11-10 12:14:36 +08:00
Gavan 3375fcb643 fix: 拖拽组件输入文字 失焦问题 2025-11-10 11:29:42 +08:00
yu.kuai ef3bae6336 feat: 支持批量重新学习失败的文档 2025-11-10 11:15:36 +08:00
xiaomakuaiz d9d3bc4911
Merge pull request #1491 from KuaiYu95/fe/editor-ui
feat: 编辑器部分组件样式更新
2025-11-10 10:37:29 +08:00
xiaomakuaiz 546062470b
Merge pull request #1478 from xiaomakuaiz/feature/summary-optimization
Feature/summary optimization
2025-11-10 10:37:10 +08:00
yu.kuai 5a3b23ac75 feat: 编辑器部分组件样式更新 2025-11-10 10:12:54 +08:00
xiaomakuaiz 35cd94e342
Merge pull request #1484 from guanweiwang/pref/landing_drag
pref: 优化和bug修复
2025-11-07 17:06:15 +08:00
monkeycode-ai f7c0fe273b Improve summary optimization with simplified aggregation
优化摘要生成逻辑:
1. 将chunk token限制从16KB提升到30KB,更合理地利用模型上下文
2. 简化摘要聚合逻辑,移除复杂的分批聚合,直接合并所有summaries
3. 保留fallback机制,当最终摘要生成失败时返回已聚合的摘要

这些改进确保了:
- 长文档能够更充分地被摘要(30KB vs 16KB)
- 代码更简洁,避免不必要的迭代聚合和额外LLM调用
- 即使最终摘要失败也能返回有用的结果

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-07 14:21:36 +08:00
Gavan 0175624c84 pref: 删除无用代码,提取公共拖拽函数,修复 footer 2025-11-07 12:00:48 +08:00
xiaomakuaiz 3032384457
Merge pull request #1479 from xiaomakuaiz/fix/qa-modal-logo
Fix QA modal logo rendering
2025-11-06 23:01:16 +08:00
monkeycode-ai 50ed0c6794 Fix QA modal logo rendering
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-06 22:44:18 +08:00
monkeycode-ai a6f4688b88 Run goimports on llm summary
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-06 20:07:30 +08:00
monkeycode-ai 575f51f0ea Simplify final summary aggregation
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-06 19:52:03 +08:00
monkeycode-ai 83f6853716 Iteratively reduce summary chunks
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-06 19:21:26 +08:00
monkeycode-ai 3dae8e8d01 Raise summary chunk limit to 16k
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-06 19:17:27 +08:00
monkeycode-ai 2e1e1848c4 Adjust summary chunking and concurrency
Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
2025-11-06 19:09:15 +08:00
xiaomakuaiz 45238c3dfa
Merge pull request #1476 from guanweiwang/feature/qa_modal
feat: 优化问答弹窗,完善 ts 配置
2025-11-06 17:43:33 +08:00
Gavan ea87d5ef7e pref: 优化 ts 配置 2025-11-06 15:37:56 +08:00
Gavan b1aefd8cfd feat: ui调整 2025-11-06 11:46:37 +08:00
xiaomakuaiz f3394a65b9
Merge pull request #1460 from KuaiYu95/fe/contribute
文档显示人员操作记录 & 网页挂件配置支持推荐问题和推荐文档 & 记录学习状态
2025-11-05 17:10:41 +08:00
xiaomakuaiz 051d2589dd
Merge pull request #1452 from coltea/feat-doc-release-user-info
feat: node release user id
2025-11-05 16:56:16 +08:00
coltea 1300682454 feat: node release user info 2025-11-05 16:51:52 +08:00
xiaomakuaiz b915dc8459
Merge pull request #1472 from guanweiwang/pref/landing
style: 样式修改
2025-11-05 16:35:05 +08:00
yu.kuai e5bad16b3c pref: code 2025-11-05 16:07:02 +08:00
yu.kuai 1a621205ef feat: 记录学习状态 2025-11-05 15:51:40 +08:00
Gavan dc14f5280b feat: 添加配色方案 2025-11-05 15:24:51 +08:00
Gavan 0ce0f9eb52 style: 样式问题 2025-11-05 14:21:33 +08:00
yu.kuai 0081f05cd9 feat: 网页挂件配置支持推荐问题和推荐文档 2025-11-05 14:07:28 +08:00
yu.kuai 1d0e4857a9 fix: 发布人员 icon 调整大小 2025-11-05 11:15:43 +08:00
yu.kuai edb7e01085 feat: 历史版本展示 markdown 文本 2025-11-05 10:40:41 +08:00
yu.kuai a4679f0ada feat: 前台支持展示更新人/创建人 2025-11-05 10:40:41 +08:00
yu.kuai 5e93e9da73 feat: 文档显示人员操作记录 2025-11-05 10:40:41 +08:00
coltea 0aeda02985 feat widget setting 2025-11-05 10:30:47 +08:00
xiaomakuaiz d630567a3c
Merge pull request #1463 from guanweiwang/pref/landing
pref: 优化主题
2025-11-04 20:03:09 +08:00
Gavan 20a0a4ded4 style: 样式问题 2025-11-04 19:56:44 +08:00
Gavan 38383b983d pref: 美化样式 2025-11-04 19:46:36 +08:00
Gavan 030a8ac25d pref: 优化主题 2025-11-04 19:13:31 +08:00
xiaomakuaiz 24d1ed1bcd
Merge pull request #1461 from guanweiwang/pref/landing
feat: 常见问题,九宫格
2025-11-04 18:55:07 +08:00
Gavan 487db8e944 feat: add DomainBlockGridConfig and DomainQuestionConfig interfaces; update BlockGridConfig and DragList components 2025-11-04 18:47:13 +08:00
Gavan 2638fcdc0c feat: 常见问题,九宫格 2025-11-04 18:32:29 +08:00
xiaomakuaiz 1aa2855e00
Merge pull request #1459 from KuaiYu95/fe/custom-md
优化 markdown 文档模式
2025-11-04 18:31:18 +08:00
xiaomakuaiz fd81e83807
Merge pull request #1462 from xiaomakuaiz/feat-contribute-content-type
Feat contribute content type
2025-11-04 18:28:52 +08:00
yu.kuai f121494416 fix: pref code 2025-11-04 18:26:21 +08:00
xiaomakuaiz b04aa2d472 feat: add content-type for contribute 2025-11-04 10:19:50 +00:00
xiaomakuaiz 69bf9cbf0e feat: add more landing components 2025-11-04 10:19:26 +00:00
yu.kuai 940282a521 feat: 后台贡献新增 markdown diff view modal 2025-11-04 16:38:22 +08:00
yu.kuai 171cc6c632 feat: 前台文档贡献支持选择文档类型 2025-11-04 16:38:22 +08:00
yu.kuai 78e5e1d70d feat: 前台编辑贡献支持 markdown 2025-11-04 16:38:22 +08:00
yu.kuai c5151ee7fe feat: 优化 markdown 编辑器
feat: subscript, supscript, alert 支持 markdown
2025-11-04 16:38:22 +08:00
yu.kuai c7f764199e feat: 编辑页支持创建最外层文件夹/文件
fix: 修复 alert 展示问题,适配 tiptap 的 markdown 代码设计
2025-11-04 16:38:22 +08:00
yu.kuai 5588a46752 feat: 自定义markdown 解析上标,下标和警告提示 2025-11-04 16:38:22 +08:00
yu.kuai 5fba15654f feat: 点击大纲标题,markdown 和 tiptap 预览同步滚动 2025-11-04 16:38:22 +08:00
xiaomakuaiz 028b872349
Merge pull request #1455 from guanweiwang/hotfix/bug
fix: 暗黑模式下, md渲染问题
2025-11-04 15:01:20 +08:00
Gavan 02d17cb48f fix: 暗黑模式下, md渲染问题 2025-11-04 14:37:48 +08:00
xiaomakuaiz c762fa3899
Merge pull request #1450 from coltea/fix-consumer-rag-info
fix consumer rag info
2025-11-03 21:16:16 +08:00
coltea 75d4149d0e fix consumer rag info 2025-11-03 21:14:04 +08:00
xiaomakuaiz ab21ad8b1f
Merge pull request #1448 from guanweiwang/pref/landing
style: 样式优化
2025-11-03 21:10:11 +08:00
xiaomakuaiz 2fea4c268d
Merge pull request #1449 from coltea/fix-consumer-deliver-policy
fix consumer deliver plicy
2025-11-03 20:53:37 +08:00
coltea ee4df8da62 fix consumer deliver plicy 2025-11-03 20:51:29 +08:00
Gavan 79e3606bb0 style: 样式优化 2025-11-03 19:34:14 +08:00
425 changed files with 21517 additions and 15686 deletions

View File

@ -13,17 +13,24 @@ type GetNodeDetailReq struct {
}
type NodeDetailResp struct {
ID string `json:"id"`
KbID string `json:"kb_id"`
Type domain.NodeType `json:"type"`
Status domain.NodeStatus `json:"status"`
Name string `json:"name"`
Content string `json:"content"`
Meta domain.NodeMeta `json:"meta"`
ParentID string `json:"parent_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Permissions domain.NodePermissions `json:"permissions"`
ID string `json:"id"`
KbID string `json:"kb_id"`
Type domain.NodeType `json:"type"`
Status domain.NodeStatus `json:"status"`
Name string `json:"name"`
Content string `json:"content"`
Meta domain.NodeMeta `json:"meta"`
ParentID string `json:"parent_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Permissions domain.NodePermissions `json:"permissions"`
CreatorId string `json:"creator_id"`
EditorId string `json:"editor_id"`
PublisherId string `json:"publisher_id" gorm:"-"`
CreatorAccount string `json:"creator_account"`
EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account" gorm:"-"`
PV int64 `json:"pv" gorm:"-"`
}
type NodePermissionReq struct {
@ -50,3 +57,11 @@ type NodePermissionEditReq struct {
type NodePermissionEditResp struct {
}
type NodeRestudyReq struct {
NodeIds []string `json:"node_ids" validate:"required,min=1"`
KbId string `json:"kb_id" validate:"required"`
}
type NodeRestudyResp struct {
}

View File

@ -0,0 +1,29 @@
package v1
import (
"time"
"github.com/chaitin/panda-wiki/domain"
)
type ShareNodeDetailResp struct {
ID string `json:"id"`
KbID string `json:"kb_id"`
Type domain.NodeType `json:"type"`
Status domain.NodeStatus `json:"status"`
Name string `json:"name"`
Content string `json:"content"`
Meta domain.NodeMeta `json:"meta"`
ParentID string `json:"parent_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Permissions domain.NodePermissions `json:"permissions"`
CreatorId string `json:"creator_id"`
EditorId string `json:"editor_id"`
PublisherId string `json:"publisher_id"`
CreatorAccount string `json:"creator_account"`
EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account"`
List []*domain.ShareNodeDetailItem `json:"list" gorm:"-"`
PV int64 `json:"pv" gorm:"-"`
}

View File

@ -0,0 +1,8 @@
package v1
type WechatAppInfoResp struct {
WeChatAppIsEnabled bool `json:"wechat_app_is_enabled"`
FeedbackEnable bool `json:"feedback_enable"`
FeedbackType []string `json:"feedback_type"`
DisclaimerContent string `json:"disclaimer_content"`
}

View File

@ -1,5 +1,4 @@
//go:build wireinject
// +build wireinject
package main

View File

@ -96,7 +96,9 @@ func createApp() (*App, error) {
return nil, err
}
authRepo := pg2.NewAuthRepo(db, logger, cacheCache)
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, knowledgeBaseRepository, llmUsecase, ragService, logger, minioClient, modelRepository, authRepo)
systemSettingRepo := pg2.NewSystemSettingRepo(db, logger)
modelUsecase := usecase.NewModelUsecase(modelRepository, nodeRepository, ragRepository, ragService, logger, configConfig, knowledgeBaseRepository, systemSettingRepo)
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, userRepository, knowledgeBaseRepository, llmUsecase, ragService, logger, minioClient, modelRepository, authRepo, modelUsecase)
nodeHandler := v1.NewNodeHandler(baseHandler, echo, nodeUsecase, authMiddleware, logger)
geoRepo := cache2.NewGeoCache(cacheCache, db, logger)
ipdbIPDB, err := ipdb.NewIPDB(configConfig, logger)
@ -105,7 +107,6 @@ func createApp() (*App, error) {
}
ipAddressRepo := ipdb2.NewIPAddressRepo(ipdbIPDB, logger)
conversationUsecase := usecase.NewConversationUsecase(conversationRepository, nodeRepository, geoRepo, logger, ipAddressRepo, authRepo)
modelUsecase := usecase.NewModelUsecase(modelRepository, nodeRepository, ragRepository, ragService, logger, configConfig, knowledgeBaseRepository)
blockWordRepo := pg2.NewBlockWordRepo(db, logger)
chatUsecase, err := usecase.NewChatUsecase(llmUsecase, knowledgeBaseRepository, conversationUsecase, modelUsecase, appRepository, blockWordRepo, authRepo, logger)
if err != nil {
@ -165,7 +166,7 @@ func createApp() (*App, error) {
wechatRepository := pg2.NewWechatRepository(db, logger)
wechatUsecase := usecase.NewWechatUsecase(logger, appUsecase, chatUsecase, wechatRepository, authRepo)
wecomUsecase := usecase.NewWecomUsecase(logger, cacheCache, appUsecase, chatUsecase, authRepo)
wechatAppUsecase := usecase.NewWechatAppUsecase(logger, appUsecase, chatUsecase, wechatRepository, authRepo)
wechatAppUsecase := usecase.NewWechatAppUsecase(logger, appUsecase, chatUsecase, wechatRepository, authRepo, appRepository)
shareWechatHandler := share.NewShareWechatHandler(echo, baseHandler, logger, appUsecase, conversationUsecase, wechatUsecase, wecomUsecase, wechatAppUsecase)
shareCaptchaHandler := share.NewShareCaptchaHandler(baseHandler, echo, logger)
openapiV1Handler := share.NewOpenapiV1Handler(echo, baseHandler, logger, authUsecase, appUsecase)
@ -184,7 +185,8 @@ func createApp() (*App, error) {
OpenapiV1Handler: openapiV1Handler,
ShareCommonHandler: shareCommonHandler,
}
client, err := telemetry.NewClient(logger, knowledgeBaseRepository)
mcpRepository := pg2.NewMCPRepository(db, logger)
client, err := telemetry.NewClient(logger, knowledgeBaseRepository, modelUsecase, userUsecase, nodeRepository, conversationRepository, mcpRepository, configConfig)
if err != nil {
return nil, err
}

View File

@ -1,5 +1,4 @@
//go:build wireinject
// +build wireinject
package main

View File

@ -8,12 +8,12 @@ package main
import (
"github.com/chaitin/panda-wiki/config"
mq2 "github.com/chaitin/panda-wiki/handler/mq"
mq3 "github.com/chaitin/panda-wiki/handler/mq"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/mq"
cache2 "github.com/chaitin/panda-wiki/repo/cache"
ipdb2 "github.com/chaitin/panda-wiki/repo/ipdb"
mq3 "github.com/chaitin/panda-wiki/repo/mq"
mq2 "github.com/chaitin/panda-wiki/repo/mq"
pg2 "github.com/chaitin/panda-wiki/repo/pg"
"github.com/chaitin/panda-wiki/store/cache"
"github.com/chaitin/panda-wiki/store/ipdb"
@ -49,11 +49,18 @@ func createApp() (*App, error) {
modelRepository := pg2.NewModelRepository(db, logger)
promptRepo := pg2.NewPromptRepo(db, logger)
llmUsecase := usecase.NewLLMUsecase(configConfig, ragService, conversationRepository, knowledgeBaseRepository, nodeRepository, modelRepository, promptRepo, logger)
ragmqHandler, err := mq2.NewRAGMQHandler(mqConsumer, logger, ragService, nodeRepository, knowledgeBaseRepository, llmUsecase, modelRepository)
mqProducer, err := mq.NewMQProducer(configConfig, logger)
if err != nil {
return nil, err
}
ragDocUpdateHandler, err := mq2.NewRagDocUpdateHandler(mqConsumer, logger, nodeRepository)
ragRepository := mq2.NewRAGRepository(mqProducer)
systemSettingRepo := pg2.NewSystemSettingRepo(db, logger)
modelUsecase := usecase.NewModelUsecase(modelRepository, nodeRepository, ragRepository, ragService, logger, configConfig, knowledgeBaseRepository, systemSettingRepo)
ragmqHandler, err := mq3.NewRAGMQHandler(mqConsumer, logger, ragService, nodeRepository, knowledgeBaseRepository, llmUsecase, modelUsecase)
if err != nil {
return nil, err
}
ragDocUpdateHandler, err := mq3.NewRagDocUpdateHandler(mqConsumer, logger, nodeRepository)
if err != nil {
return nil, err
}
@ -71,21 +78,17 @@ func createApp() (*App, error) {
geoRepo := cache2.NewGeoCache(cacheCache, db, logger)
authRepo := pg2.NewAuthRepo(db, logger, cacheCache)
statUseCase := usecase.NewStatUseCase(statRepository, nodeRepository, conversationRepository, appRepository, ipAddressRepo, geoRepo, authRepo, knowledgeBaseRepository, logger)
mqProducer, err := mq.NewMQProducer(configConfig, logger)
if err != nil {
return nil, err
}
ragRepository := mq3.NewRAGRepository(mqProducer)
userRepository := pg2.NewUserRepository(db, logger)
minioClient, err := s3.NewMinioClient(configConfig)
if err != nil {
return nil, err
}
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, knowledgeBaseRepository, llmUsecase, ragService, logger, minioClient, modelRepository, authRepo)
cronHandler, err := mq2.NewStatCronHandler(logger, statRepository, statUseCase, nodeUsecase)
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, userRepository, knowledgeBaseRepository, llmUsecase, ragService, logger, minioClient, modelRepository, authRepo, modelUsecase)
cronHandler, err := mq3.NewStatCronHandler(logger, statRepository, statUseCase, nodeUsecase)
if err != nil {
return nil, err
}
mqHandlers := &mq2.MQHandlers{
mqHandlers := &mq3.MQHandlers{
RAGMQHandler: ragmqHandler,
RagDocUpdateHandler: ragDocUpdateHandler,
StatCronHandler: cronHandler,
@ -104,6 +107,6 @@ func createApp() (*App, error) {
type App struct {
MQConsumer mq.MQConsumer
Config *config.Config
MQHandlers *mq2.MQHandlers
StatCronHandler *mq2.CronHandler
MQHandlers *mq3.MQHandlers
StatCronHandler *mq3.CronHandler
}

View File

@ -1,5 +1,4 @@
//go:build wireinject
// +build wireinject
package main

View File

@ -41,6 +41,7 @@ func createApp() (*App, error) {
return nil, err
}
ragRepository := mq2.NewRAGRepository(mqProducer)
userRepository := pg2.NewUserRepository(db, logger)
ragService, err := rag.NewRAGService(configConfig, logger)
if err != nil {
return nil, err
@ -59,8 +60,9 @@ func createApp() (*App, error) {
return nil, err
}
authRepo := pg2.NewAuthRepo(db, logger, cacheCache)
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, knowledgeBaseRepository, llmUsecase, ragService, logger, minioClient, modelRepository, authRepo)
userRepository := pg2.NewUserRepository(db, logger)
systemSettingRepo := pg2.NewSystemSettingRepo(db, logger)
modelUsecase := usecase.NewModelUsecase(modelRepository, nodeRepository, ragRepository, ragService, logger, configConfig, knowledgeBaseRepository, systemSettingRepo)
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, userRepository, knowledgeBaseRepository, llmUsecase, ragService, logger, minioClient, modelRepository, authRepo, modelUsecase)
kbRepo := cache2.NewKBRepo(cacheCache)
knowledgeBaseUsecase, err := usecase.NewKnowledgeBaseUsecase(knowledgeBaseRepository, nodeRepository, ragRepository, userRepository, ragService, kbRepo, logger, configConfig)
if err != nil {

View File

@ -24,6 +24,7 @@ const (
SourceTypeDiscordBot SourceType = "discord_bot"
SourceTypeWechatOfficialAccount SourceType = "wechat_official_account"
SourceTypeOpenAIAPI SourceType = "openai_api"
SourceTypeMcpServer SourceType = "mcp_server"
)
func (s SourceType) Name() string {
@ -46,6 +47,8 @@ func (s SourceType) Name() string {
return "Discord 机器人"
case SourceTypeWechatOfficialAccount:
return "微信公众号"
case SourceTypeMcpServer:
return "MCP 服务器"
default:
return ""
}

View File

@ -1,8 +1,6 @@
package consts
import (
"math"
"github.com/labstack/echo/v4"
)
@ -13,28 +11,13 @@ const ContextKeyEdition contextKey = "edition"
type LicenseEdition int32
const (
LicenseEditionFree LicenseEdition = 0 // 开源版
LicenseEditionContributor LicenseEdition = 1 // 联创版
LicenseEditionEnterprise LicenseEdition = 2 // 企业版
LicenseEditionFree LicenseEdition = 0 // 开源版
LicenseEditionProfession LicenseEdition = 1 // 专业版
LicenseEditionEnterprise LicenseEdition = 2 // 企业版
LicenseEditionBusiness LicenseEdition = 3 // 商业版
)
func GetLicenseEdition(c echo.Context) LicenseEdition {
edition, _ := c.Get("edition").(LicenseEdition)
return edition
}
func (e LicenseEdition) GetMaxAuth(sourceType SourceType) int {
switch e {
case LicenseEditionFree:
if sourceType == SourceTypeGitHub {
return 10
}
return 0
case LicenseEditionContributor:
return 10
case LicenseEditionEnterprise:
return math.MaxInt
default:
return 0
}
}

39
backend/consts/model.go Normal file
View File

@ -0,0 +1,39 @@
package consts
type AutoModeDefaultModel string
const (
AutoModeDefaultChatModel AutoModeDefaultModel = "deepseek-chat"
AutoModeDefaultEmbeddingModel AutoModeDefaultModel = "bge-m3"
AutoModeDefaultRerankModel AutoModeDefaultModel = "bge-reranker-v2-m3"
AutoModeDefaultAnalysisModel AutoModeDefaultModel = "qwen2.5-3b-instruct"
AutoModeDefaultAnalysisVLModel AutoModeDefaultModel = "qwen3-vl-max"
)
func GetAutoModeDefaultModel(modelType string) string {
switch modelType {
case "chat":
return string(AutoModeDefaultChatModel)
case "embedding":
return string(AutoModeDefaultEmbeddingModel)
case "rerank":
return string(AutoModeDefaultRerankModel)
case "analysis":
return string(AutoModeDefaultAnalysisModel)
case "analysis-vl":
return string(AutoModeDefaultAnalysisVLModel)
default:
return string(AutoModeDefaultChatModel)
}
}
type ModelSettingMode string
const (
ModelSettingModeManual ModelSettingMode = "manual"
ModelSettingModeAuto ModelSettingMode = "auto"
)
const (
AutoModeBaseURL = "https://model-square.app.baizhi.cloud/v1"
)

View File

@ -0,0 +1,7 @@
package consts
type SystemSettingKey string
const (
SystemSettingModelMode SystemSettingKey = "model_setting_mode"
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -117,20 +117,32 @@ definitions:
- 0
- 1
- 2
- 3
format: int32
type: integer
x-enum-comments:
LicenseEditionContributor: 联创
LicenseEditionBusiness: 商业
LicenseEditionEnterprise: 企业版
LicenseEditionFree: 开源版
LicenseEditionProfession: 专业版
x-enum-descriptions:
- 开源版
- 联创
- 专业
- 企业版
- 商业版
x-enum-varnames:
- LicenseEditionFree
- LicenseEditionContributor
- LicenseEditionProfession
- LicenseEditionEnterprise
- LicenseEditionBusiness
consts.ModelSettingMode:
enum:
- manual
- auto
type: string
x-enum-varnames:
- ModelSettingModeManual
- ModelSettingModeAuto
consts.NodeAccessPerm:
enum:
- open
@ -233,6 +245,7 @@ definitions:
- discord_bot
- wechat_official_account
- openai_api
- mcp_server
type: string
x-enum-varnames:
- SourceTypeDingTalk
@ -252,6 +265,7 @@ definitions:
- SourceTypeDiscordBot
- SourceTypeWechatOfficialAccount
- SourceTypeOpenAIAPI
- SourceTypeMcpServer
consts.StatDay:
enum:
- 1
@ -406,8 +420,6 @@ definitions:
allOf:
- $ref: '#/definitions/domain.AIFeedbackSettings'
description: AI feedback
auto_sitemap:
type: boolean
body_code:
type: string
btns:
@ -419,6 +431,8 @@ definitions:
description: catalog settings
contribute_settings:
$ref: '#/definitions/domain.ContributeSettings'
conversation_setting:
$ref: '#/definitions/domain.ConversationSetting'
copy_setting:
allOf:
- $ref: '#/definitions/consts.CopySetting'
@ -474,6 +488,10 @@ definitions:
allOf:
- $ref: '#/definitions/domain.LarkBotSettings'
description: LarkBot
mcp_server_settings:
allOf:
- $ref: '#/definitions/domain.MCPServerSettings'
description: MCP Server Settings
openai_api_bot_settings:
allOf:
- $ref: '#/definitions/domain.OpenAIAPIBotSettings'
@ -488,6 +506,8 @@ definitions:
type: array
search_placeholder:
type: string
stats_setting:
$ref: '#/definitions/domain.StatsSetting'
theme_and_style:
$ref: '#/definitions/domain.ThemeAndStyle'
theme_mode:
@ -520,6 +540,8 @@ definitions:
type: array
web_app_landing_theme:
$ref: '#/definitions/domain.WebAppLandingTheme'
wechat_app_advanced_setting:
$ref: '#/definitions/domain.WeChatAppAdvancedSetting'
wechat_app_agent_id:
type: string
wechat_app_corpid:
@ -581,8 +603,6 @@ definitions:
allOf:
- $ref: '#/definitions/domain.AIFeedbackSettings'
description: AI feedback
auto_sitemap:
type: boolean
body_code:
type: string
btns:
@ -594,6 +614,8 @@ definitions:
description: catalog settings
contribute_settings:
$ref: '#/definitions/domain.ContributeSettings'
conversation_setting:
$ref: '#/definitions/domain.ConversationSetting'
copy_setting:
$ref: '#/definitions/consts.CopySetting'
desc:
@ -644,6 +666,10 @@ definitions:
allOf:
- $ref: '#/definitions/domain.LarkBotSettings'
description: LarkBot
mcp_server_settings:
allOf:
- $ref: '#/definitions/domain.MCPServerSettings'
description: MCP Server Settings
openai_api_bot_settings:
allOf:
- $ref: '#/definitions/domain.OpenAIAPIBotSettings'
@ -658,6 +684,8 @@ definitions:
type: array
search_placeholder:
type: string
stats_setting:
$ref: '#/definitions/domain.StatsSetting'
theme_and_style:
$ref: '#/definitions/domain.ThemeAndStyle'
theme_mode:
@ -685,6 +713,8 @@ definitions:
type: array
web_app_landing_theme:
$ref: '#/definitions/domain.WebAppLandingTheme'
wechat_app_advanced_setting:
$ref: '#/definitions/domain.WeChatAppAdvancedSetting'
wechat_app_agent_id:
type: string
wechat_app_corpid:
@ -751,6 +781,7 @@ definitions:
- 9
- 10
- 11
- 12
format: int32
type: integer
x-enum-varnames:
@ -765,6 +796,7 @@ definitions:
- AppTypeOpenAIAPI
- AppTypeWecomAIBot
- AppTypeLarkBot
- AppTypeMcpServer
domain.AuthUserInfo:
properties:
avatar_url:
@ -833,6 +865,24 @@ definitions:
- ids
- kb_id
type: object
domain.BlockGridConfig:
properties:
list:
items:
properties:
id:
type: string
name:
type: string
url:
type: string
type: object
type: array
title:
type: string
type:
type: string
type: object
domain.BrandGroup:
properties:
links:
@ -1188,6 +1238,13 @@ definitions:
url:
type: string
type: object
domain.ConversationSetting:
properties:
copyright_hide_enabled:
type: boolean
copyright_info:
type: string
type: object
domain.CreateKBReleaseReq:
properties:
kb_id:
@ -1584,6 +1641,24 @@ definitions:
url:
type: string
type: object
domain.MCPServerSettings:
properties:
docs_tool_settings:
$ref: '#/definitions/domain.MCPToolSettings'
is_enabled:
type: boolean
sample_auth:
$ref: '#/definitions/domain.SimpleAuth'
type: object
domain.MCPToolSettings:
properties:
desc:
type: string
name:
type: string
type: object
domain.MessageContent:
type: object
domain.MessageFrom:
enum:
- 1
@ -1610,6 +1685,22 @@ definitions:
type:
type: string
type: object
domain.ModelModeSetting:
properties:
auto_mode_api_key:
description: 百智云 API Key
type: string
chat_model:
description: 自定义对话模型名称
type: string
is_manual_embedding_updated:
description: 手动模式下嵌入模型是否更新
type: boolean
mode:
allOf:
- $ref: '#/definitions/consts.ModelSettingMode'
description: '模式: manual 或 auto'
type: object
domain.ModelType:
enum:
- chat
@ -1715,6 +1806,8 @@ definitions:
$ref: '#/definitions/domain.NodePermissions'
position:
type: number
publisher_id:
type: string
rag_info:
$ref: '#/definitions/domain.RagInfo'
status:
@ -1829,6 +1922,8 @@ definitions:
type: array
stream:
type: boolean
stream_options:
$ref: '#/definitions/domain.OpenAIStreamOptions'
temperature:
type: number
tool_choice:
@ -1910,7 +2005,7 @@ definitions:
domain.OpenAIMessage:
properties:
content:
type: string
$ref: '#/definitions/domain.MessageContent'
name:
type: string
role:
@ -1931,6 +2026,11 @@ definitions:
required:
- type
type: object
domain.OpenAIStreamOptions:
properties:
include_usage:
type: boolean
type: object
domain.OpenAITool:
properties:
function:
@ -1993,6 +2093,22 @@ definitions:
model:
type: string
type: object
domain.QuestionConfig:
properties:
list:
items:
properties:
id:
type: string
question:
type: string
type: object
type: array
title:
type: string
type:
type: string
type: object
domain.RagInfo:
properties:
message:
@ -2088,6 +2204,31 @@ definitions:
role:
$ref: '#/definitions/schema.RoleType'
type: object
domain.ShareNodeDetailItem:
properties:
children:
items:
$ref: '#/definitions/domain.ShareNodeDetailItem'
type: array
emoji:
type: string
id:
type: string
meta:
$ref: '#/definitions/domain.NodeMeta'
name:
type: string
parent_id:
type: string
permissions:
$ref: '#/definitions/domain.NodePermissions'
position:
type: number
type:
$ref: '#/definitions/domain.NodeType'
updated_at:
type: string
type: object
domain.SimpleAuth:
properties:
enabled:
@ -2144,6 +2285,32 @@ definitions:
- StatPageSceneNodeDetail
- StatPageSceneChat
- StatPageSceneLogin
domain.StatsSetting:
properties:
pv_enable:
type: boolean
type: object
domain.SwitchModeReq:
properties:
auto_mode_api_key:
description: 百智云 API Key
type: string
chat_model:
description: 自定义对话模型名称
type: string
mode:
enum:
- manual
- auto
type: string
required:
- mode
type: object
domain.SwitchModeResp:
properties:
message:
type: string
type: object
domain.TextConfig:
properties:
title:
@ -2281,6 +2448,21 @@ definitions:
user_id:
type: string
type: object
domain.WeChatAppAdvancedSetting:
properties:
disclaimer_content:
type: string
feedback_enable:
type: boolean
feedback_type:
items:
type: string
type: array
prompt:
type: string
text_response_enable:
type: boolean
type: object
domain.WebAppCommentSettings:
properties:
is_enable:
@ -2309,6 +2491,8 @@ definitions:
$ref: '#/definitions/domain.BannerConfig'
basic_doc_config:
$ref: '#/definitions/domain.BasicDocConfig'
block_grid_config:
$ref: '#/definitions/domain.BlockGridConfig'
carousel_config:
$ref: '#/definitions/domain.CarouselConfig'
case_config:
@ -2333,6 +2517,8 @@ definitions:
items:
type: string
type: array
question_config:
$ref: '#/definitions/domain.QuestionConfig'
simple_doc_config:
$ref: '#/definitions/domain.SimpleDocConfig'
text_config:
@ -2348,6 +2534,8 @@ definitions:
$ref: '#/definitions/domain.BannerConfig'
basic_doc_config:
$ref: '#/definitions/domain.BasicDocConfig'
block_grid_config:
$ref: '#/definitions/domain.BlockGridConfig'
carousel_config:
$ref: '#/definitions/domain.CarouselConfig'
case_config:
@ -2376,6 +2564,8 @@ definitions:
items:
$ref: '#/definitions/domain.RecommendNodeListResp'
type: array
question_config:
$ref: '#/definitions/domain.QuestionConfig'
simple_doc_config:
$ref: '#/definitions/domain.SimpleDocConfig'
text_config:
@ -2401,12 +2591,38 @@ definitions:
type: object
domain.WidgetBotSettings:
properties:
btn_id:
type: string
btn_logo:
type: string
btn_position:
type: string
btn_style:
type: string
btn_text:
type: string
copyright_hide_enabled:
type: boolean
copyright_info:
type: string
disclaimer:
type: string
is_open:
type: boolean
modal_position:
type: string
placeholder:
type: string
recommend_node_ids:
items:
type: string
type: array
recommend_questions:
items:
type: string
type: array
search_mode:
type: string
theme_mode:
type: string
type: object
@ -2852,6 +3068,14 @@ definitions:
type: string
created_at:
type: string
creator_account:
type: string
creator_id:
type: string
editor_account:
type: string
editor_id:
type: string
id:
type: string
kb_id:
@ -2864,6 +3088,12 @@ definitions:
type: string
permissions:
$ref: '#/definitions/domain.NodePermissions'
publisher_account:
type: string
publisher_id:
type: string
pv:
type: integer
status:
$ref: '#/definitions/domain.NodeStatus'
type:
@ -2924,6 +3154,21 @@ definitions:
$ref: '#/definitions/domain.NodeGroupDetail'
type: array
type: object
v1.NodeRestudyReq:
properties:
kb_id:
type: string
node_ids:
items:
type: string
minItems: 1
type: array
required:
- kb_id
- node_ids
type: object
v1.NodeRestudyResp:
type: object
v1.ResetPasswordReq:
properties:
id:
@ -2935,6 +3180,49 @@ definitions:
- id
- new_password
type: object
v1.ShareNodeDetailResp:
properties:
content:
type: string
created_at:
type: string
creator_account:
type: string
creator_id:
type: string
editor_account:
type: string
editor_id:
type: string
id:
type: string
kb_id:
type: string
list:
items:
$ref: '#/definitions/domain.ShareNodeDetailItem'
type: array
meta:
$ref: '#/definitions/domain.NodeMeta'
name:
type: string
parent_id:
type: string
permissions:
$ref: '#/definitions/domain.NodePermissions'
publisher_account:
type: string
publisher_id:
type: string
pv:
type: integer
status:
$ref: '#/definitions/domain.NodeStatus'
type:
$ref: '#/definitions/domain.NodeType'
updated_at:
type: string
type: object
v1.StatCountResp:
properties:
conversation_count:
@ -2981,6 +3269,19 @@ definitions:
$ref: '#/definitions/v1.UserListItemResp'
type: array
type: object
v1.WechatAppInfoResp:
properties:
disclaimer_content:
type: string
feedback_enable:
type: boolean
feedback_type:
items:
type: string
type: array
wechat_app_is_enabled:
type: boolean
type: object
info:
contact: {}
description: panda-wiki API documentation
@ -3127,6 +3428,7 @@ paths:
- discord_bot
- wechat_official_account
- openai_api
- mcp_server
in: query
name: source_type
required: true
@ -3149,6 +3451,7 @@ paths:
- SourceTypeDiscordBot
- SourceTypeWechatOfficialAccount
- SourceTypeOpenAIAPI
- SourceTypeMcpServer
produces:
- application/json
responses:
@ -3951,6 +4254,27 @@ paths:
summary: get model list
tags:
- model
/api/v1/model/mode-setting:
get:
consumes:
- application/json
description: get current model mode setting including mode, API key and chat
model
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/domain.ModelModeSetting'
type: object
summary: get model mode setting
tags:
- model
/api/v1/model/provider/supported:
post:
consumes:
@ -3978,6 +4302,33 @@ paths:
summary: get provider supported model list
tags:
- model
/api/v1/model/switch-mode:
post:
consumes:
- application/json
description: switch model mode between manual and auto
parameters:
- description: switch mode request
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.SwitchModeReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/domain.SwitchModeResp'
type: object
summary: switch mode
tags:
- model
/api/v1/node:
post:
consumes:
@ -4275,6 +4626,36 @@ paths:
summary: Recommend Nodes
tags:
- node
/api/v1/node/restudy:
post:
consumes:
- application/json
description: 文档重新学习
operationId: v1-NodeRestudy
parameters:
- description: para
in: body
name: param
required: true
schema:
$ref: '#/definitions/v1.NodeRestudyReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/v1.NodeRestudyResp'
type: object
security:
- bearerAuth: []
summary: 文档重新学习
tags:
- Node
/api/v1/node/summary:
post:
consumes:
@ -4748,6 +5129,32 @@ paths:
summary: GetAppInfo
tags:
- share_app
/share/v1/app/wechat/info:
get:
consumes:
- application/json
description: WechatAppInfo
parameters:
- description: kb id
in: header
name: X-KB-ID
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/v1.WechatAppInfoResp'
type: object
summary: WechatAppInfo
tags:
- share_chat
/share/v1/app/wechat/service/answer:
get:
consumes:
@ -5059,7 +5466,34 @@ paths:
$ref: '#/definitions/domain.Response'
summary: ChatWidget
tags:
- share_chat
- Widget
/share/v1/chat/widget/search:
post:
consumes:
- application/json
description: WidgetSearch
parameters:
- description: Comment
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.ChatSearchReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/domain.ChatSearchResp'
type: object
summary: WidgetSearch
tags:
- Widget
/share/v1/comment:
post:
consumes:
@ -5208,7 +5642,12 @@ paths:
"200":
description: OK
schema:
$ref: '#/definitions/domain.Response'
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/v1.ShareNodeDetailResp'
type: object
summary: GetNodeDetail
tags:
- share_node

View File

@ -24,6 +24,7 @@ const (
AppTypeOpenAIAPI
AppTypeWecomAIBot
AppTypeLarkBot
AppTypeMcpServer
)
var AppTypes = []AppType{
@ -38,6 +39,7 @@ var AppTypes = []AppType{
AppTypeOpenAIAPI,
AppTypeWecomAIBot,
AppTypeLarkBot,
AppTypeMcpServer,
}
func (t AppType) ToSourceType() consts.SourceType {
@ -92,9 +94,8 @@ type AppSettings struct {
RecommendQuestions []string `json:"recommend_questions,omitempty"`
RecommendNodeIDs []string `json:"recommend_node_ids,omitempty"`
// seo
Desc string `json:"desc,omitempty"`
Keyword string `json:"keyword,omitempty"`
AutoSitemap bool `json:"auto_sitemap,omitempty"`
Desc string `json:"desc,omitempty"`
Keyword string `json:"keyword,omitempty"`
// inject code
HeadCode string `json:"head_code,omitempty"`
BodyCode string `json:"body_code,omitempty"`
@ -110,12 +111,13 @@ type AppSettings struct {
// LarkBot
LarkBotSettings LarkBotSettings `json:"lark_bot_settings,omitempty"`
// WechatAppBot 企业微信机器人
WeChatAppIsEnabled *bool `json:"wechat_app_is_enabled,omitempty"`
WeChatAppToken string `json:"wechat_app_token,omitempty"`
WeChatAppEncodingAESKey string `json:"wechat_app_encodingaeskey,omitempty"`
WeChatAppCorpID string `json:"wechat_app_corpid,omitempty"`
WeChatAppSecret string `json:"wechat_app_secret,omitempty"`
WeChatAppAgentID string `json:"wechat_app_agent_id,omitempty"`
WeChatAppIsEnabled *bool `json:"wechat_app_is_enabled,omitempty"`
WeChatAppToken string `json:"wechat_app_token,omitempty"`
WeChatAppEncodingAESKey string `json:"wechat_app_encodingaeskey,omitempty"`
WeChatAppCorpID string `json:"wechat_app_corpid,omitempty"`
WeChatAppSecret string `json:"wechat_app_secret,omitempty"`
WeChatAppAgentID string `json:"wechat_app_agent_id,omitempty"`
WeChatAppAdvancedSetting WeChatAppAdvancedSetting `json:"wechat_app_advanced_setting"`
// WecomAIBotSettings 企业微信智能机器人
WecomAIBotSettings WecomAIBotSettings `json:"wecom_ai_bot_settings"`
// WechatServiceBot
@ -161,17 +163,49 @@ type AppSettings struct {
WebAppLandingConfigs []WebAppLandingConfig `json:"web_app_landing_configs,omitempty"`
WebAppLandingTheme WebAppLandingTheme `json:"web_app_landing_theme"`
WatermarkContent string `json:"watermark_content"`
WatermarkSetting consts.WatermarkSetting `json:"watermark_setting" validate:"omitempty,oneof='' hidden visible"`
CopySetting consts.CopySetting `json:"copy_setting" validate:"omitempty,oneof='' append disabled"`
ContributeSettings ContributeSettings `json:"contribute_settings"`
HomePageSetting consts.HomePageSetting `json:"home_page_setting"`
WatermarkContent string `json:"watermark_content"`
WatermarkSetting consts.WatermarkSetting `json:"watermark_setting" validate:"omitempty,oneof='' hidden visible"`
CopySetting consts.CopySetting `json:"copy_setting" validate:"omitempty,oneof='' append disabled"`
ContributeSettings ContributeSettings `json:"contribute_settings"`
HomePageSetting consts.HomePageSetting `json:"home_page_setting"`
ConversationSetting ConversationSetting `json:"conversation_setting"`
// MCP Server Settings
MCPServerSettings MCPServerSettings `json:"mcp_server_settings,omitempty"`
StatsSetting StatsSetting `json:"stats_setting"`
}
type WeChatAppAdvancedSetting struct {
TextResponseEnable bool `json:"text_response_enable,omitempty"`
FeedbackEnable bool `json:"feedback_enable,omitempty"`
FeedbackType []string `json:"feedback_type,omitempty"`
DisclaimerContent string `json:"disclaimer_content,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
type StatsSetting struct {
PVEnable bool `json:"pv_enable"`
}
type ConversationSetting struct {
CopyrightInfo string `json:"copyright_info"`
CopyrightHideEnabled bool `json:"copyright_hide_enabled"`
}
type WebAppLandingTheme struct {
Name string `json:"name"`
}
type MCPServerSettings struct {
IsEnabled bool `json:"is_enabled"`
DocsToolSettings MCPToolSettings `json:"docs_tool_settings"`
SampleAuth SimpleAuth `json:"sample_auth"`
}
type MCPToolSettings struct {
Name string `json:"name"`
Desc string `json:"desc"`
}
type LarkBotSettings struct {
IsEnabled *bool `json:"is_enabled"`
AppID string `json:"app_id"`
@ -293,6 +327,23 @@ type TextImgConfig struct {
Desc string `json:"desc"`
} `json:"item"`
}
type QuestionConfig struct {
Type string `json:"type"`
Title string `json:"title"`
List []struct {
ID string `json:"id"`
Question string `json:"question"`
} `json:"list"`
}
type BlockGridConfig struct {
Type string `json:"type"`
Title string `json:"title"`
List []struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
} `json:"list"`
}
type WebAppLandingConfig struct {
Type string `json:"type"`
@ -310,6 +361,8 @@ type WebAppLandingConfig struct {
FeatureConfig *FeatureConfig `json:"feature_config,omitempty"`
ImgTextConfig *ImgTextConfig `json:"img_text_config,omitempty"`
TextImgConfig *TextImgConfig `json:"text_img_config,omitempty"`
QuestionConfig *QuestionConfig `json:"question_config,omitempty"`
BlockGridConfig *BlockGridConfig `json:"block_grid_config,omitempty"`
ComConfigOrder []string `json:"com_config_order"`
}
@ -380,10 +433,21 @@ type FooterSettings struct {
}
type WidgetBotSettings struct {
IsOpen bool `json:"is_open,omitempty"`
ThemeMode string `json:"theme_mode,omitempty"`
BtnText string `json:"btn_text,omitempty"`
BtnLogo string `json:"btn_logo,omitempty"`
IsOpen bool `json:"is_open,omitempty"`
ThemeMode string `json:"theme_mode,omitempty"`
BtnText string `json:"btn_text,omitempty"`
BtnLogo string `json:"btn_logo,omitempty"`
RecommendQuestions []string `json:"recommend_questions,omitempty"`
RecommendNodeIDs []string `json:"recommend_node_ids,omitempty"`
BtnStyle string `json:"btn_style,omitempty"`
BtnID string `json:"btn_id,omitempty"`
BtnPosition string `json:"btn_position,omitempty"`
ModalPosition string `json:"modal_position,omitempty"`
SearchMode string `json:"search_mode,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
CopyrightInfo string `json:"copyright_info,omitempty"`
CopyrightHideEnabled bool `json:"copyright_hide_enabled,omitempty"`
}
type BrandGroup struct {
@ -431,9 +495,8 @@ type AppSettingsResp struct {
RecommendQuestions []string `json:"recommend_questions,omitempty"`
RecommendNodeIDs []string `json:"recommend_node_ids,omitempty"`
// seo
Desc string `json:"desc,omitempty"`
Keyword string `json:"keyword,omitempty"`
AutoSitemap bool `json:"auto_sitemap,omitempty"`
Desc string `json:"desc,omitempty"`
Keyword string `json:"keyword,omitempty"`
// inject code
HeadCode string `json:"head_code,omitempty"`
BodyCode string `json:"body_code,omitempty"`
@ -449,12 +512,13 @@ type AppSettingsResp struct {
// LarkBot
LarkBotSettings LarkBotSettings `json:"lark_bot_settings,omitempty"`
// WechatAppBot
WeChatAppIsEnabled *bool `json:"wechat_app_is_enabled,omitempty"`
WeChatAppToken string `json:"wechat_app_token,omitempty"`
WeChatAppEncodingAESKey string `json:"wechat_app_encodingaeskey,omitempty"`
WeChatAppCorpID string `json:"wechat_app_corpid,omitempty"`
WeChatAppSecret string `json:"wechat_app_secret,omitempty"`
WeChatAppAgentID string `json:"wechat_app_agent_id,omitempty"`
WeChatAppIsEnabled *bool `json:"wechat_app_is_enabled,omitempty"`
WeChatAppToken string `json:"wechat_app_token,omitempty"`
WeChatAppEncodingAESKey string `json:"wechat_app_encodingaeskey,omitempty"`
WeChatAppCorpID string `json:"wechat_app_corpid,omitempty"`
WeChatAppSecret string `json:"wechat_app_secret,omitempty"`
WeChatAppAgentID string `json:"wechat_app_agent_id,omitempty"`
WeChatAppAdvancedSetting WeChatAppAdvancedSetting `json:"wechat_app_advanced_setting"`
// WechatServiceBot
WeChatServiceIsEnabled *bool `json:"wechat_service_is_enabled,omitempty"`
WeChatServiceToken string `json:"wechat_service_token,omitempty"`
@ -507,6 +571,10 @@ type AppSettingsResp struct {
WebAppLandingConfigs []WebAppLandingConfigResp `json:"web_app_landing_configs,omitempty"`
WebAppLandingTheme WebAppLandingTheme `json:"web_app_landing_theme"`
HomePageSetting consts.HomePageSetting `json:"home_page_setting"`
ConversationSetting ConversationSetting `json:"conversation_setting"`
// MCP Server Settings
MCPServerSettings MCPServerSettings `json:"mcp_server_settings,omitempty"`
StatsSetting StatsSetting `json:"stats_setting"`
}
type WebAppLandingConfigResp struct {
@ -524,6 +592,8 @@ type WebAppLandingConfigResp struct {
FeatureConfig *FeatureConfig `json:"feature_config,omitempty"`
ImgTextConfig *ImgTextConfig `json:"img_text_config,omitempty"`
TextImgConfig *TextImgConfig `json:"text_img_config,omitempty"`
QuestionConfig *QuestionConfig `json:"question_config,omitempty"`
BlockGridConfig *BlockGridConfig `json:"block_grid_config,omitempty"`
ComConfigOrder []string `json:"com_config_order"`
NodeIds []string `json:"node_ids"`
Nodes []*RecommendNodeListResp `json:"nodes" gorm:"-"`

View File

@ -21,6 +21,16 @@ type ChatRequest struct {
RemoteIP string `json:"-"`
Info ConversationInfo `json:"-"`
Prompt string `json:"-"`
}
type ChatRagOnlyRequest struct {
Message string `json:"message" validate:"required"`
KBID string `json:"-" validate:"required"`
UserInfo UserInfo `json:"user_info"`
AppType AppType `json:"app_type" validate:"required,oneof=1 2"`
}
type ConversationInfo struct {

View File

@ -129,6 +129,10 @@ type KBReleaseNodeRelease struct {
CreatedAt time.Time `json:"created_at"`
}
func (KBReleaseNodeRelease) TableName() string {
return "kb_release_node_releases"
}
type CreateKBReleaseReq struct {
KBID string `json:"kb_id" validate:"required"`
Message string `json:"message" validate:"required"`

45
backend/domain/license.go Normal file
View File

@ -0,0 +1,45 @@
package domain
import (
"context"
"encoding/json"
)
const ContextKeyEditionLimitation contextKey = "edition_limitation"
type BaseEditionLimitation struct {
MaxKb int `json:"max_kb"` // 知识库站点数量
MaxNode int `json:"max_node"` // 单个知识库下文档数量
MaxSSOUser int `json:"max_sso_users"` // SSO认证用户数量
MaxAdmin int64 `json:"max_admin"` // 后台管理员数量
AllowAdminPerm bool `json:"allow_admin_perm"` // 支持管理员分权控制
AllowCustomCopyright bool `json:"allow_custom_copyright"` // 支持自定义版权信息
AllowCommentAudit bool `json:"allow_comment_audit"` // 支持评论审核
AllowAdvancedBot bool `json:"allow_advanced_bot"` // 支持高级机器人配置
AllowWatermark bool `json:"allow_watermark"` // 支持水印
AllowCopyProtection bool `json:"allow_copy_protection"` // 支持内容复制保护
AllowOpenAIBotSettings bool `json:"allow_open_ai_bot_settings"` // 支持问答机器人
AllowMCPServer bool `json:"allow_mcp_server"` // 支持创建MCP Server
AllowNodeStats bool `json:"allow_node_stats"` // 支持文档统计
}
var baseEditionLimitationDefault = BaseEditionLimitation{
MaxKb: 1,
MaxAdmin: 1,
MaxNode: 300,
}
func GetBaseEditionLimitation(c context.Context) BaseEditionLimitation {
edition, ok := c.Value(ContextKeyEditionLimitation).([]byte)
if !ok {
return baseEditionLimitationDefault
}
var editionLimitation BaseEditionLimitation
if err := json.Unmarshal(edition, &editionLimitation); err != nil {
return baseEditionLimitationDefault
}
return editionLimitation
}

View File

@ -6,7 +6,7 @@ import (
"strings"
)
var SystemPrompt = `
var SystemDefaultPrompt = `
你是一个专业的AI知识库问答助手要按照以下步骤回答用户问题
请仔细阅读以下信息

View File

@ -165,3 +165,13 @@ type ProviderModelListItem struct {
type ActivateModelReq struct {
ModelID string `json:"model_id" validate:"required"`
}
type SwitchModeReq struct {
Mode string `json:"mode" validate:"required,oneof=manual auto"`
AutoModeAPIKey string `json:"auto_mode_api_key"` // 百智云 API Key
ChatModel string `json:"chat_model"` // 自定义对话模型名称
}
type SwitchModeResp struct {
Message string `json:"message"`
}

View File

@ -174,6 +174,7 @@ type NodeListItemResp struct {
EditorId string `json:"editor_id"`
Creator string `json:"creator"`
Editor string `json:"editor"`
PublisherId string `json:"publisher_id" gorm:"-"`
Permissions NodePermissions `json:"permissions" gorm:"type:jsonb"`
}
@ -251,10 +252,24 @@ type ShareNodeListItemResp struct {
ParentID string `json:"parent_id"`
Position float64 `json:"position"`
Emoji string `json:"emoji"`
Meta NodeMeta `json:"meta"`
UpdatedAt time.Time `json:"updated_at"`
Permissions NodePermissions `json:"permissions" gorm:"type:jsonb"`
}
type ShareNodeDetailItem struct {
ID string `json:"id"`
Name string `json:"name"`
Type NodeType `json:"type"`
ParentID string `json:"parent_id"`
Position float64 `json:"position"`
Emoji string `json:"emoji"`
Meta NodeMeta `json:"meta"`
UpdatedAt time.Time `json:"updated_at"`
Permissions NodePermissions `json:"permissions" gorm:"type:jsonb"`
Children []*ShareNodeDetailItem `json:"children,omitempty"`
}
func (n *ShareNodeListItemResp) GetURL(baseURL string) string {
return fmt.Sprintf("%s/node/%s", baseURL, n.ID)
}
@ -279,10 +294,12 @@ type GetRecommendNodeListReq struct {
// table: node_releases
type NodeRelease struct {
ID string `json:"id" gorm:"primaryKey"`
KBID string `json:"kb_id" gorm:"index"`
NodeID string `json:"node_id" gorm:"index"`
DocID string `json:"doc_id" gorm:"index"` // for rag service
ID string `json:"id" gorm:"primaryKey"`
KBID string `json:"kb_id" gorm:"index"`
PublisherId string `json:"publisher_id"`
EditorId string `json:"editor_id"`
NodeID string `json:"node_id" gorm:"index"`
DocID string `json:"doc_id" gorm:"index"` // for rag service
Type NodeType `json:"type"`
@ -312,3 +329,15 @@ type BatchMoveReq struct {
KBID string `json:"kb_id" validate:"required"`
ParentID string `json:"parent_id"`
}
type NodeCreateInfo struct {
ID string `json:"id"`
Account string `json:"account"`
CreatorId string `json:"creator_id"`
}
type NodeReleaseWithPublisher struct {
ID string `json:"id" gorm:"primaryKey"`
PublisherId string `json:"publisher_id"`
PublisherAccount string `json:"publisher_account"`
}

View File

@ -1,10 +1,17 @@
package domain
import (
"encoding/json"
"fmt"
"strings"
)
// OpenAI API 请求结构体
type OpenAICompletionsRequest struct {
Model string `json:"model" validate:"required"`
Messages []OpenAIMessage `json:"messages" validate:"required"`
Stream bool `json:"stream,omitempty"`
StreamOptions *OpenAIStreamOptions `json:"stream_options,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
@ -17,9 +24,95 @@ type OpenAICompletionsRequest struct {
ResponseFormat *OpenAIResponseFormat `json:"response_format,omitempty"`
}
type OpenAIStreamOptions struct {
IncludeUsage bool `json:"include_usage,omitempty"`
}
// MessageContent 支持字符串或内容数组
type MessageContent struct {
isString bool
strValue string
arrValue []OpenAIContentPart
}
// OpenAIContentPart 表示内容数组中的单个元素
type OpenAIContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *OpenAIContentPartURL `json:"image_url,omitempty"`
}
// OpenAIContentPartURL represents the image_url field in content parts
type OpenAIContentPartURL struct {
URL string `json:"url"`
}
// UnmarshalJSON 自定义解析,支持 string 或 array 格式
func (mc *MessageContent) UnmarshalJSON(data []byte) error {
// 尝试解析为字符串
var str string
if err := json.Unmarshal(data, &str); err == nil {
mc.isString = true
mc.strValue = str
return nil
}
// 尝试解析为数组
var arr []OpenAIContentPart
if err := json.Unmarshal(data, &arr); err == nil {
mc.isString = false
mc.arrValue = arr
return nil
}
return fmt.Errorf("content must be string or array")
}
// MarshalJSON 自定义序列化
func (mc *MessageContent) MarshalJSON() ([]byte, error) {
if mc.isString {
return json.Marshal(mc.strValue)
}
return json.Marshal(mc.arrValue)
}
// NewStringContent 创建字符串类型的 MessageContent
func NewStringContent(s string) *MessageContent {
return &MessageContent{
isString: true,
strValue: s,
}
}
// NewArrayContent 创建数组类型的 MessageContent
func NewArrayContent(parts []OpenAIContentPart) *MessageContent {
return &MessageContent{
isString: false,
arrValue: parts,
}
}
// String 获取文本内容
func (mc *MessageContent) String() string {
if mc.isString {
return mc.strValue
}
// 从数组中提取文本
var builder strings.Builder
for _, part := range mc.arrValue {
if part.Type == "text" {
if builder.Len() > 0 && part.Text != "" {
builder.WriteString(" ")
}
builder.WriteString(part.Text)
}
}
return builder.String()
}
type OpenAIMessage struct {
Role string `json:"role" validate:"required"`
Content string `json:"content,omitempty"`
Content *MessageContent `json:"content,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
@ -90,6 +183,7 @@ type OpenAIStreamResponse struct {
Created int64 `json:"created"`
Model string `json:"model"`
Choices []OpenAIStreamChoice `json:"choices"`
Usage *OpenAIUsage `json:"usage,omitempty"`
}
type OpenAIStreamChoice struct {

View File

@ -0,0 +1,186 @@
package domain
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMessageContent_UnmarshalJSON_String(t *testing.T) {
tests := []struct {
name string
json string
expected string
}{
{"simple string", `"hello"`, "hello"},
{"with quotes", `"say \"hello\""`, `say "hello"`},
{"with newline", `"line1\nline2"`, "line1\nline2"},
{"empty string", `""`, ""},
{"unicode", `"你好 🌍"`, "你好 🌍"},
{"special chars", `"Hello \"World\"\nNew Line\tTab"`, "Hello \"World\"\nNew Line\tTab"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var mc MessageContent
err := json.Unmarshal([]byte(tt.json), &mc)
require.NoError(t, err)
assert.Equal(t, tt.expected, mc.String())
assert.True(t, mc.isString)
})
}
}
func TestMessageContent_UnmarshalJSON_Array(t *testing.T) {
tests := []struct {
name string
json string
expected string
}{
{
"single text part",
`[{"type":"text","text":"Hello"}]`,
"Hello",
},
{
"multiple text parts",
`[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]`,
"Hello World",
},
{
"mixed types with image",
`[{"type":"text","text":"Look at this"},{"type":"image_url","image_url":{"url":"https://example.com/img.png"}},{"type":"text","text":"image"}]`,
"Look at this image",
},
{
"empty array",
`[]`,
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var mc MessageContent
err := json.Unmarshal([]byte(tt.json), &mc)
require.NoError(t, err)
assert.Equal(t, tt.expected, mc.String())
assert.False(t, mc.isString)
})
}
}
func TestMessageContent_UnmarshalJSON_Invalid(t *testing.T) {
tests := []struct {
name string
json string
}{
{"number", `123`},
{"boolean", `true`},
{"object", `{"key":"value"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var mc MessageContent
err := json.Unmarshal([]byte(tt.json), &mc)
assert.Error(t, err)
assert.Contains(t, err.Error(), "content must be string or array")
})
}
}
func TestMessageContent_UnmarshalJSON_Null(t *testing.T) {
var mc *MessageContent
err := json.Unmarshal([]byte(`null`), &mc)
assert.NoError(t, err)
assert.Nil(t, mc)
}
func TestMessageContent_MarshalJSON_String(t *testing.T) {
mc := NewStringContent("Hello World")
data, err := json.Marshal(mc)
require.NoError(t, err)
assert.Equal(t, `"Hello World"`, string(data))
}
func TestMessageContent_MarshalJSON_Array(t *testing.T) {
mc := NewArrayContent([]OpenAIContentPart{
{Type: "text", Text: "Hello"},
{Type: "text", Text: "World"},
})
data, err := json.Marshal(mc)
require.NoError(t, err)
assert.JSONEq(t, `[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]`, string(data))
}
func TestMessageContent_Roundtrip_String(t *testing.T) {
original := NewStringContent("Test message with \"quotes\" and \nnewlines")
// Marshal
data, err := json.Marshal(original)
require.NoError(t, err)
// Unmarshal
var decoded MessageContent
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
// Verify
assert.Equal(t, original.String(), decoded.String())
assert.Equal(t, original.isString, decoded.isString)
}
func TestMessageContent_Roundtrip_Array(t *testing.T) {
parts := []OpenAIContentPart{
{Type: "text", Text: "Part 1"},
{Type: "text", Text: "Part 2"},
}
original := NewArrayContent(parts)
// Marshal
data, err := json.Marshal(original)
require.NoError(t, err)
// Unmarshal
var decoded MessageContent
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
// Verify
assert.Equal(t, original.String(), decoded.String())
assert.Equal(t, original.isString, decoded.isString)
}
func TestNewStringContent(t *testing.T) {
mc := NewStringContent("test")
assert.NotNil(t, mc)
assert.True(t, mc.isString)
assert.Equal(t, "test", mc.strValue)
assert.Equal(t, "test", mc.String())
}
func TestNewArrayContent(t *testing.T) {
parts := []OpenAIContentPart{
{Type: "text", Text: "Hello"},
}
mc := NewArrayContent(parts)
assert.NotNil(t, mc)
assert.False(t, mc.isString)
assert.Equal(t, parts, mc.arrValue)
assert.Equal(t, "Hello", mc.String())
}
func TestMessageContent_String_EmptyArray(t *testing.T) {
mc := NewArrayContent([]OpenAIContentPart{})
assert.Equal(t, "", mc.String())
}
func TestMessageContent_String_NoTextParts(t *testing.T) {
mc := NewArrayContent([]OpenAIContentPart{
{Type: "image_url", Text: ""},
})
assert.Equal(t, "", mc.String())
}

View File

@ -8,6 +8,7 @@ import (
const (
SettingKeySystemPrompt = "system_prompt"
SettingBlockWords = "block_words"
SettingCopyrightInfo = "本网站由 PandaWiki 提供技术支持"
)
// table: settings

View File

@ -105,3 +105,14 @@ type StatPageHour struct {
func (StatPageHour) TableName() string {
return "stat_page_hours"
}
// NodeStats node表统计数据
type NodeStats struct {
ID int64 `json:"id" gorm:"primaryKey;autoIncrement"`
NodeID string `json:"node_id" gorm:"uniqueIndex"`
PV int64 `json:"pv"`
}
func (NodeStats) TableName() string {
return "node_stats"
}

View File

@ -0,0 +1,29 @@
package domain
import (
"time"
"github.com/chaitin/panda-wiki/consts"
)
// table: settings
type SystemSetting struct {
ID int `json:"id" gorm:"primary_key"`
Key consts.SystemSettingKey `json:"key"`
Value []byte `json:"value" gorm:"type:jsonb"` // JSON string
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (SystemSetting) TableName() string {
return "system_settings"
}
// ModelModeSetting 模型配置结构体
type ModelModeSetting struct {
Mode consts.ModelSettingMode `json:"mode"` // 模式: manual 或 auto
AutoModeAPIKey string `json:"auto_mode_api_key"` // 百智云 API Key
ChatModel string `json:"chat_model"` // 自定义对话模型名称
IsManualEmbeddingUpdated bool `json:"is_manual_embedding_updated"` // 手动模式下嵌入模型是否更新
}

View File

@ -35,6 +35,7 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.4.20
github.com/lib/pq v1.10.9
github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20250508043914-ed57fa5c5274
github.com/mark3labs/mcp-go v0.43.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
github.com/minio/minio-go/v7 v7.0.91
@ -49,6 +50,7 @@ require (
github.com/sbzhu/weworkapi_golang v0.0.0-20210525081115-1799804a7c8d
github.com/silenceper/wechat/v2 v2.1.9
github.com/spf13/viper v1.20.1
github.com/stretchr/testify v1.10.0
github.com/swaggo/echo-swagger v1.4.1
github.com/swaggo/swag v1.16.5
github.com/tidwall/gjson v1.14.1
@ -98,6 +100,7 @@ require (
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250710065240-482d48888f25 // indirect
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250626133421-3c142631c961 // indirect
github.com/cohesion-org/deepseek-go v1.2.8 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@ -135,6 +138,7 @@ require (
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/invopop/yaml v0.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@ -165,6 +169,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
@ -183,6 +188,7 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yargevad/filepathx v1.0.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect

View File

@ -321,6 +321,8 @@ github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/C
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/invopop/yaml v0.1.0 h1:YW3WGUoJEXYfzWBjn00zIlrw7brGVD0fUKRYDPAPhrc=
github.com/invopop/yaml v0.1.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@ -396,6 +398,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.0 h1:lgiKcWMddh4sngbU+hoWOZ9iAe/qp/m851RQpj3Y7jA=
github.com/mark3labs/mcp-go v0.43.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -576,6 +580,8 @@ github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJ
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=

View File

@ -2,6 +2,7 @@ package mq
import (
"context"
"time"
"github.com/robfig/cron/v3"
@ -66,6 +67,16 @@ func NewStatCronHandler(logger *log.Logger, statRepo *pg.StatRepository, statUse
func (h *CronHandler) RemoveOldStatData() {
h.logger.Info("remove old stat data start")
// 零点时同步数据至node_stats持久化
if time.Now().Hour() == 0 {
if err := h.statUseCase.MigrateYesterdayPVToNodeStats(context.Background()); err != nil {
h.logger.Error("migrate yesterday PV data to node_stats failed", log.Error(err))
} else {
h.logger.Info("migrate yesterday PV data to node_stats successful")
}
}
err := h.statRepo.RemoveOldData(context.Background())
if err != nil {
h.logger.Error("remove old stat data failed", log.Error(err))

View File

@ -27,6 +27,7 @@ var ProviderSet = wire.NewSet(
usecase.NewLLMUsecase,
usecase.NewStatUseCase,
usecase.NewNodeUsecase,
usecase.NewModelUsecase,
NewRAGMQHandler,
NewRagDocUpdateHandler,

View File

@ -15,24 +15,24 @@ import (
)
type RAGMQHandler struct {
consumer mq.MQConsumer
logger *log.Logger
rag rag.RAGService
nodeRepo *pg.NodeRepository
kbRepo *pg.KnowledgeBaseRepository
modelRepo *pg.ModelRepository
llmUsecase *usecase.LLMUsecase
consumer mq.MQConsumer
logger *log.Logger
rag rag.RAGService
nodeRepo *pg.NodeRepository
kbRepo *pg.KnowledgeBaseRepository
llmUsecase *usecase.LLMUsecase
modelUsecase *usecase.ModelUsecase
}
func NewRAGMQHandler(consumer mq.MQConsumer, logger *log.Logger, rag rag.RAGService, nodeRepo *pg.NodeRepository, kbRepo *pg.KnowledgeBaseRepository, llmUsecase *usecase.LLMUsecase, modelRepo *pg.ModelRepository) (*RAGMQHandler, error) {
func NewRAGMQHandler(consumer mq.MQConsumer, logger *log.Logger, rag rag.RAGService, nodeRepo *pg.NodeRepository, kbRepo *pg.KnowledgeBaseRepository, llmUsecase *usecase.LLMUsecase, modelUsecase *usecase.ModelUsecase) (*RAGMQHandler, error) {
h := &RAGMQHandler{
consumer: consumer,
logger: logger.WithModule("mq.rag"),
rag: rag,
nodeRepo: nodeRepo,
kbRepo: kbRepo,
llmUsecase: llmUsecase,
modelRepo: modelRepo,
consumer: consumer,
logger: logger.WithModule("mq.rag"),
rag: rag,
nodeRepo: nodeRepo,
kbRepo: kbRepo,
llmUsecase: llmUsecase,
modelUsecase: modelUsecase,
}
if err := consumer.RegisterHandler(domain.VectorTaskTopic, h.HandleNodeContentVectorRequest); err != nil {
return nil, err
@ -134,11 +134,13 @@ func (h *RAGMQHandler) HandleNodeContentVectorRequest(ctx context.Context, msg t
h.logger.Info("node is folder, skip summary", log.Any("node_id", request.NodeID))
return nil
}
model, err := h.modelRepo.GetChatModel(ctx)
model, err := h.modelUsecase.GetChatModel(ctx)
if err != nil {
h.logger.Error("get chat model failed", log.Error(err))
return nil
}
summary, err := h.llmUsecase.SummaryNode(ctx, model, node.Name, node.Content)
if err != nil {
h.logger.Error("summary node content failed", log.Error(err))

View File

@ -49,6 +49,7 @@ func NewShareAppHandler(
})
share.GET("/web/info", h.GetWebAppInfo)
share.GET("/widget/info", h.GetWidgetAppInfo)
share.GET("/wechat/info", h.WechatAppInfo)
// wechat official account
share.GET("/wechat/official_account", h.VerifyUrlWechatOfficialAccount)
@ -101,6 +102,28 @@ func (h *ShareAppHandler) GetWidgetAppInfo(c echo.Context) error {
return h.NewResponseWithData(c, appInfo)
}
// WechatAppInfo
//
// @Summary WechatAppInfo
// @Description WechatAppInfo
// @Tags share_chat
// @Accept json
// @Produce json
// @Param X-KB-ID header string true "kb id"
// @Success 200 {object} domain.Response{data=v1.WechatAppInfoResp}
// @Router /share/v1/app/wechat/info [get]
func (h *ShareAppHandler) WechatAppInfo(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
appInfo, err := h.usecase.GetWechatAppInfo(c.Request().Context(), kbID)
if err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
return h.NewResponseWithData(c, appInfo)
}
func (h *ShareAppHandler) VerifyUrlWechatOfficialAccount(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")

View File

@ -61,6 +61,7 @@ func NewShareChatHandler(
share.POST("/search", h.ChatSearch, h.ShareAuthMiddleware.Authorize)
share.POST("/completions", h.ChatCompletions)
share.POST("/widget", h.ChatWidget)
share.POST("/widget/search", h.WidgetSearch)
share.POST("/feedback", h.FeedBack)
return h
}
@ -131,7 +132,7 @@ func (h *ShareChatHandler) ChatMessage(c echo.Context) error {
//
// @Summary ChatWidget
// @Description ChatWidget
// @Tags share_chat
// @Tags Widget
// @Accept json
// @Produce json
// @Param app_type query string true "app type"
@ -268,7 +269,9 @@ func (h *ShareChatHandler) ChatCompletions(c echo.Context) error {
var lastUserMessage string
for i := len(req.Messages) - 1; i >= 0; i-- {
if req.Messages[i].Role == "user" {
lastUserMessage = req.Messages[i].Content
if req.Messages[i].Content != nil {
lastUserMessage = req.Messages[i].Content.String()
}
break
}
}
@ -345,11 +348,12 @@ func (h *ShareChatHandler) handleOpenAIStreamResponse(c echo.Context, eventCh <-
Index: 0,
Delta: domain.OpenAIMessage{
Role: "assistant",
Content: event.Content,
Content: domain.NewStringContent(event.Content),
},
},
},
}
if err := h.writeOpenAIStreamEvent(c, streamResp); err != nil {
return err
}
@ -397,7 +401,7 @@ func (h *ShareChatHandler) handleOpenAINonStreamResponse(c echo.Context, eventCh
Index: 0,
Message: domain.OpenAIMessage{
Role: "assistant",
Content: content,
Content: domain.NewStringContent(content),
},
FinishReason: "stop",
},
@ -441,7 +445,7 @@ func stringPtr(s string) *string {
return &s
}
// ChatMessage chat search
// ChatSearch searches chat messages in shared knowledge base
//
// @Summary ChatSearch
// @Description ChatSearch
@ -484,3 +488,43 @@ func (h *ShareChatHandler) ChatSearch(c echo.Context) error {
}
return h.NewResponseWithData(c, resp)
}
// WidgetSearch
//
// @Summary WidgetSearch
// @Description WidgetSearch
// @Tags Widget
// @Accept json
// @Produce json
// @Param request body domain.ChatSearchReq true "Comment"
// @Success 200 {object} domain.Response{data=domain.ChatSearchResp}
// @Router /share/v1/chat/widget/search [post]
func (h *ShareChatHandler) WidgetSearch(c echo.Context) error {
var req domain.ChatSearchReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "parse request failed", err)
}
req.KBID = c.Request().Header.Get("X-KB-ID")
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
// validate widget info
widgetAppInfo, err := h.appUsecase.GetWidgetAppInfo(c.Request().Context(), req.KBID)
if err != nil {
h.logger.Error("get widget app info failed", log.Error(err))
return h.sendErrMsg(c, "get app info error")
}
if !widgetAppInfo.Settings.WidgetBotSettings.IsOpen {
return h.sendErrMsg(c, "widget is not open")
}
req.RemoteIP = c.RealIP()
resp, err := h.chatUsecase.Search(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "failed to search docs", err)
}
return h.NewResponseWithData(c, resp)
}

View File

@ -6,7 +6,6 @@ import (
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
@ -157,7 +156,7 @@ func (h *ShareCommentHandler) GetCommentList(c echo.Context) error {
}
// 查询数据库获取所有评论-->0 所有, 12 为需要审核的评论
commentsList, err := h.usecase.GetCommentListByNodeID(ctx, nodeID, consts.GetLicenseEdition(c))
commentsList, err := h.usecase.GetCommentListByNodeID(ctx, nodeID)
if err != nil {
return h.NewResponseWithError(c, "failed to get comment list", err)
}

View File

@ -70,7 +70,7 @@ func (h *ShareNodeHandler) GetNodeList(c echo.Context) error {
// @Param X-KB-ID header string true "kb id"
// @Param id query string true "node id"
// @Param format query string true "format"
// @Success 200 {object} domain.Response
// @Success 200 {object} domain.Response{data=v1.ShareNodeDetailResp}
// @Router /share/v1/node/detail [get]
func (h *ShareNodeHandler) GetNodeDetail(c echo.Context) error {
kbID := c.Request().Header.Get("X-KB-ID")
@ -91,5 +91,15 @@ func (h *ShareNodeHandler) GetNodeDetail(c echo.Context) error {
if err != nil {
return h.NewResponseWithError(c, "failed to get node detail", err)
}
// If the node is a folder, return the list of child nodes
if node.Type == domain.NodeTypeFolder {
childNodes, err := h.usecase.GetNodeReleaseListByParentID(c.Request().Context(), kbID, id, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, "failed to get child nodes", err)
}
node.List = childNodes
}
return h.NewResponseWithData(c, node)
}

View File

@ -5,7 +5,6 @@ import (
"github.com/labstack/echo/v4"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/handler"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/usecase"
@ -37,13 +36,6 @@ func (h *ShareSitemapHandler) GetSitemap(c echo.Context) error {
if kbID == "" {
return h.NewResponseWithError(c, "kb_id is required", nil)
}
appInfo, err := h.appUsecase.ShareGetWebAppInfo(c.Request().Context(), kbID, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, "web app not found", err)
}
if !appInfo.Settings.AutoSitemap {
return h.NewResponseWithError(c, "未开启自动生成站点地图功能", nil)
}
xml, err := h.sitemapUsecase.GetSitemap(c.Request().Context(), kbID)
if err != nil {

View File

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"strconv"
"github.com/labstack/echo/v4"
"github.com/sbzhu/weworkapi_golang/wxbizmsgcrypt"
@ -111,6 +112,12 @@ func (h *ShareWechatHandler) GetWechatAnswer(c echo.Context) error {
return err
}
//2.answer
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "feedback_score", Content: strconv.Itoa(int(conversation.Messages[1].Info.Score))}); err != nil {
return err
}
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "message_id", Content: conversation.Messages[1].ID}); err != nil {
return err
}
if err := h.writeSSEEvent(c, domain.SSEEvent{Type: "answer", Content: conversation.Messages[1].Content}); err != nil {
return err
}
@ -331,6 +338,7 @@ func (h *ShareWechatHandler) VerifyUrlWechatApp(c echo.Context) error {
return c.String(http.StatusOK, string(req))
}
// WechatHandlerApp /share/v1/app/wechat/app
func (h *ShareWechatHandler) WechatHandlerApp(c echo.Context) error {
signature := c.QueryParam("msg_signature")
timestamp := c.QueryParam("timestamp")
@ -383,20 +391,22 @@ func (h *ShareWechatHandler) WechatHandlerApp(c echo.Context) error {
return c.String(http.StatusOK, "")
}
immediateResponse, err := wechatConfig.SendResponse(*msg, "正在思考您的问题,请稍候...")
if err != nil {
return h.NewResponseWithError(c, "Failed to send immediate response", err)
var immediateResponse []byte
if domain.GetBaseEditionLimitation(ctx).AllowAdvancedBot && appInfo.Settings.WeChatAppAdvancedSetting.TextResponseEnable {
immediateResponse, err = wechatConfig.SendResponse(*msg, "正在思考您的问题,请稍候...")
if err != nil {
return h.NewResponseWithError(c, "Failed to send immediate response", err)
}
}
go func(msg *wechat.ReceivedMessage, wechatConfig *wechat.WechatConfig, kbId string) {
ctx := context.Background()
err := h.wechatAppUsecase.Wechat(ctx, msg, wechatConfig, kbId)
go func(ctx context.Context, msg *wechat.ReceivedMessage, wechatConfig *wechat.WechatConfig, kbId string, appInfo *domain.AppDetailResp) {
err := h.wechatAppUsecase.Wechat(ctx, msg, wechatConfig, kbId, &appInfo.Settings.WeChatAppAdvancedSetting)
if err != nil {
h.logger.Error("wechat async failed")
}
}(msg, wechatConfig, kbID)
}(ctx, msg, wechatConfig, kbID, appInfo)
return c.XMLBlob(http.StatusOK, []byte(immediateResponse))
return c.XMLBlob(http.StatusOK, immediateResponse)
}
func (h *ShareWechatHandler) WecomAIBotVerify(c echo.Context) error {

View File

@ -100,8 +100,8 @@ func (h *AppHandler) UpdateApp(c echo.Context) error {
}
ctx := c.Request().Context()
if err := h.usecase.ValidateUpdateApp(ctx, id, &appRequest, consts.GetLicenseEdition(c)); err != nil {
h.logger.Error("UpdateApp", log.Any("req:", appRequest), log.Any("err:", err))
if err := h.usecase.ValidateUpdateApp(ctx, id, &appRequest); err != nil {
h.logger.Error("UpdateApp", log.Any("req:", appRequest.Settings), log.Any("err:", err))
return h.NewResponseWithErrCode(c, domain.ErrCodePermissionDenied)
}

View File

@ -5,6 +5,7 @@ import (
v1 "github.com/chaitin/panda-wiki/api/kb/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
)
// KBUserList
@ -55,8 +56,8 @@ func (h *KnowledgeBaseHandler) KBUserInvite(c echo.Context) error {
return h.NewResponseWithError(c, "validate request failed", err)
}
if consts.GetLicenseEdition(c) != consts.LicenseEditionEnterprise && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "非企业版本只能使用完全控制权限", nil)
if !domain.GetBaseEditionLimitation(c.Request().Context()).AllowAdminPerm && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "当前版本不支持管理员分权控制", nil)
}
err := h.usecase.KBUserInvite(c.Request().Context(), req)
@ -87,8 +88,8 @@ func (h *KnowledgeBaseHandler) KBUserUpdate(c echo.Context) error {
return h.NewResponseWithError(c, "validate request failed", err)
}
if consts.GetLicenseEdition(c) != consts.LicenseEditionEnterprise && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "非企业版本只能使用完全控制权限", nil)
if !domain.GetBaseEditionLimitation(c.Request().Context()).AllowAdminPerm && req.Perm != consts.UserKBPermissionFullControl {
return h.NewResponseWithError(c, "当前版本不支持管理员分权控制", nil)
}
err := h.usecase.UpdateUserKB(c.Request().Context(), req)

View File

@ -91,11 +91,7 @@ func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c echo.Context) error {
return h.NewResponseWithError(c, "ports is required", nil)
}
req.MaxKB = 1
maxKB := c.Get("max_kb")
if maxKB != nil {
req.MaxKB = maxKB.(int)
}
req.MaxKB = domain.GetBaseEditionLimitation(c.Request().Context()).MaxKb
did, err := h.usecase.CreateKnowledgeBase(c.Request().Context(), &req)
if err != nil {
@ -244,6 +240,12 @@ func (h *KnowledgeBaseHandler) DeleteKnowledgeBase(c echo.Context) error {
// @Success 200 {object} domain.Response
// @Router /api/v1/knowledge_base/release [post]
func (h *KnowledgeBaseHandler) CreateKBRelease(c echo.Context) error {
ctx := c.Request().Context()
authInfo := domain.GetAuthInfoFromCtx(ctx)
if authInfo == nil {
return h.NewResponseWithError(c, "authInfo not found in context", nil)
}
req := &domain.CreateKBReleaseReq{}
if err := c.Bind(req); err != nil {
return h.NewResponseWithError(c, "request body is invalid", err)
@ -252,7 +254,7 @@ func (h *KnowledgeBaseHandler) CreateKBRelease(c echo.Context) error {
return h.NewResponseWithError(c, "validate request body failed", err)
}
id, err := h.usecase.CreateKBRelease(c.Request().Context(), req)
id, err := h.usecase.CreateKBRelease(ctx, req, authInfo.UserId)
if err != nil {
return h.NewResponseWithError(c, "create kb release failed", err)
}

View File

@ -40,11 +40,13 @@ func NewModelHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *
group.POST("/check", handler.CheckModel)
group.POST("/provider/supported", handler.GetProviderSupportedModelList)
group.PUT("", handler.UpdateModel)
group.POST("/switch-mode", handler.SwitchMode)
group.GET("/mode-setting", handler.GetModelModeSetting)
return handler
}
// get model list
// GetModelList
//
// @Summary get model list
// @Description get model list
@ -64,7 +66,7 @@ func (h *ModelHandler) GetModelList(c echo.Context) error {
return h.NewResponseWithData(c, models)
}
// create model
// CreateModel
//
// @Summary create model
// @Description create model
@ -83,9 +85,6 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithError(c, "invalid request", err)
}
if consts.GetLicenseEdition(c) == consts.LicenseEditionContributor && req.Provider != domain.ModelProviderBrandBaiZhiCloud {
return h.NewResponseWithError(c, "联创版只能使用百智云模型哦~", nil)
}
ctx := c.Request().Context()
param := domain.ModelParam{}
@ -110,7 +109,7 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithData(c, model)
}
// update model
// UpdateModel
//
// @Description update model
// @Tags model
@ -128,9 +127,6 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
return h.NewResponseWithError(c, "invalid request", err)
}
if consts.GetLicenseEdition(c) == consts.LicenseEditionContributor && req.Provider != domain.ModelProviderBrandBaiZhiCloud {
return h.NewResponseWithError(c, "联创版只能使用百智云模型哦~", nil)
}
ctx := c.Request().Context()
if err := h.usecase.Update(ctx, &req); err != nil {
return h.NewResponseWithError(c, "update model failed", err)
@ -138,7 +134,7 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
return h.NewResponseWithData(c, nil)
}
// check model
// CheckModel
//
// @Summary check model
// @Description check model
@ -211,3 +207,58 @@ func (h *ModelHandler) GetProviderSupportedModelList(c echo.Context) error {
}
return h.NewResponseWithData(c, models)
}
// SwitchMode
//
// @Summary switch mode
// @Description switch model mode between manual and auto
// @Tags model
// @Accept json
// @Produce json
// @Param request body domain.SwitchModeReq true "switch mode request"
// @Success 200 {object} domain.Response{data=domain.SwitchModeResp}
// @Router /api/v1/model/switch-mode [post]
func (h *ModelHandler) SwitchMode(c echo.Context) error {
var req domain.SwitchModeReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "bind request failed", err)
}
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
if err := h.usecase.SwitchMode(ctx, &req); err != nil {
return h.NewResponseWithError(c, err.Error(), err)
}
resp := &domain.SwitchModeResp{
Message: "模式切换成功",
}
return h.NewResponseWithData(c, resp)
}
// GetModelModeSetting
//
// @Summary get model mode setting
// @Description get current model mode setting including mode, API key and chat model
// @Tags model
// @Accept json
// @Produce json
// @Success 200 {object} domain.Response{data=domain.ModelModeSetting}
// @Router /api/v1/model/mode-setting [get]
func (h *ModelHandler) GetModelModeSetting(c echo.Context) error {
ctx := c.Request().Context()
setting, err := h.usecase.GetModelModeSetting(ctx)
if err != nil {
// 如果获取失败,返回默认值(手动模式)
h.logger.Warn("failed to get model mode setting, return default", log.Error(err))
defaultSetting := domain.ModelModeSetting{
Mode: consts.ModelSettingModeManual,
AutoModeAPIKey: "",
ChatModel: "",
}
return h.NewResponseWithData(c, defaultSetting)
}
return h.NewResponseWithData(c, setting)
}

View File

@ -47,6 +47,7 @@ func NewNodeHandler(
group.POST("/batch_move", h.BatchMoveNode)
group.GET("/recommend_nodes", h.RecommendNodes)
group.POST("/restudy", h.NodeRestudy)
// node permission
group.GET("/permission", h.NodePermission)
@ -80,15 +81,13 @@ func (h *NodeHandler) CreateNode(c echo.Context) error {
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
req.MaxNode = 300
if maxNode := c.Get("max_node"); maxNode != nil {
req.MaxNode = maxNode.(int)
}
req.MaxNode = domain.GetBaseEditionLimitation(ctx).MaxNode
id, err := h.usecase.Create(c.Request().Context(), req, authInfo.UserId)
if err != nil {
if errors.Is(err, domain.ErrMaxNodeLimitReached) {
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到联创版或企业版", nil)
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到更高版本", nil)
}
return h.NewResponseWithError(c, "create node failed", err)
}
@ -147,6 +146,7 @@ func (h *NodeHandler) GetNodeDetail(c echo.Context) error {
node, err := h.usecase.GetNodeByKBID(c.Request().Context(), req.ID, req.KbId, req.Format)
if err != nil {
h.logger.Error("get node by kb id failed", log.Error(err))
return h.NewResponseWithError(c, "get node detail failed", err)
}
return h.NewResponseWithData(c, node)
@ -384,3 +384,32 @@ func (h *NodeHandler) NodePermissionEdit(c echo.Context) error {
}
return h.NewResponseWithData(c, nil)
}
// NodeRestudy 文档重新学习
//
// @Tags Node
// @Summary 文档重新学习
// @Description 文档重新学习
// @ID v1-NodeRestudy
// @Accept json
// @Produce json
// @Security bearerAuth
// @Param param body v1.NodeRestudyReq true "para"
// @Success 200 {object} domain.Response{data=v1.NodeRestudyResp}
// @Router /api/v1/node/restudy [post]
func (h *NodeHandler) NodeRestudy(c echo.Context) error {
var req v1.NodeRestudyReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "request params is invalid", err)
}
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request params failed", err)
}
if err := h.usecase.NodeRestudy(c.Request().Context(), &req); err != nil {
return h.NewResponseWithError(c, "node restudy failed", err)
}
return h.NewResponseWithData(c, nil)
}

View File

@ -192,12 +192,29 @@ func (h *UserHandler) ResetPassword(c echo.Context) error {
if err != nil {
return h.NewResponseWithError(c, "failed to get user", err)
}
if user.Account == "admin" && authInfo.UserId == req.ID {
return h.NewResponseWithError(c, "请修改安装目录下 .env 文件中的 ADMIN_PASSWORD并重启 panda-wiki-api 容器使更改生效。", nil)
// 非超级管理员没有改密码权限
if user.Role != consts.UserRoleAdmin {
return h.NewResponseWithErrCode(c, domain.ErrCodePermissionDenied)
}
if user.Account != "admin" && authInfo.UserId != req.ID {
return h.NewResponseWithError(c, "只有管理员可以重置其他用户密码", nil)
if user.Account == "admin" {
// admin 改不了自己的密码
if authInfo.UserId == req.ID {
return h.NewResponseWithError(c, "请修改安装目录下 .env 文件中的 ADMIN_PASSWORD并重启 panda-wiki-api 容器使更改生效。", nil)
}
} else {
targetUser, err := h.usecase.GetUser(ctx, req.ID)
if err != nil {
return h.NewResponseWithError(c, "failed to get target user", err)
}
// 超级管理员不能改其他超级管理员密码
if targetUser.Role == consts.UserRoleAdmin && targetUser.ID != authInfo.UserId {
return h.NewResponseWithError(c, "无法修改其他超级管理员密码", nil)
}
}
err = h.usecase.ResetPassword(c.Request().Context(), &req)
if err != nil {
return h.NewResponseWithError(c, "failed to reset password", err)

View File

@ -15,7 +15,7 @@ type AuthMiddleware interface {
Authorize(next echo.HandlerFunc) echo.HandlerFunc
ValidateUserRole(role consts.UserRole) echo.MiddlewareFunc
ValidateKBUserPerm(role consts.UserKBPermission) echo.MiddlewareFunc
ValidateLicenseEdition(edition consts.LicenseEdition) echo.MiddlewareFunc
ValidateLicenseEdition(edition ...consts.LicenseEdition) echo.MiddlewareFunc
MustGetUserID(c echo.Context) (string, bool)
}

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"io"
"net/http"
"slices"
"strings"
"github.com/golang-jwt/jwt/v5"
@ -194,7 +195,7 @@ func (m *JWTMiddleware) ValidateKBUserPerm(perm consts.UserKBPermission) echo.Mi
}
}
func (m *JWTMiddleware) ValidateLicenseEdition(needEdition consts.LicenseEdition) echo.MiddlewareFunc {
func (m *JWTMiddleware) ValidateLicenseEdition(needEditions ...consts.LicenseEdition) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@ -206,7 +207,7 @@ func (m *JWTMiddleware) ValidateLicenseEdition(needEdition consts.LicenseEdition
})
}
if edition < needEdition {
if !slices.Contains(needEditions, edition) {
return c.JSON(http.StatusForbidden, domain.PWResponse{
Success: false,
Message: "Unauthorized ValidateLicenseEdition",

View File

@ -53,7 +53,7 @@ func (m *MigrationNodeVersion) Execute(tx *gorm.DB) error {
Message: "release all old nodes",
Tag: "init",
NodeIDs: nodeIDs,
})
}, "")
if err != nil {
return fmt.Errorf("create kb release failed: %w", err)
}

View File

@ -96,6 +96,14 @@ func (c *MQConsumer) registerCoreNATSHandler(topic string, handler func(ctx cont
func (c *MQConsumer) registerJetStreamHandler(topic string, handler func(ctx context.Context, msg types.Message) error) error {
consumerName := domain.TopicConsumerName[topic]
// Choose deliver policy based on topic
var deliverPolicy nats.SubOpt
if topic == domain.VectorTaskTopic {
deliverPolicy = nats.DeliverNew()
} else {
deliverPolicy = nats.DeliverAll()
}
sub, err := c.js.Subscribe(topic, func(msg *nats.Msg) {
c.logger.Debug("received message via JetStream",
log.String("topic", topic),
@ -113,7 +121,7 @@ func (c *MQConsumer) registerJetStreamHandler(topic string, handler func(ctx con
log.String("topic", topic),
log.Error(err))
}
}, nats.DeliverAll(), nats.AckExplicit(), nats.Durable(consumerName), nats.ConsumerName(consumerName))
}, deliverPolicy, nats.AckExplicit(), nats.Durable(consumerName), nats.ConsumerName(consumerName))
if err != nil {
c.logger.Error("failed to subscribe to topic via JetStream",
log.String("topic", topic),

View File

@ -14,11 +14,14 @@ import (
"github.com/google/uuid"
"github.com/sbzhu/weworkapi_golang/wxbizmsgcrypt"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/pkg/bot"
)
const wechatMessageMaxBytes = 2000
func NewWechatConfig(ctx context.Context, CorpID, Token, EncodingAESKey string, kbid string, secret string, agentID string, logger *log.Logger) (*WechatConfig, error) {
return &WechatConfig{
Ctx: ctx,
@ -49,22 +52,30 @@ func (cfg *WechatConfig) VerifyUrlWechatAPP(signature, timestamp, nonce, echostr
return decryptEchoStr, nil
}
func (cfg *WechatConfig) Wechat(msg ReceivedMessage, getQA bot.GetQAFun, userinfo *UserInfo) error {
func (cfg *WechatConfig) Wechat(msg ReceivedMessage, getQA bot.GetQAFun, userinfo *UserInfo, useTextResponse bool, weChatAppAdvancedSetting *domain.WeChatAppAdvancedSetting) error {
token, err := cfg.GetAccessToken()
if err != nil {
return err
}
err = cfg.ProcessMessage(msg, getQA, token, userinfo)
if err != nil {
cfg.logger.Error("send to ai failed!", log.Error(err))
return err
if useTextResponse {
err = cfg.ProcessTextMessage(msg, getQA, token, userinfo, weChatAppAdvancedSetting.DisclaimerContent)
if err != nil {
cfg.logger.Error("send to ai failed!", log.Error(err))
return err
}
} else {
if err := cfg.ProcessUrlMessage(msg, getQA, token, userinfo); err != nil {
cfg.logger.Error("send to ai failed!", log.Error(err))
return err
}
}
return nil
}
// forwardToBackend
func (cfg *WechatConfig) ProcessMessage(msg ReceivedMessage, GetQA bot.GetQAFun, token string, userinfo *UserInfo) error {
func (cfg *WechatConfig) ProcessUrlMessage(msg ReceivedMessage, GetQA bot.GetQAFun, token string, userinfo *UserInfo) error {
// 1. get ai channel
id, err := uuid.NewV7()
if err != nil {
@ -73,7 +84,7 @@ func (cfg *WechatConfig) ProcessMessage(msg ReceivedMessage, GetQA bot.GetQAFun,
}
conversationID := id.String()
wccontent, err := GetQA(cfg.Ctx, msg.Content, domain.ConversationInfo{
contentChan, err := GetQA(cfg.Ctx, msg.Content, domain.ConversationInfo{
UserInfo: domain.UserInfo{
UserID: userinfo.UserID,
NickName: userinfo.Name,
@ -93,7 +104,7 @@ func (cfg *WechatConfig) ProcessMessage(msg ReceivedMessage, GetQA bot.GetQAFun,
}
domain.ConversationManager.Store(conversationID, state)
go cfg.SendQuestionToAI(conversationID, wccontent)
go cfg.SendQuestionToAI(conversationID, contentChan)
}
baseUrl, err := cfg.WeRepo.GetWechatBaseURL(cfg.Ctx, cfg.kbID)
@ -112,6 +123,54 @@ func (cfg *WechatConfig) ProcessMessage(msg ReceivedMessage, GetQA bot.GetQAFun,
return nil
}
func (cfg *WechatConfig) ProcessTextMessage(msg ReceivedMessage, GetQA bot.GetQAFun, token string, userinfo *UserInfo, disclaimerContent string) error {
// 1. get ai channel
id, err := uuid.NewV7()
if err != nil {
cfg.logger.Error("failed to generate conversation uuid", log.Error(err))
id = uuid.New()
}
conversationID := id.String()
contentChan, err := GetQA(cfg.Ctx, msg.Content, domain.ConversationInfo{
UserInfo: domain.UserInfo{
UserID: userinfo.UserID,
NickName: userinfo.Name,
From: domain.MessageFromPrivate,
}}, conversationID)
if err != nil {
return err
}
var fullResponse string
for content := range contentChan {
fullResponse += content
if len([]byte(fullResponse)) > wechatMessageMaxBytes { // wechat limit 2048 byte
if _, _, err := cfg.SendResponseToUser(fullResponse, msg.FromUserName, token); err != nil {
return err
}
fullResponse = ""
}
}
if len([]byte(fullResponse+disclaimerContent)) > wechatMessageMaxBytes {
if _, _, err := cfg.SendResponseToUser(fullResponse, msg.FromUserName, token); err != nil {
return err
}
if _, _, err := cfg.SendResponseToUser(disclaimerContent, msg.FromUserName, token); err != nil {
return err
}
} else {
if disclaimerContent != "" {
fullResponse += fmt.Sprintf("\n%s", disclaimerContent)
}
if _, _, err := cfg.SendResponseToUser(fullResponse, msg.FromUserName, token); err != nil {
return err
}
}
return nil
}
// SendResponseToUser
func (cfg *WechatConfig) SendURLToUser(touser, question, token, conversationID, baseUrl string) (int, string, error) {
msgData := map[string]interface{}{
@ -121,7 +180,7 @@ func (cfg *WechatConfig) SendURLToUser(touser, question, token, conversationID,
"textcard": map[string]interface{}{
"title": question,
"description": "<div class = \"highlight\">本回答由 PandaWiki 基于 AI 生成,仅供参考。</div>",
"url": fmt.Sprintf("%s/h5-chat?id=%s", baseUrl, conversationID),
"url": fmt.Sprintf("%s/h5-chat?id=%s&source_type=%s", baseUrl, conversationID, consts.SourceTypeWechatBot),
},
}
@ -150,7 +209,6 @@ func (cfg *WechatConfig) SendURLToUser(touser, question, token, conversationID,
return result.Errcode, result.Errmsg, nil
}
// SendResponseToUser
func (cfg *WechatConfig) SendResponseToUser(response string, touser string, token string) (int, string, error) {
msgData := map[string]interface{}{
@ -184,6 +242,9 @@ func (cfg *WechatConfig) SendResponseToUser(response string, touser string, toke
if err := json.Unmarshal(body, &result); err != nil {
return 0, "", err
}
if result.Errcode != 0 {
return result.Errcode, result.Errmsg, fmt.Errorf("wechat Api failed : %s (code: %d)", result.Errmsg, result.Errcode)
}
return result.Errcode, result.Errmsg, nil
}
@ -235,7 +296,7 @@ func (cfg *WechatConfig) GetAccessToken() (string, error) {
defer tokenCache.Mutex.Unlock()
if tokenCache.AccessToken != "" && time.Now().Before(tokenCache.TokenExpire) {
cfg.logger.Info("access token has existed and is valid")
cfg.logger.Debug("access token has existed and is valid")
return tokenCache.AccessToken, nil
}

View File

@ -297,7 +297,7 @@ func (cfg *WechatServiceConfig) GetAccessToken() (string, error) {
defer tokenCache.Mutex.Unlock()
if tokenCache.AccessToken != "" && time.Now().Before(tokenCache.TokenExpire) {
cfg.logger.Info("access token has existed and is valid")
cfg.logger.Debug("access token has existed and is valid")
return tokenCache.AccessToken, nil
}

View File

@ -18,7 +18,8 @@ import (
const (
// AuthURL api doc https://developer.work.weixin.qq.com/document/path/98152
AuthURL = "https://login.work.weixin.qq.com/wwlogin/sso/login"
AuthWebURL = "https://login.work.weixin.qq.com/wwlogin/sso/login"
AuthAPPURL = "https://open.weixin.qq.com/connect/oauth2/authorize"
TokenURL = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
UserInfoURL = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"
UserDetailURL = "https://qyapi.weixin.qq.com/cgi-bin/user/get"
@ -29,11 +30,6 @@ const (
callbackPath = "/share/pro/v1/openapi/wecom/callback"
)
var oauthEndpoint = oauth2.Endpoint{
AuthURL: AuthURL,
TokenURL: TokenURL,
}
// Client 企业微信客户端
type Client struct {
context context.Context
@ -115,17 +111,24 @@ type UserListResponse struct {
} `json:"userlist"`
}
func NewClient(ctx context.Context, logger *log.Logger, corpID, corpSecret, agentID, redirectURI string, cache *cache.Cache) (*Client, error) {
func NewClient(ctx context.Context, logger *log.Logger, corpID, corpSecret, agentID, redirectURI string, cache *cache.Cache, isApp bool) (*Client, error) {
redirectURL, _ := url.Parse(redirectURI)
redirectURL.Path = callbackPath
redirectURI = redirectURL.String()
authUrl := AuthWebURL
if isApp {
authUrl = AuthAPPURL
}
oauthConfig := &oauth2.Config{
ClientID: corpID,
ClientSecret: corpSecret,
RedirectURL: redirectURI,
Endpoint: oauthEndpoint,
Scopes: []string{"snsapi_privateinfo"},
Endpoint: oauth2.Endpoint{
AuthURL: authUrl,
TokenURL: TokenURL,
},
Scopes: []string{"snsapi_privateinfo"},
}
return &Client{
@ -150,7 +153,11 @@ func (c *Client) GenerateAuthURL(state string) string {
params.Set("agentid", c.agentID)
params.Set("state", state)
return fmt.Sprintf("%s?%s", AuthURL, params.Encode())
authUrl := fmt.Sprintf("%s?%s", c.oauthConfig.Endpoint.AuthURL, params.Encode())
if c.oauthConfig.Endpoint.AuthURL == AuthAPPURL {
authUrl += "#wechat_redirect"
}
return authUrl
}
// GetAccessToken 获取企业微信访问令牌

@ -1 +1 @@
Subproject commit e7bf0a030242425c83291b369770c113fd503a5a
Subproject commit 530a059db3c8b1ef86c3a43eaf70d75c46c75df9

View File

@ -2,5 +2,7 @@ package backend
import (
_ "github.com/jinzhu/copier"
_ "github.com/mark3labs/mcp-go/mcp"
_ "github.com/mark3labs/mcp-go/server"
_ "google.golang.org/protobuf/types/known/emptypb"
)

View File

@ -300,8 +300,8 @@ func (r *AuthRepo) GetOrCreateAuth(ctx context.Context, auth *domain.Auth, sourc
return err
}
if int(count) >= licenseEdition.GetMaxAuth(sourceType) {
return fmt.Errorf("exceed max auth limit for kb %s, current count: %d, max limit: %d", auth.KBID, count, licenseEdition.GetMaxAuth(sourceType))
if int(count) >= domain.GetBaseEditionLimitation(ctx).MaxSSOUser {
return fmt.Errorf("exceed max auth limit for kb %s, current count: %d, max limit: %d", auth.KBID, count, domain.GetBaseEditionLimitation(ctx).MaxSSOUser)
}
auth.LastLoginTime = time.Now()

View File

@ -26,12 +26,12 @@ func (r *CommentRepository) CreateComment(ctx context.Context, comment *domain.C
return nil
}
func (r *CommentRepository) GetCommentList(ctx context.Context, nodeID string, edition consts.LicenseEdition) ([]*domain.ShareCommentListItem, int64, error) {
func (r *CommentRepository) GetCommentList(ctx context.Context, nodeID string) ([]*domain.ShareCommentListItem, int64, error) {
// 按照时间排序来查询node_id的comments
var comments []*domain.ShareCommentListItem
query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("node_id = ?", nodeID)
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionEnterprise {
if domain.GetBaseEditionLimitation(ctx).AllowCommentAudit {
query = query.Where("status = ?", domain.CommentStatusAccepted) //accepted
}
@ -50,14 +50,14 @@ func (r *CommentRepository) GetCommentList(ctx context.Context, nodeID string, e
func (r *CommentRepository) GetCommentListByKbID(ctx context.Context, req *domain.CommentListReq, edition consts.LicenseEdition) ([]*domain.CommentListItem, int64, error) {
comments := []*domain.CommentListItem{}
query := r.db.Model(&domain.Comment{}).Where("comments.kb_id = ?", req.KbID)
query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("comments.kb_id = ?", req.KbID)
var count int64
if req.Status == nil {
if err := query.Count(&count).Error; err != nil {
return nil, 0, err
}
} else {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionEnterprise {
if domain.GetBaseEditionLimitation(ctx).AllowCommentAudit {
query = query.Where("comments.status = ?", *req.Status)
}
// 按照时间排序来查询kb_id的comments ->reject pending accepted
@ -84,7 +84,7 @@ func (r *CommentRepository) GetCommentListByKbID(ctx context.Context, req *domai
func (r *CommentRepository) DeleteCommentList(ctx context.Context, commentID []string) error {
// 批量删除指定id的comment,获取删除的总的数量、
query := r.db.Model(&domain.Comment{}).Where("id IN (?)", commentID)
query := r.db.WithContext(ctx).Model(&domain.Comment{}).Where("id IN (?)", commentID)
if err := query.Delete(&domain.Comment{}).Error; err != nil {
return err

View File

@ -263,3 +263,27 @@ func (r *ConversationRepository) GetConversationDistributionByHour(ctx context.C
return counts, nil
}
func (r *ConversationRepository) GetConversationCountByAppType(ctx context.Context) (map[domain.AppType]int64, error) {
type row struct {
AppType int `gorm:"column:app_type"`
Count int64 `gorm:"column:count"`
}
var rows []row
if err := r.db.WithContext(ctx).
Model(&domain.Conversation{}).
Joins("JOIN apps ON conversations.app_id = apps.id").
Select("apps.type as app_type, COUNT(*) as count").
Group("apps.type").
Find(&rows).Error; err != nil {
return nil, err
}
result := make(map[domain.AppType]int64)
for _, t := range domain.AppTypes {
result[t] = 0
}
for _, rrow := range rows {
result[domain.AppType(rrow.AppType)] = rrow.Count
}
return result, nil
}

View File

@ -169,7 +169,7 @@ func (r *KnowledgeBaseRepository) SyncKBAccessSettingsToCaddy(ctx context.Contex
{
"match": []map[string]any{
{
"path": []string{"/share/v1/chat/completions", "/share/v1/app/wechat/app", "/share/v1/app/wechat/service", "/sitemap.xml", "/share/v1/app/wechat/official_account", "/share/v1/app/wechat/service/answer"},
"path": []string{"/share/v1/chat/completions", "/share/v1/app/wechat/app", "/share/v1/app/wechat/service", "/sitemap.xml", "/share/v1/app/wechat/official_account", "/share/v1/app/wechat/service/answer", "/mcp"},
},
},
"handle": []map[string]any{

25
backend/repo/pg/mcp.go Normal file
View File

@ -0,0 +1,25 @@
package pg
import (
"context"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/store/pg"
)
type MCPRepository struct {
db *pg.DB
logger *log.Logger
}
func NewMCPRepository(db *pg.DB, logger *log.Logger) *MCPRepository {
return &MCPRepository{db: db, logger: logger}
}
func (r *MCPRepository) GetMCPCallCount(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Table("mcp_calls").Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@ -16,6 +16,7 @@ import (
"github.com/samber/lo/mutable"
v1 "github.com/chaitin/panda-wiki/api/node/v1"
shareV1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
@ -156,6 +157,32 @@ func (r *NodeRepository) GetLatestNodeReleaseByNodeIDs(ctx context.Context, kbID
return nodeReleases, nil
}
func (r *NodeRepository) GetNodeReleasePublisherMap(ctx context.Context, kbID string) (map[string]string, error) {
type Result struct {
NodeID string `gorm:"column:node_id"`
PublisherID string `gorm:"column:publisher_id"`
}
var results []Result
if err := r.db.WithContext(ctx).
Model(&domain.NodeRelease{}).
Select("node_id, publisher_id").
Where("kb_id = ?", kbID).
Where("node_releases.doc_id != '' ").
Find(&results).Error; err != nil {
return nil, err
}
publisherMap := make(map[string]string)
for _, result := range results {
if result.PublisherID != "" {
publisherMap[result.NodeID] = result.PublisherID
}
}
return publisherMap, nil
}
func (r *NodeRepository) UpdateNodeContent(ctx context.Context, req *domain.UpdateNodeReq, userId string) error {
// Use transaction to ensure data consistency
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
@ -258,8 +285,11 @@ func (r *NodeRepository) GetByID(ctx context.Context, id, kbId string) (*v1.Node
var node *v1.NodeDetailResp
if err := r.db.WithContext(ctx).
Model(&domain.Node{}).
Where("id = ?", id).
Where("kb_id = ?", kbId).
Select("nodes.*, creator.id as creator_id, creator.account as creator_account, editor.id as editor_id, editor.account as editor_account").
Joins("left join users creator on creator.id = nodes.creator_id").
Joins("left join users editor on editor.id = nodes.editor_id").
Where("nodes.id = ?", id).
Where("nodes.kb_id = ?", kbId).
First(&node).Error; err != nil {
return nil, err
}
@ -425,6 +455,20 @@ func (r *NodeRepository) GetLatestNodeReleaseByNodeID(ctx context.Context, nodeI
return nodeRelease, nil
}
func (r *NodeRepository) GetLatestNodeReleaseWithPublishAccount(ctx context.Context, nodeID string) (*domain.NodeReleaseWithPublisher, error) {
var nodeRelease *domain.NodeReleaseWithPublisher
if err := r.db.WithContext(ctx).
Model(&domain.NodeRelease{}).
Select("node_releases.id, node_releases.publisher_id, users.account as publisher_account").
Joins("left join users on users.id = node_releases.publisher_id").
Where("node_releases.node_id = ?", nodeID).
Order("node_releases.updated_at DESC").
Find(&nodeRelease).Error; err != nil {
return nil, err
}
return nodeRelease, nil
}
// GetNodeReleaseWithDirPathByID gets a node release by ID and includes its directory path
func (r *NodeRepository) GetNodeReleaseWithDirPathByID(ctx context.Context, id string) (*domain.NodeReleaseWithDirPath, error) {
// First get the node release
@ -665,14 +709,14 @@ func (r *NodeRepository) GetNodeReleaseListByKBID(ctx context.Context, kbID stri
Where("kb_release_node_releases.kb_id = ?", kbID).
Where("kb_release_node_releases.release_id = ?", kbRelease.ID).
Where("nodes.permissions->>'visible' != ?", consts.NodeAccessPermClosed).
Select("node_releases.node_id as id, node_releases.name, node_releases.type, node_releases.parent_id, node_releases.position, node_releases.meta->>'emoji' as emoji, node_releases.updated_at, nodes.permissions").
Select("node_releases.node_id as id, node_releases.name, node_releases.type, node_releases.parent_id, nodes.position, node_releases.meta->>'emoji' as emoji, node_releases.updated_at, nodes.permissions, nodes.meta").
Find(&nodes).Error; err != nil {
return nil, err
}
return nodes, nil
}
func (r *NodeRepository) GetNodeReleaseDetailByKBIDAndID(ctx context.Context, kbID, id string) (*v1.NodeDetailResp, error) {
func (r *NodeRepository) GetNodeReleaseDetailByKBIDAndID(ctx context.Context, kbID, id string) (*shareV1.ShareNodeDetailResp, error) {
// get kb release
var kbRelease *domain.KBRelease
if err := r.db.WithContext(ctx).
@ -683,16 +727,16 @@ func (r *NodeRepository) GetNodeReleaseDetailByKBIDAndID(ctx context.Context, kb
return nil, err
}
var node *v1.NodeDetailResp
var node *shareV1.ShareNodeDetailResp
if err := r.db.WithContext(ctx).
Model(&domain.KBReleaseNodeRelease{}).
Select("node_releases.*, nodes.permissions, nodes.creator_id").
Joins("LEFT JOIN node_releases ON node_releases.id = kb_release_node_releases.node_release_id").
Joins("LEFT JOIN nodes ON nodes.id = kb_release_node_releases.node_id").
Where("kb_release_node_releases.release_id = ?", kbRelease.ID).
Where("node_releases.node_id = ?", id).
Where("node_releases.kb_id = ?", kbID).
Where("nodes.permissions->>'visitable' != ?", consts.NodeAccessPermClosed).
Select("node_releases.*, nodes.permissions").
First(&node).Error; err != nil {
return nil, err
}
@ -810,7 +854,7 @@ func (r *NodeRepository) TraverseNodesByCursor(ctx context.Context, callback fun
}
// CreateNodeReleases create node releases
func (r *NodeRepository) CreateNodeReleases(ctx context.Context, kbID string, nodeIDs []string) ([]string, error) {
func (r *NodeRepository) CreateNodeReleases(ctx context.Context, kbID, userId string, nodeIDs []string) ([]string, error) {
releaseIDs := make([]string, 0)
if err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// update node status to published and return node ids
@ -829,17 +873,19 @@ func (r *NodeRepository) CreateNodeReleases(ctx context.Context, kbID string, no
for i, updatedNode := range updatedNodes {
// create node release
nodeRelease := &domain.NodeRelease{
ID: uuid.New().String(),
KBID: kbID,
NodeID: updatedNode.ID,
Type: updatedNode.Type,
Name: updatedNode.Name,
Meta: updatedNode.Meta,
Content: updatedNode.Content,
ParentID: updatedNode.ParentID,
Position: updatedNode.Position,
CreatedAt: updatedNode.CreatedAt,
UpdatedAt: time.Now(),
ID: uuid.New().String(),
KBID: kbID,
PublisherId: userId,
EditorId: updatedNode.EditorId,
NodeID: updatedNode.ID,
Type: updatedNode.Type,
Name: updatedNode.Name,
Meta: updatedNode.Meta,
Content: updatedNode.Content,
ParentID: updatedNode.ParentID,
Position: updatedNode.Position,
CreatedAt: updatedNode.CreatedAt,
UpdatedAt: time.Now(),
}
nodeReleases[i] = nodeRelease
releaseIDs = append(releaseIDs, nodeRelease.ID)
@ -1132,3 +1178,14 @@ func (r *NodeRepository) GetNodeIdsByDocIds(ctx context.Context, docIds []string
return docToNodeMap, nil
}
func (r *NodeRepository) GetNodeCount(ctx context.Context) (int, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&domain.Node{}).
Count(&count).Error
if err != nil {
return 0, err
}
return int(count), nil
}

View File

@ -0,0 +1,39 @@
package pg
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/utils"
)
func (r *NodeRepository) GetNodeStatsByNodeId(ctx context.Context, nodeId string) (*domain.NodeStats, error) {
var nodeStats *domain.NodeStats
if err := r.db.WithContext(ctx).
Model(&domain.NodeStats{}).
Where("node_id = ?", nodeId).
First(&nodeStats).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
nodeStats = &domain.NodeStats{
ID: 0,
NodeID: nodeId,
PV: 0,
}
} else {
return nil, err
}
}
var todayStats int64
if err := r.db.WithContext(ctx).Model(&domain.StatPage{}).
Where("created_at >= ?", utils.GetTimeHourOffset(-24)).
Where("node_id = ?", nodeId).Count(&todayStats).Error; err != nil {
return nil, err
}
nodeStats.PV += todayStats
return nodeStats, nil
}

View File

@ -23,4 +23,6 @@ var ProviderSet = wire.NewSet(
NewAuthRepo,
NewWechatRepository,
NewAPITokenRepo,
NewSystemSettingRepo,
NewMCPRepository,
)

View File

@ -3,6 +3,9 @@ package pg
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
v1 "github.com/chaitin/panda-wiki/api/stat/v1"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/store/cache"
@ -156,3 +159,46 @@ func (r *StatRepository) RemoveOldData(ctx context.Context) error {
}
return nil
}
// GetYesterdayPVByNode 获取昨天的PV数据按node_id分组
func (r *StatRepository) GetYesterdayPVByNode(ctx context.Context) (map[string]int64, error) {
type PVResult struct {
NodeID string
Count int64
}
var results []PVResult
if err := r.db.WithContext(ctx).Model(&domain.StatPage{}).
Where("created_at < ?", utils.GetTimeHourOffset(0)).
Where("created_at >= ?", utils.GetTimeHourOffset(-24)).
Where("node_id != ?", "").
Group("node_id").
Select("node_id, COUNT(*) as count").
Find(&results).Error; err != nil {
return nil, err
}
pvMap := make(map[string]int64)
for _, result := range results {
pvMap[result.NodeID] = result.Count
}
return pvMap, nil
}
// UpsertNodeStats 插入或更新node_stats表
func (r *StatRepository) UpsertNodeStats(ctx context.Context, nodeID string, pvCount int64) error {
nodeStats := &domain.NodeStats{
NodeID: nodeID,
PV: pvCount,
}
// 使用GORM的Clauses进行upsert操作
return r.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "node_id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"pv": gorm.Expr("node_stats.pv + ?", pvCount),
}),
}).
Create(nodeStats).Error
}

View File

@ -0,0 +1,35 @@
package pg
import (
"context"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/store/pg"
)
type SystemSettingRepo struct {
db *pg.DB
logger *log.Logger
}
func NewSystemSettingRepo(db *pg.DB, logger *log.Logger) *SystemSettingRepo {
return &SystemSettingRepo{
db: db,
logger: logger.WithModule("repo.pg.system_setting"),
}
}
func (r *SystemSettingRepo) GetSystemSetting(ctx context.Context, key string) (*domain.SystemSetting, error) {
var setting domain.SystemSetting
result := r.db.WithContext(ctx).Where("key = ?", key).First(&setting)
if result.Error != nil {
return nil, result.Error
}
return &setting, nil
}
func (r *SystemSettingRepo) UpdateSystemSetting(ctx context.Context, key, value string) error {
return r.db.WithContext(ctx).Model(&domain.SystemSetting{}).Where("key = ?", key).Update("value", value).Error
}

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"github.com/samber/lo"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@ -59,18 +60,14 @@ func (r *UserRepository) CreateUser(ctx context.Context, user *domain.User, edit
}
user.Password = string(hashedPassword)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionFree {
var count int64
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
return err
}
if edition == consts.LicenseEditionFree && count >= 1 {
return errors.New("free edition only allows 1 user")
}
if edition == consts.LicenseEditionContributor && count >= 5 {
return errors.New("contributor edition only allows 5 user")
}
var count int64
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
return err
}
if count >= domain.GetBaseEditionLimitation(ctx).MaxAdmin {
return fmt.Errorf("exceed max admin limit, current count: %d, max limit: %d", count, domain.GetBaseEditionLimitation(ctx).MaxAdmin)
}
if err := tx.Create(user).Error; err != nil {
return err
}
@ -113,6 +110,22 @@ func (r *UserRepository) ListUsers(ctx context.Context) ([]v1.UserListItemResp,
return users, nil
}
func (r *UserRepository) GetUsersAccountMap(ctx context.Context) (map[string]string, error) {
var users []v1.UserListItemResp
err := r.db.WithContext(ctx).
Model(&domain.User{}).
Find(&users).Error
if err != nil {
return nil, err
}
m := lo.SliceToMap(users, func(user v1.UserListItemResp) (string, string) {
return user.ID, user.Account
})
return m, nil
}
func (r *UserRepository) UpdateUserPassword(ctx context.Context, userID string, newPassword string) error {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {

View File

@ -0,0 +1,2 @@
ALTER TABLE node_releases DROP COLUMN IF EXISTS publisher_id;
ALTER TABLE node_releases DROP COLUMN IF EXISTS editor_id;

View File

@ -0,0 +1,2 @@
ALTER TABLE node_releases ADD COLUMN IF NOT EXISTS publisher_id text default '';
ALTER TABLE node_releases ADD COLUMN IF NOT EXISTS editor_id text default '';

View File

@ -0,0 +1,4 @@
-- Drop settings table
DROP TABLE IF EXISTS system_settings;
-- drop index
DROP INDEX IF EXISTS idx_system_settings_key;

View File

@ -0,0 +1,30 @@
-- Create settings table
CREATE TABLE IF NOT EXISTS system_settings (
id SERIAL PRIMARY KEY,
key TEXT NOT NULL,
value JSONB NOT NULL,
description TEXT,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_uniq_system_settings_key ON system_settings(key);
-- Insert model_setting_mode setting
-- If there are existing knowledge bases, set mode to 'manual', otherwise set to 'auto'
INSERT INTO system_settings (key, value, description)
SELECT
'model_setting_mode',
jsonb_build_object(
'mode', CASE
WHEN EXISTS (SELECT 1 FROM knowledge_bases LIMIT 1) THEN 'manual'
ELSE 'auto'
END,
'auto_mode_api_key', '',
'chat_model', '',
'is_manual_embedding_updated', false
),
'Model setting mode configuration'
WHERE NOT EXISTS (
SELECT 1 FROM system_settings WHERE key = 'model_setting_mode'
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS mcp_calls;

View File

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS mcp_calls (
id SERIAL PRIMARY KEY,
mcp_session_id TEXT NOT NULL,
kb_id TEXT NOT NULL,
remote_ip TEXT,
initialize_req JSONB,
initialize_resp JSONB,
tool_call_req JSONB,
tool_call_resp TEXT,
created_at timestamptz NOT NULL DEFAULT NOW()
);

View File

@ -0,0 +1 @@
DROP TABLE IF EXISTS node_stats;

View File

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS node_stats (
id BIGSERIAL PRIMARY KEY,
node_id TEXT NOT NULL UNIQUE,
pv BIGINT NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT NOW()
);

View File

@ -28,6 +28,8 @@ func NewHTML2MDConverter() *converter.Converter {
conv.Register.RendererFor("span", converter.TagTypeInline, renderAttachment, converter.PriorityEarly)
// task list
conv.Register.RendererFor("ul", converter.TagTypeBlock, renderTaskList, converter.PriorityEarly)
// flowchart/diagram to mermaid code block
conv.Register.RendererFor("div", converter.TagTypeBlock, renderFlowchart, converter.PriorityEarly)
return conv
}
@ -126,3 +128,40 @@ func getTextFromTaskItem(node *html.Node) string {
extractText(node)
return strings.TrimSpace(textContent.String())
}
// renderFlowchart 将流程图 div 转换为 Mermaid 代码块
func renderFlowchart(ctx converter.Context, w converter.Writer, node *html.Node) converter.RenderStatus {
if node.Type != html.ElementNode || node.Data != "div" {
return converter.RenderTryNext
}
// 仅处理 data-type="flow" 的 div
dataType, ok := dom.GetAttribute(node, "data-type")
if !ok || dataType != "flow" {
return converter.RenderTryNext
}
// 提取 data-code 属性
code, hasCode := dom.GetAttribute(node, "data-code")
if !hasCode || strings.TrimSpace(code) == "" {
return converter.RenderTryNext
}
// 解码 HTML 实体
code = html.UnescapeString(code)
// 处理转义的换行符
code = strings.ReplaceAll(code, "\\n", "\n")
// 写入 Mermaid 代码块
if _, err := w.WriteString("\n```mermaid\n"); err != nil {
return converter.RenderTryNext
}
if _, err := w.WriteString(code); err != nil {
return converter.RenderTryNext
}
if _, err := w.WriteString("\n```\n\n"); err != nil {
return converter.RenderTryNext
}
return converter.RenderSuccess
}

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"os"
"path/filepath"
@ -13,8 +14,12 @@ import (
"github.com/google/uuid"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/repo/pg"
"github.com/chaitin/panda-wiki/usecase"
)
const (
@ -24,27 +29,43 @@ const (
// Client is the telemetry client
type Client struct {
baseURL string
httpClient *http.Client
machineID string
firstReport bool
stopChan chan struct{}
logger *log.Logger
repo *pg.KnowledgeBaseRepository
baseURL string
httpClient *http.Client
machineID string
firstReport bool
stopChan chan struct{}
logger *log.Logger
repo *pg.KnowledgeBaseRepository
modelUsecase *usecase.ModelUsecase
userUsecase *usecase.UserUsecase
nodeRepo *pg.NodeRepository
conversationRepo *pg.ConversationRepository
mcpRepo *pg.MCPRepository
cfg *config.Config
aesKey string
}
// NewClient creates a new telemetry client
func NewClient(logger *log.Logger, repo *pg.KnowledgeBaseRepository) (*Client, error) {
func NewClient(logger *log.Logger, repo *pg.KnowledgeBaseRepository, modelUsecase *usecase.ModelUsecase, userUsecase *usecase.UserUsecase, nodeRepo *pg.NodeRepository, conversationRepo *pg.ConversationRepository, mcpRepo *pg.MCPRepository, cfg *config.Config) (*Client, error) {
baseURL := "https://baizhi.cloud/api/public/data/report"
aesKey := "SZ3SDP38y9Gg2c6yHdLPgDeX"
client := &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
firstReport: true,
stopChan: make(chan struct{}),
logger: logger.WithModule("telemetry"),
repo: repo,
firstReport: true,
stopChan: make(chan struct{}),
logger: logger.WithModule("telemetry"),
repo: repo,
modelUsecase: modelUsecase,
userUsecase: userUsecase,
nodeRepo: nodeRepo,
conversationRepo: conversationRepo,
mcpRepo: mcpRepo,
cfg: cfg,
aesKey: aesKey,
}
// get or create machine ID
@ -139,18 +160,50 @@ func (c *Client) startPeriodicReport() {
ticker := time.NewTicker(reportInterval)
defer ticker.Stop()
dataTimer := time.NewTimer(c.nextReportDataDelay())
defer dataTimer.Stop()
for {
select {
case <-ticker.C:
if err := c.reportInstallation(); err != nil {
c.logger.Error("periodic report installation", log.Error(err))
}
case <-dataTimer.C:
if err := c.reportData(); err != nil {
c.logger.Error("periodic report data", log.Error(err))
}
dataTimer.Reset(c.nextReportDataDelay())
case <-c.stopChan:
return
}
}
}
// 计算下一次数据上报的延迟,使其在每天 0:00:000:29:59 窗口内随机触发。
// 若当前时间位于当日窗口内,返回窗口剩余时间内的随机秒数;否则返回到次日窗口的随机偏移。
func (c *Client) nextReportDataDelay() time.Duration {
now := time.Now()
loc := now.Location()
if now.Hour() == 0 && now.Minute() < 30 {
end := time.Date(now.Year(), now.Month(), now.Day(), 0, 29, 59, 0, loc)
remaining := end.Sub(now)
sec := int(remaining / time.Second)
if sec <= 0 {
nextMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).Add(24 * time.Hour)
offset := time.Duration(rand.Intn(30*60)) * time.Second
return time.Until(nextMidnight.Add(offset))
}
offset := rand.Intn(sec) + 1
return time.Duration(offset) * time.Second
}
nextMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).Add(24 * time.Hour)
offset := time.Duration(rand.Intn(30*60)) * time.Second
return time.Until(nextMidnight.Add(offset))
}
// reportInstallation reports installation information
func (c *Client) reportInstallation() error {
event := InstallationEvent{
@ -172,7 +225,7 @@ func (c *Client) reportInstallation() error {
if err != nil {
return fmt.Errorf("marshal installation event: %w", err)
}
eventEncrypted, err := Encrypt([]byte("SZ3SDP38y9Gg2c6yHdLPgDeX"), eventRaw)
eventEncrypted, err := Encrypt([]byte(c.aesKey), eventRaw)
if err != nil {
return fmt.Errorf("encrypt installation event: %w", err)
}
@ -206,6 +259,120 @@ func (c *Client) reportInstallation() error {
return nil
}
func (c *Client) reportData() error {
event := DailyReportEvent{
InstallationEvent: InstallationEvent{
Version: Version,
Timestamp: time.Now().Format(time.RFC3339),
MachineID: c.machineID,
Type: "data_report",
},
}
if repoList, err := c.repo.GetKnowledgeBaseList(context.Background()); err == nil {
event.KBCount = len(repoList)
} else {
c.logger.Error("get knowledge base list failed in telemetry", log.Error(err))
}
if modelModeSetting, err := c.modelUsecase.GetModelModeSetting(context.Background()); err == nil {
event.ModelConfigMode = string(modelModeSetting.Mode)
} else {
c.logger.Error("get model config mode failed in telemetry", log.Error(err))
}
if ok, err := c.isAdminLoggedInYesterday(); err == nil {
event.AdminLoggedInToday = ok
} else {
c.logger.Error("get admin login today failed in telemetry", log.Error(err))
}
if count, err := c.nodeRepo.GetNodeCount(context.Background()); err == nil {
event.DocsCount = count
} else {
c.logger.Error("get docs count failed in telemetry", log.Error(err))
}
// conversation counts by app type across all KBs
if totals, err := c.conversationRepo.GetConversationCountByAppType(context.Background()); err == nil {
event.WebConversationCount = int(totals[domain.AppTypeWeb])
event.WidgetConversationCount = int(totals[domain.AppTypeWidget])
event.DingTalkBotConversationCount = int(totals[domain.AppTypeDingTalkBot])
event.FeishuBotConversationCount = int(totals[domain.AppTypeFeishuBot])
event.WechatBotConversationCount = int(totals[domain.AppTypeWechatBot])
event.WeChatServerBotConversationCount = int(totals[domain.AppTypeWechatServiceBot])
event.DiscordBotConversationCount = int(totals[domain.AppTypeDisCordBot])
event.WechatOfficialAccountConversationCount = int(totals[domain.AppTypeWechatOfficialAccount])
event.OpenAIAPIConversationCount = int(totals[domain.AppTypeOpenAIAPI])
event.WecomAIBotConversationCount = int(totals[domain.AppTypeWecomAIBot])
event.LarkBotConversationCount = int(totals[domain.AppTypeLarkBot])
} else {
c.logger.Error("get conversation count by app type failed", log.Error(err))
}
if count, err := c.mcpRepo.GetMCPCallCount(context.Background()); err == nil {
event.McpServerConversationCount = int(count)
} else {
c.logger.Error("get mcp call count failed", log.Error(err))
}
eventRaw, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("marshal installation event: %w", err)
}
c.logger.Info("report data event", log.String("event", string(eventRaw)))
eventEncrypted, err := Encrypt([]byte(c.aesKey), eventRaw)
if err != nil {
return fmt.Errorf("encrypt installation event: %w", err)
}
data := map[string]string{
"index": "panda-wiki-installation",
"data": eventEncrypted,
"id": uuid.New().String(),
}
eventEncryptedRaw, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal installation event: %w", err)
}
req, err := http.NewRequest("POST", c.baseURL, bytes.NewBuffer(eventEncryptedRaw))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
return nil
}
// 判断“昨日是否有管理员访问”。
// 因为数据在每天 01 点上报,这里采用昨日 0:00 至今日 0:00 的时间窗口。
func (c *Client) isAdminLoggedInYesterday() (bool, error) {
resp, err := c.userUsecase.ListUsers(context.Background())
if err != nil {
return false, err
}
now := time.Now()
loc := now.Location()
todayMidnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
yesterdayMidnight := todayMidnight.Add(-24 * time.Hour)
for _, u := range resp.Users {
if u.Role == consts.UserRoleAdmin && u.LastAccess != nil && !u.LastAccess.Before(yesterdayMidnight) && u.LastAccess.Before(todayMidnight) {
return true, nil
}
}
return false, nil
}
// Stop stops periodic report
func (c *Client) Stop() {
close(c.stopChan)
@ -219,3 +386,22 @@ type InstallationEvent struct {
Type string `json:"type"`
KBCount int `json:"kb_count"`
}
type DailyReportEvent struct {
InstallationEvent
ModelConfigMode string `json:"model_config_mode"` // 模型配置模式
AdminLoggedInToday bool `json:"admin_logged_in_today"` // 是否今日登录管理端
DocsCount int `json:"docs_count"` // 文件数量
WebConversationCount int `json:"web_conversation_count"` // 网页对话次数
WidgetConversationCount int `json:"widget_conversation_count"` // 插件对话次数
DingTalkBotConversationCount int `json:"dingtalk_bot_conversation_count"` // 钉钉机器人对话次数
FeishuBotConversationCount int `json:"feishu_bot_conversation_count"` // 飞书机器人对话次数
WechatBotConversationCount int `json:"wechat_bot_conversation_count"` // 企业微信机器人对话次数
WeChatServerBotConversationCount int `json:"wechat_server_bot_conversation_count"` // 企业微信客服对话次数
DiscordBotConversationCount int `json:"discord_bot_conversation_count"` // Discord 机器人对话次数
WechatOfficialAccountConversationCount int `json:"wechat_official_account_conversation_count"` // 微信公众号对话次数
OpenAIAPIConversationCount int `json:"openai_api_conversation_count"` // OpenAI API 调用次数
WecomAIBotConversationCount int `json:"wecom_ai_bot_conversation_count"` // 企业微信智能机器人对话次数
LarkBotConversationCount int `json:"lark_bot_conversation_count"` // 飞书机器人对话次数
McpServerConversationCount int `json:"mcp_server_conversation_count"` // MCP 对话次数
}

View File

@ -7,6 +7,7 @@ import (
"sync"
"time"
v1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
@ -87,33 +88,68 @@ func NewAppUsecase(
return u
}
func (u *AppUsecase) ValidateUpdateApp(ctx context.Context, id string, req *domain.UpdateAppReq, edition consts.LicenseEdition) error {
switch edition {
case consts.LicenseEditionFree:
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
if app.Settings.WatermarkContent != req.Settings.WatermarkContent ||
app.Settings.WatermarkSetting != req.Settings.WatermarkSetting ||
app.Settings.ContributeSettings != req.Settings.ContributeSettings ||
app.Settings.CopySetting != req.Settings.CopySetting {
func (u *AppUsecase) ValidateUpdateApp(ctx context.Context, id string, req *domain.UpdateAppReq) error {
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
limitation := domain.GetBaseEditionLimitation(ctx)
if !limitation.AllowCopyProtection && app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
if !limitation.AllowWatermark {
if app.Settings.WatermarkSetting != req.Settings.WatermarkSetting || app.Settings.WatermarkContent != req.Settings.WatermarkContent {
return domain.ErrPermissionDenied
}
case consts.LicenseEditionContributor:
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
if app.Settings.WatermarkContent != req.Settings.WatermarkContent ||
app.Settings.WatermarkSetting != req.Settings.WatermarkSetting ||
app.Settings.CopySetting != req.Settings.CopySetting {
}
if !limitation.AllowAdvancedBot {
if !slices.Equal(app.Settings.WechatServiceContainKeywords, req.Settings.WechatServiceContainKeywords) ||
!slices.Equal(app.Settings.WechatServiceEqualKeywords, req.Settings.WechatServiceEqualKeywords) {
return domain.ErrPermissionDenied
}
if app.Settings.WeChatAppAdvancedSetting.FeedbackEnable != req.Settings.WeChatAppAdvancedSetting.FeedbackEnable ||
app.Settings.WeChatAppAdvancedSetting.TextResponseEnable != req.Settings.WeChatAppAdvancedSetting.TextResponseEnable ||
app.Settings.WeChatAppAdvancedSetting.Prompt != req.Settings.WeChatAppAdvancedSetting.Prompt ||
!slices.Equal(app.Settings.WeChatAppAdvancedSetting.FeedbackType, req.Settings.WeChatAppAdvancedSetting.FeedbackType) ||
app.Settings.WeChatAppAdvancedSetting.DisclaimerContent != req.Settings.WeChatAppAdvancedSetting.DisclaimerContent {
return domain.ErrPermissionDenied
}
} else {
if req.Settings.WeChatAppAdvancedSetting.Prompt == "" {
req.Settings.WeChatAppAdvancedSetting.Prompt = domain.SystemDefaultPrompt
}
}
if !limitation.AllowCommentAudit && app.Settings.WebAppCommentSettings.ModerationEnable != req.Settings.WebAppCommentSettings.ModerationEnable {
return domain.ErrPermissionDenied
}
if !limitation.AllowOpenAIBotSettings {
if app.Settings.OpenAIAPIBotSettings.IsEnabled != req.Settings.OpenAIAPIBotSettings.IsEnabled || app.Settings.OpenAIAPIBotSettings.SecretKey != req.Settings.OpenAIAPIBotSettings.SecretKey {
return domain.ErrPermissionDenied
}
}
if !limitation.AllowCustomCopyright {
if app.Settings.WidgetBotSettings.CopyrightHideEnabled != req.Settings.WidgetBotSettings.CopyrightHideEnabled || app.Settings.WidgetBotSettings.CopyrightInfo != req.Settings.WidgetBotSettings.CopyrightInfo {
return domain.ErrPermissionDenied
}
if app.Settings.ConversationSetting.CopyrightHideEnabled != req.Settings.ConversationSetting.CopyrightHideEnabled {
return domain.ErrPermissionDenied
}
if req.Settings.ConversationSetting.CopyrightInfo != domain.SettingCopyrightInfo && app.Settings.ConversationSetting.CopyrightInfo != req.Settings.ConversationSetting.CopyrightInfo {
req.Settings.ConversationSetting.CopyrightInfo = domain.SettingCopyrightInfo
}
}
if !limitation.AllowMCPServer {
if app.Settings.MCPServerSettings.IsEnabled != req.Settings.MCPServerSettings.IsEnabled {
return domain.ErrPermissionDenied
}
case consts.LicenseEditionEnterprise:
return nil
default:
return fmt.Errorf("unsupported license type: %d", edition)
}
return nil
@ -422,6 +458,8 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
FeatureConfig: app.Settings.WebAppLandingConfigs[i].FeatureConfig,
ImgTextConfig: app.Settings.WebAppLandingConfigs[i].ImgTextConfig,
TextImgConfig: app.Settings.WebAppLandingConfigs[i].TextImgConfig,
QuestionConfig: app.Settings.WebAppLandingConfigs[i].QuestionConfig,
BlockGridConfig: app.Settings.WebAppLandingConfigs[i].BlockGridConfig,
ComConfigOrder: app.Settings.WebAppLandingConfigs[i].ComConfigOrder,
NodeIds: app.Settings.WebAppLandingConfigs[i].NodeIds,
}
@ -437,7 +475,6 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
RecommendNodeIDs: app.Settings.RecommendNodeIDs,
Desc: app.Settings.Desc,
Keyword: app.Settings.Keyword,
AutoSitemap: app.Settings.AutoSitemap,
HeadCode: app.Settings.HeadCode,
BodyCode: app.Settings.BodyCode,
// DingTalkBot
@ -452,12 +489,13 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
// LarkBot
LarkBotSettings: app.Settings.LarkBotSettings,
// WechatBot
WeChatAppIsEnabled: app.Settings.WeChatAppIsEnabled,
WeChatAppToken: app.Settings.WeChatAppToken,
WeChatAppCorpID: app.Settings.WeChatAppCorpID,
WeChatAppEncodingAESKey: app.Settings.WeChatAppEncodingAESKey,
WeChatAppSecret: app.Settings.WeChatAppSecret,
WeChatAppAgentID: app.Settings.WeChatAppAgentID,
WeChatAppIsEnabled: app.Settings.WeChatAppIsEnabled,
WeChatAppToken: app.Settings.WeChatAppToken,
WeChatAppCorpID: app.Settings.WeChatAppCorpID,
WeChatAppEncodingAESKey: app.Settings.WeChatAppEncodingAESKey,
WeChatAppSecret: app.Settings.WeChatAppSecret,
WeChatAppAgentID: app.Settings.WeChatAppAgentID,
WeChatAppAdvancedSetting: app.Settings.WeChatAppAdvancedSetting,
// WechatServiceBot
WeChatServiceIsEnabled: app.Settings.WeChatServiceIsEnabled,
WeChatServiceToken: app.Settings.WeChatServiceToken,
@ -500,14 +538,24 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
WebAppLandingConfigs: webAppLandingConfigs,
WebAppLandingTheme: app.Settings.WebAppLandingTheme,
WatermarkContent: app.Settings.WatermarkContent,
WatermarkSetting: app.Settings.WatermarkSetting,
CopySetting: app.Settings.CopySetting,
ContributeSettings: app.Settings.ContributeSettings,
HomePageSetting: app.Settings.HomePageSetting,
WatermarkContent: app.Settings.WatermarkContent,
WatermarkSetting: app.Settings.WatermarkSetting,
CopySetting: app.Settings.CopySetting,
ContributeSettings: app.Settings.ContributeSettings,
HomePageSetting: app.Settings.HomePageSetting,
ConversationSetting: app.Settings.ConversationSetting,
WecomAIBotSettings: app.Settings.WecomAIBotSettings,
MCPServerSettings: app.Settings.MCPServerSettings,
StatsSetting: app.Settings.StatsSetting,
}
if !domain.GetBaseEditionLimitation(ctx).AllowCustomCopyright {
appDetailResp.Settings.ConversationSetting.CopyrightHideEnabled = false
appDetailResp.Settings.ConversationSetting.CopyrightInfo = domain.SettingCopyrightInfo
}
// init ai feedback string
if app.Settings.AIFeedbackSettings.AIFeedbackType == nil {
appDetailResp.Settings.AIFeedbackSettings.AIFeedbackType = []string{"内容不准确", "没有帮助", "其他"}
@ -530,6 +578,19 @@ func (u *AppUsecase) GetAppDetailByKBIDAndAppType(ctx context.Context, kbID stri
return appDetailResp, nil
}
func (u *AppUsecase) GetMCPServerAppInfo(ctx context.Context, kbID string) (*domain.AppInfoResp, error) {
apiApp, err := u.repo.GetOrCreateAppByKBIDAndType(ctx, kbID, domain.AppTypeMcpServer)
if err != nil {
return nil, err
}
appInfo := &domain.AppInfoResp{
Settings: domain.AppSettingsResp{
MCPServerSettings: apiApp.Settings.MCPServerSettings,
},
}
return appInfo, nil
}
func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId uint) (*domain.AppInfoResp, error) {
app, err := u.repo.GetOrCreateAppByKBIDAndType(ctx, kbID, domain.AppTypeWeb)
if err != nil {
@ -552,6 +613,8 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
ImgTextConfig: app.Settings.WebAppLandingConfigs[i].ImgTextConfig,
TextImgConfig: app.Settings.WebAppLandingConfigs[i].TextImgConfig,
MetricsConfig: app.Settings.WebAppLandingConfigs[i].MetricsConfig,
QuestionConfig: app.Settings.WebAppLandingConfigs[i].QuestionConfig,
BlockGridConfig: app.Settings.WebAppLandingConfigs[i].BlockGridConfig,
ComConfigOrder: app.Settings.WebAppLandingConfigs[i].ComConfigOrder,
NodeIds: app.Settings.WebAppLandingConfigs[i].NodeIds,
}
@ -574,7 +637,6 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
RecommendNodeIDs: app.Settings.RecommendNodeIDs,
Desc: app.Settings.Desc,
Keyword: app.Settings.Keyword,
AutoSitemap: app.Settings.AutoSitemap,
HeadCode: app.Settings.HeadCode,
BodyCode: app.Settings.BodyCode,
// theme
@ -598,11 +660,13 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
WebAppLandingConfigs: webAppLandingConfigs,
WebAppLandingTheme: app.Settings.WebAppLandingTheme,
WatermarkContent: app.Settings.WatermarkContent,
WatermarkSetting: app.Settings.WatermarkSetting,
CopySetting: app.Settings.CopySetting,
ContributeSettings: app.Settings.ContributeSettings,
HomePageSetting: app.Settings.HomePageSetting,
WatermarkContent: app.Settings.WatermarkContent,
WatermarkSetting: app.Settings.WatermarkSetting,
CopySetting: app.Settings.CopySetting,
ContributeSettings: app.Settings.ContributeSettings,
HomePageSetting: app.Settings.HomePageSetting,
ConversationSetting: app.Settings.ConversationSetting,
StatsSetting: app.Settings.StatsSetting,
},
}
// init ai feedback string
@ -614,10 +678,12 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
}
showBrand := true
defaultDisclaimer := "本回答由 PandaWiki 基于 AI 生成,仅供参考。"
licenseEdition, _ := ctx.Value(consts.ContextKeyEdition).(consts.LicenseEdition)
if licenseEdition < consts.LicenseEditionEnterprise {
if !domain.GetBaseEditionLimitation(ctx).AllowCustomCopyright {
appInfo.Settings.WebAppCustomSettings.ShowBrandInfo = &showBrand
appInfo.Settings.DisclaimerSettings.Content = &defaultDisclaimer
appInfo.Settings.ConversationSetting.CopyrightHideEnabled = false
appInfo.Settings.ConversationSetting.CopyrightInfo = domain.SettingCopyrightInfo
} else {
if appInfo.Settings.DisclaimerSettings.Content == nil {
appInfo.Settings.DisclaimerSettings.Content = &defaultDisclaimer
@ -642,23 +708,50 @@ func (u *AppUsecase) GetWidgetAppInfo(ctx context.Context, kbID string) (*domain
Icon: webApp.Settings.Icon,
WelcomeStr: webApp.Settings.WelcomeStr,
SearchPlaceholder: webApp.Settings.SearchPlaceholder,
RecommendQuestions: webApp.Settings.RecommendQuestions,
RecommendQuestions: widgetApp.Settings.WidgetBotSettings.RecommendQuestions,
WidgetBotSettings: widgetApp.Settings.WidgetBotSettings,
},
}
if len(webApp.Settings.RecommendNodeIDs) > 0 {
if len(widgetApp.Settings.WidgetBotSettings.RecommendNodeIDs) > 0 {
nodes, err := u.nodeUsecase.GetRecommendNodeList(ctx, &domain.GetRecommendNodeListReq{
KBID: kbID,
NodeIDs: webApp.Settings.RecommendNodeIDs,
NodeIDs: widgetApp.Settings.WidgetBotSettings.RecommendNodeIDs,
})
if err != nil {
return nil, err
}
appInfo.RecommendNodes = nodes
}
if !domain.GetBaseEditionLimitation(ctx).AllowCustomCopyright {
appInfo.Settings.WidgetBotSettings.CopyrightHideEnabled = false
appInfo.Settings.WidgetBotSettings.CopyrightInfo = domain.SettingCopyrightInfo
}
return appInfo, nil
}
func (u *AppUsecase) GetWechatAppInfo(ctx context.Context, kbID string) (*v1.WechatAppInfoResp, error) {
wechatApp, err := u.repo.GetOrCreateAppByKBIDAndType(ctx, kbID, domain.AppTypeWechatBot)
if err != nil {
return nil, err
}
resp := &v1.WechatAppInfoResp{}
if wechatApp.Settings.WeChatAppIsEnabled != nil {
resp.WeChatAppIsEnabled = *wechatApp.Settings.WeChatAppIsEnabled
}
if domain.GetBaseEditionLimitation(ctx).AllowAdvancedBot {
resp.FeedbackEnable = wechatApp.Settings.WeChatAppAdvancedSetting.FeedbackEnable
resp.FeedbackType = wechatApp.Settings.WeChatAppAdvancedSetting.FeedbackType
resp.DisclaimerContent = wechatApp.Settings.WeChatAppAdvancedSetting.DisclaimerContent
}
return resp, nil
}
func (u *AppUsecase) handleBotAuths(ctx context.Context, id string, newSettings *domain.AppSettings) error {
currentApp, err := u.repo.GetAppDetail(ctx, id)

View File

@ -219,7 +219,7 @@ func (u *ChatUsecase) Chat(ctx context.Context, req *domain.ChatRequest) (<-chan
}
// 4. retrieve documents and format prompt
messages, rankedNodes, err := u.llmUsecase.FormatConversationMessages(ctx, req.ConversationID, req.KBID, groupIds)
messages, rankedNodes, err := u.llmUsecase.FormatConversationMessages(ctx, req.ConversationID, req.KBID, groupIds, req.Prompt)
if err != nil {
u.logger.Error("failed to format chat messages", log.Error(err))
eventCh <- domain.SSEEvent{Type: "error", Content: "failed to format chat messages"}
@ -301,6 +301,64 @@ func (u *ChatUsecase) Chat(ctx context.Context, req *domain.ChatRequest) (<-chan
return eventCh, nil
}
func (u *ChatUsecase) ChatRagOnly(ctx context.Context, req *domain.ChatRagOnlyRequest) (<-chan domain.SSEEvent, error) {
eventCh := make(chan domain.SSEEvent, 100)
go func() {
defer close(eventCh)
// extra1. if user set question block words then check it
blockWords, err := u.blockWordRepo.GetBlockWords(ctx, req.KBID)
if err != nil {
u.logger.Error("failed to get question block words", log.Error(err))
eventCh <- domain.SSEEvent{Type: "error", Content: "failed to get question block words"}
return
}
if len(blockWords) > 0 { // check --> filter
questionFilter := utils.GetDFA(req.KBID)
if err := questionFilter.DFA.Check(req.Message); err != nil { // exist then return err
answer := "**您的问题包含敏感词, AI 无法回答您的问题。**"
eventCh <- domain.SSEEvent{Type: "error", Content: answer}
return
}
}
if req.UserInfo.AuthUserID == 0 {
auth, _ := u.AuthRepo.GetAuthBySourceType(ctx, req.AppType.ToSourceType())
if auth != nil {
req.UserInfo.AuthUserID = auth.ID
}
}
groupIds, err := u.AuthRepo.GetAuthGroupIdsWithParentsByAuthId(ctx, req.UserInfo.AuthUserID)
if err != nil {
u.logger.Error("failed to get auth groupIds", log.Error(err))
eventCh <- domain.SSEEvent{Type: "error", Content: "failed to get auth groupIds"}
return
}
// retrieve documents
kb, err := u.kbRepo.GetKnowledgeBaseByID(ctx, req.KBID)
if err != nil {
u.logger.Error("failed to get kb", log.Error(err))
eventCh <- domain.SSEEvent{Type: "error", Content: "failed to get kb"}
return
}
rankedNodes, err := u.llmUsecase.GetRankNodes(ctx, []string{kb.DatasetID}, req.Message, groupIds, 0, nil)
if err != nil {
u.logger.Error("failed to get rank nodes", log.Error(err))
eventCh <- domain.SSEEvent{Type: "error", Content: "failed to get rank nodes"}
return
}
documents := domain.FormatNodeChunks(rankedNodes, kb.AccessSettings.BaseURL)
u.logger.Debug("documents", log.String("documents", documents))
// send only the documents part
eventCh <- domain.SSEEvent{Type: "data", Content: documents}
eventCh <- domain.SSEEvent{Type: "done"}
}()
return eventCh, nil
}
func (u *ChatUsecase) CreateAcOnChunk(ctx context.Context, kbID string, answer *string, eventCh chan<- domain.SSEEvent, blockWords []string) (func(ctx context.Context, dataType, chunk string) error,
func(ctx context.Context, dataType string)) {
var buffer strings.Builder

View File

@ -72,8 +72,8 @@ func (u *CommentUsecase) CreateComment(ctx context.Context, commentReq *domain.C
return CommentStr, nil
}
func (u *CommentUsecase) GetCommentListByNodeID(ctx context.Context, nodeID string, edition consts.LicenseEdition) (*domain.PaginatedResult[[]*domain.ShareCommentListItem], error) {
comments, total, err := u.CommentRepo.GetCommentList(ctx, nodeID, edition)
func (u *CommentUsecase) GetCommentListByNodeID(ctx context.Context, nodeID string) (*domain.PaginatedResult[[]*domain.ShareCommentListItem], error) {
comments, total, err := u.CommentRepo.GetCommentList(ctx, nodeID)
if err != nil {
return nil, err
}

View File

@ -84,7 +84,7 @@ func (u *CreationUsecase) TextCreation(ctx context.Context, req *domain.TextReq,
func (u *CreationUsecase) TabComplete(ctx context.Context, req *domain.CompleteReq) (string, error) {
// For FIM (Fill in Middle) style completion, we need to handle prefix and suffix
if req.Prefix != "" || req.Suffix != "" {
model, err := u.model.GetModelByType(ctx, domain.ModelTypeChat)
model, err := u.model.GetChatModel(ctx)
if err != nil {
u.logger.Error("get chat model failed", log.Error(err))
return "", domain.ErrModelNotConfigured

View File

@ -146,10 +146,10 @@ func (u *KnowledgeBaseUsecase) DeleteKnowledgeBase(ctx context.Context, kbID str
return nil
}
func (u *KnowledgeBaseUsecase) CreateKBRelease(ctx context.Context, req *domain.CreateKBReleaseReq) (string, error) {
func (u *KnowledgeBaseUsecase) CreateKBRelease(ctx context.Context, req *domain.CreateKBReleaseReq, userId string) (string, error) {
if len(req.NodeIDs) > 0 {
// create published nodes
releaseIDs, err := u.nodeRepo.CreateNodeReleases(ctx, req.KBID, req.NodeIDs)
releaseIDs, err := u.nodeRepo.CreateNodeReleases(ctx, req.KBID, userId, req.NodeIDs)
if err != nil {
return "", fmt.Errorf("failed to create published nodes: %w", err)
}

View File

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"math"
"slices"
"strings"
"time"
@ -16,8 +15,6 @@ import (
"github.com/cloudwego/eino/schema"
"github.com/pkoukk/tiktoken-go"
"github.com/samber/lo"
"github.com/samber/lo/parallel"
"golang.org/x/sync/semaphore"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/domain"
@ -39,6 +36,11 @@ type LLMUsecase struct {
modelkit *modelkit.ModelKit
}
const (
summaryChunkTokenLimit = 30720 // 30KB tokens per chunk
summaryMaxChunks = 4 // max chunks to process for summary
)
func NewLLMUsecase(config *config.Config, rag rag.RAGService, conversationRepo *pg.ConversationRepository, kbRepo *pg.KnowledgeBaseRepository, nodeRepo *pg.NodeRepository, modelRepo *pg.ModelRepository, promptRepo *pg.PromptRepo, logger *log.Logger) *LLMUsecase {
tiktoken.SetBpeLoader(&utils.Localloader{})
modelkit := modelkit.NewModelKit(logger.Logger)
@ -60,6 +62,7 @@ func (u *LLMUsecase) FormatConversationMessages(
conversationID string,
kbID string,
groupIDs []int,
systemPrompt string,
) ([]*schema.Message, []*domain.RankedNodeChunks, error) {
messages := make([]*schema.Message, 0)
rankedNodes := make([]*domain.RankedNodeChunks, 0)
@ -83,12 +86,15 @@ func (u *LLMUsecase) FormatConversationMessages(
if len(historyMessages) > 0 {
question := historyMessages[len(historyMessages)-1].Content
systemPrompt := domain.SystemPrompt
if prompt, err := u.promptRepo.GetPrompt(ctx, kbID); err != nil {
u.logger.Error("get prompt from settings failed", log.Error(err))
} else {
if prompt != "" {
systemPrompt = prompt
if systemPrompt == "" {
if settingPrompt, err := u.promptRepo.GetPrompt(ctx, kbID); err != nil {
u.logger.Error("get prompt from settings failed", log.Error(err))
} else {
if settingPrompt != "" {
systemPrompt = settingPrompt
} else {
systemPrompt = domain.SystemDefaultPrompt
}
}
}
@ -197,52 +203,59 @@ func (u *LLMUsecase) SummaryNode(ctx context.Context, model *domain.Model, name,
return "", err
}
chunks, err := u.SplitByTokenLimit(content, int(math.Floor(1024*32*0.95)))
chunks, err := u.SplitByTokenLimit(content, summaryChunkTokenLimit)
if err != nil {
return "", err
}
sem := semaphore.NewWeighted(int64(10))
summaries := parallel.Map(chunks, func(chunk string, _ int) string {
if err := sem.Acquire(ctx, 1); err != nil {
u.logger.Error("Failed to acquire semaphore for chunk: ", log.Error(err))
return ""
}
defer sem.Release(1)
summary, err := u.Generate(ctx, chatModel, []*schema.Message{
{
Role: "system",
Content: "你是文档总结助手请根据文档内容总结出文档的摘要。摘要是纯文本应该简洁明了不要超过160个字。",
},
{
Role: "user",
Content: fmt.Sprintf("文档名称:%s\n文档内容%s", name, chunk),
},
})
if err != nil {
u.logger.Error("Failed to generate summary for chunk: ", log.Error(err))
return ""
}
if strings.HasPrefix(summary, "<think>") {
// remove <think> body </think>
endIndex := strings.Index(summary, "</think>")
if endIndex != -1 {
summary = strings.TrimSpace(summary[endIndex+8:]) // 8 is length of "</think>"
}
}
return summary
})
// 使用lo.Filter处理错误
defeatSummary := lo.Filter(summaries, func(summary string, index int) bool {
return summary == ""
})
if len(defeatSummary) > 0 {
return "", fmt.Errorf("failed to generate summaries for all chunks: %d/%d", len(defeatSummary), len(chunks))
if len(chunks) > summaryMaxChunks {
u.logger.Debug("trim summary chunks for large document", log.String("node", name), log.Int("original_chunks", len(chunks)), log.Int("used_chunks", summaryMaxChunks))
chunks = chunks[:summaryMaxChunks]
}
contents, err := u.SplitByTokenLimit(strings.Join(summaries, "\n\n"), int(math.Floor(1024*32*0.95)))
if err != nil {
return "", err
summaries := make([]string, 0, len(chunks))
for idx, chunk := range chunks {
summary, err := u.requestSummary(ctx, chatModel, name, chunk)
if err != nil {
u.logger.Error("Failed to generate summary for chunk", log.Int("chunk_index", idx), log.Error(err))
continue
}
if summary == "" {
u.logger.Warn("Empty summary returned for chunk", log.Int("chunk_index", idx))
continue
}
summaries = append(summaries, summary)
}
if len(summaries) == 0 {
return "", fmt.Errorf("failed to generate summary for document %s", name)
}
// Join all summaries and generate final summary
joined := strings.Join(summaries, "\n\n")
finalSummary, err := u.requestSummary(ctx, chatModel, name, joined)
if err != nil {
u.logger.Error("Failed to generate final summary, using aggregated summaries", log.Error(err))
// Fallback: return the joined summaries directly
if len(joined) > 500 {
return joined[:500] + "...", nil
}
return joined, nil
}
return finalSummary, nil
}
func (u *LLMUsecase) trimThinking(summary string) string {
if !strings.HasPrefix(summary, "<think>") {
return summary
}
endIndex := strings.Index(summary, "</think>")
if endIndex == -1 {
return summary
}
return strings.TrimSpace(summary[endIndex+len("</think>"):])
}
func (u *LLMUsecase) requestSummary(ctx context.Context, chatModel model.BaseChatModel, name, content string) (string, error) {
summary, err := u.Generate(ctx, chatModel, []*schema.Message{
{
Role: "system",
@ -250,20 +263,13 @@ func (u *LLMUsecase) SummaryNode(ctx context.Context, model *domain.Model, name,
},
{
Role: "user",
Content: fmt.Sprintf("文档名称:%s\n文档内容%s", name, contents[0]),
Content: fmt.Sprintf("文档名称:%s\n文档内容%s", name, content),
},
})
if err != nil {
return "", err
}
if strings.HasPrefix(summary, "<think>") {
// remove <think> body </think>
endIndex := strings.Index(summary, "</think>")
if endIndex != -1 {
summary = strings.TrimSpace(summary[endIndex+8:]) // 8 is length of "</think>"
}
}
return summary, nil
return strings.TrimSpace(u.trimThinking(summary)), nil
}
func (u *LLMUsecase) SplitByTokenLimit(text string, maxTokens int) ([]string, error) {

View File

@ -2,14 +2,15 @@ package usecase
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
"github.com/samber/lo"
modelkitDomain "github.com/chaitin/ModelKit/v2/domain"
modelkit "github.com/chaitin/ModelKit/v2/usecase"
"github.com/chaitin/panda-wiki/config"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
"github.com/chaitin/panda-wiki/repo/mq"
@ -18,118 +19,47 @@ import (
)
type ModelUsecase struct {
modelRepo *pg.ModelRepository
logger *log.Logger
config *config.Config
nodeRepo *pg.NodeRepository
ragRepo *mq.RAGRepository
ragStore rag.RAGService
kbRepo *pg.KnowledgeBaseRepository
modelRepo *pg.ModelRepository
logger *log.Logger
config *config.Config
nodeRepo *pg.NodeRepository
ragRepo *mq.RAGRepository
ragStore rag.RAGService
kbRepo *pg.KnowledgeBaseRepository
systemSettingRepo *pg.SystemSettingRepo
modelkit *modelkit.ModelKit
}
func NewModelUsecase(modelRepo *pg.ModelRepository, nodeRepo *pg.NodeRepository, ragRepo *mq.RAGRepository, ragStore rag.RAGService, logger *log.Logger, config *config.Config, kbRepo *pg.KnowledgeBaseRepository) *ModelUsecase {
func NewModelUsecase(modelRepo *pg.ModelRepository, nodeRepo *pg.NodeRepository, ragRepo *mq.RAGRepository, ragStore rag.RAGService, logger *log.Logger, config *config.Config, kbRepo *pg.KnowledgeBaseRepository, settingRepo *pg.SystemSettingRepo) *ModelUsecase {
modelkit := modelkit.NewModelKit(logger.Logger)
u := &ModelUsecase{
modelRepo: modelRepo,
logger: logger.WithModule("usecase.model"),
config: config,
nodeRepo: nodeRepo,
ragRepo: ragRepo,
ragStore: ragStore,
kbRepo: kbRepo,
}
if err := u.initEmbeddingAndRerankModel(context.Background()); err != nil {
logger.Error("init embedding & rerank & analysis model failed", log.Any("error", err))
modelRepo: modelRepo,
logger: logger.WithModule("usecase.model"),
config: config,
nodeRepo: nodeRepo,
ragRepo: ragRepo,
ragStore: ragStore,
kbRepo: kbRepo,
systemSettingRepo: settingRepo,
modelkit: modelkit,
}
return u
}
func (u *ModelUsecase) initEmbeddingAndRerankModel(ctx context.Context) error {
isReady := false
// wait for raglite to be ready
for range 60 {
models, err := u.ragStore.GetModelList(ctx)
if err != nil {
u.logger.Error("wait for raglite to be ready", log.Any("error", err))
time.Sleep(1 * time.Second)
continue
}
isReady = true
if len(models) > 0 {
// init analysis model for old user
hasAnalysis := false
for _, m := range models {
if m.Type == domain.ModelTypeAnalysis {
hasAnalysis = true
break
}
}
if !hasAnalysis {
if err := u.createAndSyncModelToRAGLite(ctx, "qwen2.5-3b-instruct", domain.ModelTypeAnalysis); err != nil {
return fmt.Errorf("add analysis model err: %v", err)
}
}
return nil
} else {
break
}
}
if !isReady {
return fmt.Errorf("raglite is not ready")
}
if err := u.createAndSyncModelToRAGLite(ctx, "bge-m3", domain.ModelTypeEmbedding); err != nil {
return fmt.Errorf("create and sync model err: %v", err)
}
if err := u.createAndSyncModelToRAGLite(ctx, "bge-reranker-v2-m3", domain.ModelTypeRerank); err != nil {
return fmt.Errorf("create and sync model err: %v", err)
}
if err := u.createAndSyncModelToRAGLite(ctx, "qwen2.5-3b-instruct", domain.ModelTypeAnalysis); err != nil {
return fmt.Errorf("create and sync model err: %v", err)
}
return nil
}
func (u *ModelUsecase) createAndSyncModelToRAGLite(ctx context.Context, modelName string, modelType domain.ModelType) error {
// FIXME: just for test, remove it later
// shared_key by BaiZhiCloud
sharedKey := "sk-r8tmBtcU1JotPDPnlgZLOY4Z6Dbb7FufcSeTkFpRWA5v4Llr"
baseURL := "https://model-square.app.baizhi.cloud/v1"
model := &domain.Model{
ID: uuid.New().String(),
Provider: domain.ModelProviderBrandBaiZhiCloud,
Model: modelName,
APIKey: sharedKey,
APIHeader: "",
BaseURL: baseURL,
IsActive: true,
APIVersion: "",
Type: modelType,
}
id, err := u.ragStore.AddModel(ctx, model)
if err != nil {
return fmt.Errorf("init %s model failed: %w", modelName, err)
}
model.ID = id
if err := u.modelRepo.Create(ctx, model); err != nil {
return fmt.Errorf("create %s model failed: %w", modelName, err)
}
return nil
}
func (u *ModelUsecase) Create(ctx context.Context, model *domain.Model) error {
var updatedEmbeddingModel bool
if model.Type == domain.ModelTypeEmbedding {
updatedEmbeddingModel = true
}
if err := u.modelRepo.Create(ctx, model); err != nil {
return err
}
if model.Type == domain.ModelTypeEmbedding || model.Type == domain.ModelTypeRerank || model.Type == domain.ModelTypeAnalysis || model.Type == domain.ModelTypeAnalysisVL {
if id, err := u.ragStore.AddModel(ctx, model); err != nil {
// 模型更新成功后,如果更新嵌入模型,则触发记录更新
if updatedEmbeddingModel {
if _, err := u.updateModeSettingConfig(ctx, "", "", "", true); err != nil {
return err
} else {
model.ID = id
}
}
if model.Type == domain.ModelTypeEmbedding {
return u.TriggerUpsertRecords(ctx)
}
return nil
}
@ -178,44 +108,50 @@ func (u *ModelUsecase) TriggerUpsertRecords(ctx context.Context) error {
}
func (u *ModelUsecase) Update(ctx context.Context, req *domain.UpdateModelReq) error {
var updatedEmbeddingModel bool
if req.Type == domain.ModelTypeEmbedding {
updatedEmbeddingModel = true
}
if err := u.modelRepo.Update(ctx, req); err != nil {
return err
}
ragModelTypes := []domain.ModelType{
domain.ModelTypeEmbedding,
domain.ModelTypeRerank,
domain.ModelTypeAnalysis,
domain.ModelTypeAnalysisVL,
}
if lo.Contains(ragModelTypes, req.Type) {
updateModel := &domain.Model{
ID: req.ID,
Model: req.Model,
Type: req.Type,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
IsActive: true,
}
if req.Parameters != nil {
updateModel.Parameters = *req.Parameters
}
// update is active flag for analysis models
if (req.Type == domain.ModelTypeAnalysis || req.Type == domain.ModelTypeAnalysisVL) && req.IsActive != nil {
updateModel.IsActive = *req.IsActive
}
if err := u.ragStore.UpdateModel(ctx, updateModel); err != nil {
// 模型更新成功后,如果更新嵌入模型,则触发记录更新
if updatedEmbeddingModel {
if _, err := u.updateModeSettingConfig(ctx, "", "", "", true); err != nil {
return err
}
}
// update all records when embedding model is updated
if req.Type == domain.ModelTypeEmbedding {
return u.TriggerUpsertRecords(ctx)
}
return nil
}
func (u *ModelUsecase) GetChatModel(ctx context.Context) (*domain.Model, error) {
return u.modelRepo.GetChatModel(ctx)
var model *domain.Model
modelModeSetting, err := u.GetModelModeSetting(ctx)
// 获取不到模型模式时,使用手动模式, 不返回错误
if err != nil {
u.logger.Error("get model mode setting failed, use manual mode", log.Error(err))
}
if err == nil && modelModeSetting.Mode == consts.ModelSettingModeAuto && modelModeSetting.AutoModeAPIKey != "" {
modelName := modelModeSetting.ChatModel
if modelName == "" {
modelName = string(consts.AutoModeDefaultChatModel)
}
model = &domain.Model{
Model: modelName,
Type: domain.ModelTypeChat,
IsActive: true,
BaseURL: consts.AutoModeBaseURL,
APIKey: modelModeSetting.AutoModeAPIKey,
Provider: domain.ModelProviderBrandBaiZhiCloud,
}
return model, nil
}
model, err = u.modelRepo.GetChatModel(ctx)
if err != nil {
return nil, err
}
return model, nil
}
func (u *ModelUsecase) GetModelByType(ctx context.Context, modelType domain.ModelType) (*domain.Model, error) {
@ -225,3 +161,175 @@ func (u *ModelUsecase) GetModelByType(ctx context.Context, modelType domain.Mode
func (u *ModelUsecase) UpdateUsage(ctx context.Context, modelID string, usage *schema.TokenUsage) error {
return u.modelRepo.UpdateUsage(ctx, modelID, usage)
}
func (u *ModelUsecase) SwitchMode(ctx context.Context, req *domain.SwitchModeReq) error {
// 只有配置正确才能切换模式
if req.Mode == string(consts.ModelSettingModeAuto) {
if req.AutoModeAPIKey == "" {
return fmt.Errorf("auto mode api key is required")
}
modelName := req.ChatModel
if modelName == "" {
modelName = consts.GetAutoModeDefaultModel(string(domain.ModelTypeChat))
}
// 检查 API Key 是否有效
check, err := u.modelkit.CheckModel(ctx, &modelkitDomain.CheckModelReq{
Provider: string(domain.ModelProviderBrandBaiZhiCloud),
Model: modelName,
BaseURL: consts.AutoModeBaseURL,
APIKey: req.AutoModeAPIKey,
Type: string(domain.ModelTypeChat),
})
if err != nil {
return fmt.Errorf("百智云模型 API Key 检查失败: %w", err)
}
if check.Error != "" {
return fmt.Errorf("百智云模型 API Key 检查失败: %s", check.Error)
}
} else {
needModelTypes := []domain.ModelType{
domain.ModelTypeChat,
domain.ModelTypeEmbedding,
domain.ModelTypeRerank,
domain.ModelTypeAnalysis,
}
for _, modelType := range needModelTypes {
if _, err := u.modelRepo.GetModelByType(ctx, modelType); err != nil {
return fmt.Errorf("需要配置 %s 模型", modelType)
}
}
}
oldModelModeSetting, err := u.GetModelModeSetting(ctx)
if err != nil {
return err
}
var isResetEmbeddingUpdateFlag = true
// 只有切换手动模式时重置isManualEmbeddingUpdated为false
if req.Mode == string(consts.ModelSettingModeManual) {
isResetEmbeddingUpdateFlag = false
}
modelModeSetting, err := u.updateModeSettingConfig(ctx, req.Mode, req.AutoModeAPIKey, req.ChatModel, isResetEmbeddingUpdateFlag)
if err != nil {
return err
}
return u.updateRAGModelsByMode(ctx, req.Mode, modelModeSetting.AutoModeAPIKey, oldModelModeSetting)
}
// updateModeSettingConfig 读取当前设置并更新,然后持久化
func (u *ModelUsecase) updateModeSettingConfig(ctx context.Context, mode, apiKey, chatModel string, isManualEmbeddingUpdated bool) (*domain.ModelModeSetting, error) {
// 读取当前设置
setting, err := u.systemSettingRepo.GetSystemSetting(ctx, string(consts.SystemSettingModelMode))
if err != nil {
return nil, fmt.Errorf("failed to get current model setting: %w", err)
}
var config domain.ModelModeSetting
if err := json.Unmarshal(setting.Value, &config); err != nil {
return nil, fmt.Errorf("failed to parse current model setting: %w", err)
}
// 更新设置
if apiKey != "" {
config.AutoModeAPIKey = apiKey
}
if chatModel != "" {
config.ChatModel = chatModel
}
if mode != "" {
config.Mode = consts.ModelSettingMode(mode)
}
config.IsManualEmbeddingUpdated = isManualEmbeddingUpdated
// 持久化设置
updatedValue, err := json.Marshal(config)
if err != nil {
return nil, fmt.Errorf("failed to marshal updated model setting: %w", err)
}
if err := u.systemSettingRepo.UpdateSystemSetting(ctx, string(consts.SystemSettingModelMode), string(updatedValue)); err != nil {
return nil, fmt.Errorf("failed to update model setting: %w", err)
}
return &config, nil
}
func (u *ModelUsecase) GetModelModeSetting(ctx context.Context) (domain.ModelModeSetting, error) {
setting, err := u.systemSettingRepo.GetSystemSetting(ctx, string(consts.SystemSettingModelMode))
if err != nil {
return domain.ModelModeSetting{}, fmt.Errorf("failed to get model mode setting: %w", err)
}
var config domain.ModelModeSetting
if err := json.Unmarshal(setting.Value, &config); err != nil {
return domain.ModelModeSetting{}, fmt.Errorf("failed to parse model mode setting: %w", err)
}
// 无效设置检查
if config == (domain.ModelModeSetting{}) || config.Mode == "" {
return domain.ModelModeSetting{}, fmt.Errorf("model mode setting is invalid")
}
return config, nil
}
// updateRAGModelsByMode 根据模式更新 RAG 模型embedding、rerank、analysis、analysisVL
func (u *ModelUsecase) updateRAGModelsByMode(ctx context.Context, mode, autoModeAPIKey string, oldModelModeSetting domain.ModelModeSetting) error {
var isTriggerUpsertRecords = true
// 手动切换到手动模式, 根据IsManualEmbeddingUpdated字段决定
if oldModelModeSetting.Mode == consts.ModelSettingModeManual && mode == string(consts.ModelSettingModeManual) {
isTriggerUpsertRecords = oldModelModeSetting.IsManualEmbeddingUpdated
}
ragModelTypes := []domain.ModelType{
domain.ModelTypeEmbedding,
domain.ModelTypeRerank,
domain.ModelTypeAnalysis,
domain.ModelTypeAnalysisVL,
}
for _, modelType := range ragModelTypes {
var model *domain.Model
if mode == string(consts.ModelSettingModeManual) {
// 获取该类型的活跃模型
m, err := u.modelRepo.GetModelByType(ctx, modelType)
if err != nil {
u.logger.Warn("failed to get model by type", log.String("type", string(modelType)), log.Any("error", err))
continue
}
if m == nil || !m.IsActive {
u.logger.Warn("no active model found for type", log.String("type", string(modelType)))
continue
}
model = m
} else {
modelName := consts.GetAutoModeDefaultModel(string(modelType))
model = &domain.Model{
Model: modelName,
Type: modelType,
IsActive: true,
BaseURL: consts.AutoModeBaseURL,
APIKey: autoModeAPIKey,
Provider: domain.ModelProviderBrandBaiZhiCloud,
}
}
// 更新RAG存储中的模型
if model != nil {
// rag store中更新失败不影响其他模型更新
if err := u.ragStore.UpdateModel(ctx, model); err != nil {
u.logger.Error("failed to update model in RAG store", log.String("model_id", model.ID), log.String("type", string(modelType)), log.Any("error", err))
continue
}
u.logger.Info("successfully updated RAG model", log.String("model name: ", string(model.Model)))
}
}
// 触发记录更新
if isTriggerUpsertRecords {
u.logger.Info("embedding model updated, triggering upsert records")
return u.TriggerUpsertRecords(ctx)
}
return nil
}

View File

@ -15,6 +15,7 @@ import (
"gorm.io/gorm"
v1 "github.com/chaitin/panda-wiki/api/node/v1"
shareV1 "github.com/chaitin/panda-wiki/api/share/v1"
"github.com/chaitin/panda-wiki/consts"
"github.com/chaitin/panda-wiki/domain"
"github.com/chaitin/panda-wiki/log"
@ -26,22 +27,25 @@ import (
)
type NodeUsecase struct {
nodeRepo *pg.NodeRepository
appRepo *pg.AppRepository
ragRepo *mq.RAGRepository
kbRepo *pg.KnowledgeBaseRepository
modelRepo *pg.ModelRepository
authRepo *pg.AuthRepo
llmUsecase *LLMUsecase
logger *log.Logger
s3Client *s3.MinioClient
rAGService rag.RAGService
nodeRepo *pg.NodeRepository
appRepo *pg.AppRepository
ragRepo *mq.RAGRepository
kbRepo *pg.KnowledgeBaseRepository
modelRepo *pg.ModelRepository
userRepo *pg.UserRepository
authRepo *pg.AuthRepo
llmUsecase *LLMUsecase
logger *log.Logger
s3Client *s3.MinioClient
rAGService rag.RAGService
modelUsecase *ModelUsecase
}
func NewNodeUsecase(
nodeRepo *pg.NodeRepository,
appRepo *pg.AppRepository,
ragRepo *mq.RAGRepository,
userRepo *pg.UserRepository,
kbRepo *pg.KnowledgeBaseRepository,
llmUsecase *LLMUsecase,
ragService rag.RAGService,
@ -49,18 +53,21 @@ func NewNodeUsecase(
s3Client *s3.MinioClient,
modelRepo *pg.ModelRepository,
authRepo *pg.AuthRepo,
modelUsecase *ModelUsecase,
) *NodeUsecase {
return &NodeUsecase{
nodeRepo: nodeRepo,
rAGService: ragService,
appRepo: appRepo,
ragRepo: ragRepo,
kbRepo: kbRepo,
authRepo: authRepo,
llmUsecase: llmUsecase,
modelRepo: modelRepo,
logger: logger.WithModule("usecase.node"),
s3Client: s3Client,
nodeRepo: nodeRepo,
rAGService: ragService,
appRepo: appRepo,
ragRepo: ragRepo,
kbRepo: kbRepo,
authRepo: authRepo,
userRepo: userRepo,
llmUsecase: llmUsecase,
modelRepo: modelRepo,
logger: logger.WithModule("usecase.node"),
s3Client: s3Client,
modelUsecase: modelUsecase,
}
}
@ -79,6 +86,21 @@ func (u *NodeUsecase) GetList(ctx context.Context, req *domain.GetNodeListReq) (
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nodes, nil
}
publisherMap, err := u.nodeRepo.GetNodeReleasePublisherMap(ctx, req.KBID)
if err != nil {
return nil, err
}
for _, node := range nodes {
if publisherID, exists := publisherMap[node.ID]; exists {
node.PublisherId = publisherID
}
}
return nodes, nil
}
@ -87,6 +109,22 @@ func (u *NodeUsecase) GetNodeByKBID(ctx context.Context, id, kbId, format string
if err != nil {
return nil, err
}
nodeRelease, err := u.nodeRepo.GetLatestNodeReleaseWithPublishAccount(ctx, node.ID)
if err != nil {
return nil, err
}
if nodeRelease != nil {
node.PublisherId = nodeRelease.PublisherId
node.PublisherAccount = nodeRelease.PublisherAccount
}
nodeStat, err := u.nodeRepo.GetNodeStatsByNodeId(ctx, node.ID)
if err != nil {
return nil, err
}
node.PV = nodeStat.PV
if node.Meta.ContentType == domain.ContentTypeMD {
return node, nil
}
@ -169,11 +207,41 @@ func (u *NodeUsecase) ValidateNodePerm(ctx context.Context, kbID, nodeId string,
return nil
}
func (u *NodeUsecase) GetNodeReleaseDetailByKBIDAndID(ctx context.Context, kbID, nodeId, format string) (*v1.NodeDetailResp, error) {
func (u *NodeUsecase) GetNodeReleaseDetailByKBIDAndID(ctx context.Context, kbID, nodeId, format string) (*shareV1.ShareNodeDetailResp, error) {
node, err := u.nodeRepo.GetNodeReleaseDetailByKBIDAndID(ctx, kbID, nodeId)
if err != nil {
return nil, err
}
userMap, err := u.userRepo.GetUsersAccountMap(ctx)
if err != nil {
return nil, err
}
if account, ok := userMap[node.CreatorId]; ok {
node.CreatorAccount = account
}
if account, ok := userMap[node.EditorId]; ok {
node.EditorAccount = account
}
if account, ok := userMap[node.PublisherId]; ok {
node.PublisherAccount = account
}
if domain.GetBaseEditionLimitation(ctx).AllowNodeStats {
webApp, err := u.appRepo.GetOrCreateAppByKBIDAndType(ctx, kbID, domain.AppTypeWeb)
if err != nil {
return nil, err
}
if webApp.Settings.StatsSetting.PVEnable {
nodeStat, err := u.nodeRepo.GetNodeStatsByNodeId(ctx, nodeId)
if err != nil {
return nil, err
}
node.PV = nodeStat.PV
}
}
if node.Meta.ContentType == domain.ContentTypeMD {
return node, nil
}
@ -191,7 +259,7 @@ func (u *NodeUsecase) MoveNode(ctx context.Context, req *domain.MoveNodeReq) err
}
func (u *NodeUsecase) SummaryNode(ctx context.Context, req *domain.NodeSummaryReq) (string, error) {
model, err := u.modelRepo.GetChatModel(ctx)
model, err := u.modelUsecase.GetChatModel(ctx)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", domain.ErrModelNotConfigured
@ -318,6 +386,75 @@ func (u *NodeUsecase) GetNodeReleaseListByKBID(ctx context.Context, kbID string,
return items, nil
}
func (u *NodeUsecase) GetNodeReleaseListByParentID(ctx context.Context, kbID, parentID string, authId uint) ([]*domain.ShareNodeDetailItem, error) {
// 一次性查询所有节点
allNodes, err := u.nodeRepo.GetNodeReleaseListByKBID(ctx, kbID)
if err != nil {
return nil, err
}
nodeGroupIds, err := u.GetNodeIdsByAuthId(ctx, authId, consts.NodePermNameVisible)
if err != nil {
return nil, err
}
// 先过滤权限
visibleNodes := make([]*domain.ShareNodeListItemResp, 0)
for i, node := range allNodes {
switch node.Permissions.Visible {
case consts.NodeAccessPermOpen:
visibleNodes = append(visibleNodes, allNodes[i])
case consts.NodeAccessPermPartial:
if slices.Contains(nodeGroupIds, node.ID) {
visibleNodes = append(visibleNodes, allNodes[i])
}
}
}
// 构建父子关系映射
childrenMap := make(map[string][]*domain.ShareNodeListItemResp)
for _, node := range visibleNodes {
childrenMap[node.ParentID] = append(childrenMap[node.ParentID], node)
}
// 构建树结构
result := u.buildNodeTree(parentID, childrenMap)
return result, nil
}
// buildNodeTree 递归构建节点树结构
func (u *NodeUsecase) buildNodeTree(parentID string, childrenMap map[string][]*domain.ShareNodeListItemResp) []*domain.ShareNodeDetailItem {
children := childrenMap[parentID]
result := make([]*domain.ShareNodeDetailItem, 0, len(children))
for _, child := range children {
node := &domain.ShareNodeDetailItem{
ID: child.ID,
Name: child.Name,
Type: child.Type,
ParentID: child.ParentID,
Position: child.Position,
Meta: child.Meta,
Emoji: child.Emoji,
UpdatedAt: child.UpdatedAt,
Children: make([]*domain.ShareNodeDetailItem, 0),
}
// 如果是文件夹,递归构建其子节点
if child.Type == domain.NodeTypeFolder {
childNodes := u.buildNodeTree(child.ID, childrenMap)
if len(childNodes) > 0 {
node.Children = append(node.Children, childNodes...)
}
}
result = append(result, node)
}
return result
}
func (u *NodeUsecase) GetNodeIdsByAuthId(ctx context.Context, authId uint, PermName consts.NodePermName) ([]string, error) {
authGroups, err := u.authRepo.GetAuthGroupWithParentsByAuthId(ctx, authId)
if err != nil {
@ -375,7 +512,7 @@ func (u *NodeUsecase) GetNodePermissionsByID(ctx context.Context, id, kbID strin
}
func (u *NodeUsecase) ValidateNodePermissionsEdit(req v1.NodePermissionEditReq, edition consts.LicenseEdition) error {
if edition != consts.LicenseEditionEnterprise {
if !slices.Contains([]consts.LicenseEdition{consts.LicenseEditionBusiness, consts.LicenseEditionEnterprise}, edition) {
if req.Permissions.Answerable == consts.NodeAccessPermPartial || req.Permissions.Visitable == consts.NodeAccessPermPartial || req.Permissions.Visible == consts.NodeAccessPermPartial {
return domain.ErrPermissionDenied
}
@ -519,8 +656,10 @@ func (u *NodeUsecase) SyncRagNodeStatus(ctx context.Context) error {
for statusInfo, nodeIDs := range statusGroups {
updateMap := map[string]interface{}{
"status": statusInfo.status,
"message": statusInfo.message,
"rag_info": domain.RagInfo{
Status: consts.NodeRagInfoStatus(statusInfo.status),
Message: statusInfo.message,
},
}
if err := u.nodeRepo.UpdateNodesByKbID(ctx, nodeIDs, kb.ID, updateMap); err != nil {
@ -542,3 +681,30 @@ func (u *NodeUsecase) SyncRagNodeStatus(ctx context.Context) error {
return nil
}
func (u *NodeUsecase) NodeRestudy(ctx context.Context, req *v1.NodeRestudyReq) error {
nodeReleases, err := u.nodeRepo.GetLatestNodeReleaseByNodeIDs(ctx, req.KbId, req.NodeIds)
if err != nil {
return fmt.Errorf("get latest node release failed: %w", err)
}
for _, nodeRelease := range nodeReleases {
if nodeRelease.DocID == "" {
continue
}
if err := u.ragRepo.AsyncUpdateNodeReleaseVector(ctx, []*domain.NodeReleaseVectorRequest{
{
KBID: nodeRelease.KBID,
NodeReleaseID: nodeRelease.ID,
Action: "upsert",
},
}); err != nil {
u.logger.Error("async update node release vector failed",
log.String("node_release_id", nodeRelease.ID),
log.Error(err))
continue
}
}
return nil
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strconv"
@ -67,12 +68,12 @@ func (u *StatUseCase) ValidateStatDay(statDay consts.StatDay, edition consts.Lic
case consts.StatDay1:
return nil
case consts.StatDay7:
if edition < consts.LicenseEditionContributor {
if edition == consts.LicenseEditionFree {
return domain.ErrPermissionDenied
}
return nil
case consts.StatDay30, consts.StatDay90:
if edition < consts.LicenseEditionEnterprise {
if !slices.Contains([]consts.LicenseEdition{consts.LicenseEditionBusiness, consts.LicenseEditionEnterprise}, edition) {
return domain.ErrPermissionDenied
}
return nil
@ -471,3 +472,28 @@ func (u *StatUseCase) AggregateHourlyStats(ctx context.Context) error {
func (u *StatUseCase) CleanupOldHourlyStats(ctx context.Context) error {
return u.repo.CleanupOldHourlyStats(ctx)
}
// MigrateYesterdayPVToNodeStats 将昨天的PV数据从stat_page迁移到node_stats
func (u *StatUseCase) MigrateYesterdayPVToNodeStats(ctx context.Context) error {
// 获取昨天的PV数据按node_id分组
pvMap, err := u.repo.GetYesterdayPVByNode(ctx)
if err != nil {
u.logger.Error("failed to get yesterday PV data", log.Error(err))
return err
}
// 遍历并插入/更新到node_stats表
for nodeID, pvCount := range pvMap {
if err := u.repo.UpsertNodeStats(ctx, nodeID, pvCount); err != nil {
u.logger.Error("failed to upsert node stats",
log.Error(err),
log.String("node_id", nodeID),
log.Int64("pv_count", pvCount))
return err
}
}
u.logger.Info("successfully migrated yesterday PV data to node_stats",
log.Int("node_count", len(pvMap)))
return nil
}

View File

@ -13,18 +13,20 @@ import (
type WechatAppUsecase struct {
logger *log.Logger
AppUsecase *AppUsecase
authRepo *pg.AuthRepo
chatUsecase *ChatUsecase
appRepo *pg.AppRepository
authRepo *pg.AuthRepo
weRepo *pg.WechatRepository
}
func NewWechatAppUsecase(logger *log.Logger, AppUsecase *AppUsecase, chatUsecase *ChatUsecase, weRepo *pg.WechatRepository, authRepo *pg.AuthRepo) *WechatAppUsecase {
func NewWechatAppUsecase(logger *log.Logger, AppUsecase *AppUsecase, chatUsecase *ChatUsecase, weRepo *pg.WechatRepository, authRepo *pg.AuthRepo, appRepo *pg.AppRepository) *WechatAppUsecase {
return &WechatAppUsecase{
logger: logger.WithModule("usecase.wechatAppUsecase"),
AppUsecase: AppUsecase,
chatUsecase: chatUsecase,
weRepo: weRepo,
authRepo: authRepo,
appRepo: appRepo,
}
}
@ -37,7 +39,7 @@ func (u *WechatAppUsecase) VerifyUrlWechatAPP(ctx context.Context, signature, ti
return body, nil
}
func (u *WechatAppUsecase) Wechat(ctx context.Context, msg *wechat.ReceivedMessage, wc *wechat.WechatConfig, KbId string) error {
func (u *WechatAppUsecase) Wechat(ctx context.Context, msg *wechat.ReceivedMessage, wc *wechat.WechatConfig, KbId string, weChatAppAdvancedSetting *domain.WeChatAppAdvancedSetting) error {
getQA := u.getQAFunc(KbId, domain.AppTypeWechatBot)
// 调用接口,获取到用户的详细消息
@ -49,8 +51,10 @@ func (u *WechatAppUsecase) Wechat(ctx context.Context, msg *wechat.ReceivedMessa
u.logger.Info("get userinfo success", log.Any("userinfo", userinfo))
wc.WeRepo = u.weRepo
useTextResponse := domain.GetBaseEditionLimitation(ctx).AllowAdvancedBot && (weChatAppAdvancedSetting != nil && weChatAppAdvancedSetting.TextResponseEnable)
// 发送消息给用户
err = wc.Wechat(*msg, getQA, userinfo)
err = wc.Wechat(*msg, getQA, userinfo, useTextResponse, weChatAppAdvancedSetting)
if err != nil {
u.logger.Error("wc wechat failed", log.Error(err))
@ -79,6 +83,12 @@ func (u *WechatAppUsecase) getQAFunc(kbID string, appType domain.AppType) bot.Ge
u.logger.Error("get auth failed", log.Error(err))
return nil, err
}
wechatApp, err := u.appRepo.GetOrCreateAppByKBIDAndType(ctx, kbID, domain.AppTypeWechatBot)
if err != nil {
u.logger.Error("failed to get wechat app", log.Error(err), log.String("kb_id", kbID))
return nil, err
}
info.UserInfo.AuthUserID = auth.ID
eventCh, err := u.chatUsecase.Chat(ctx, &domain.ChatRequest{
@ -88,6 +98,7 @@ func (u *WechatAppUsecase) getQAFunc(kbID string, appType domain.AppType) bot.Ge
RemoteIP: "",
ConversationID: ConversationID,
Info: info,
Prompt: wechatApp.Settings.WeChatAppAdvancedSetting.Prompt,
})
if err != nil {
return nil, err

View File

@ -2,13 +2,18 @@ package utils
import (
"net"
"net/http"
"strings"
"github.com/labstack/echo/v4"
)
func GetClientIPFromRemoteAddr(c echo.Context) string {
addr := c.Request().RemoteAddr
return ExtractHostFromRemoteAddr(c.Request())
}
func ExtractHostFromRemoteAddr(r *http.Request) string {
addr := r.RemoteAddr
if addr == "" {
return ""
}

View File

@ -8,7 +8,6 @@
"build:dev": "vite build --m development",
"build": "tsc -b && vite build",
"build:analyze": "tsc -b && vite build -- --analyze",
"icon": "node ./scripts/downLoadIcon.cjs",
"api": "cx-swagger-api"
},
"dependencies": {
@ -19,9 +18,6 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@reduxjs/toolkit": "^2.5.0",
"@tiptap/extension-collaboration": "^3.3.0",
"@tiptap/extension-collaboration-caret": "^3.3.0",
"ace-builds": "^1.43.4",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"echarts": "^5.6.0",
@ -32,9 +28,9 @@
"lottie-react": "^2.4.1",
"lowlight": "^3.3.0",
"prosemirror-state": "^1.4.3",
"react-ace": "^14.0.1",
"react-color-palette": "^7.3.1",
"react-colorful": "^5.6.1",
"react-diff-viewer": "^3.1.1",
"react-dropzone": "^14.3.8",
"react-image-crop": "^11.0.10",
"react-markdown": "^10.1.0",

View File

@ -1,23 +0,0 @@
const fs = require("fs");
const path = require("path");
async function downloadFile(url) {
const iconPath = path.resolve(__dirname, "../src/assets/fonts/iconfont.js");
const iconDir = path.dirname(iconPath);
// 检查目录是否存在,不存在则创建
if (!fs.existsSync(iconDir)) {
fs.mkdirSync(iconDir, { recursive: true });
console.log(`目录 ${iconDir} 已创建`);
}
const response = await fetch(`https:${url}`, {
method: "GET",
// responseType: "stream", // fetch 不支持此参数
}).then((res) => res.text());
fs.writeFileSync(iconPath, response);
console.log("Download Icon Success");
}
let argument = process.argv.splice(2);
downloadFile(argument[0]);

View File

@ -1,5 +1,8 @@
import { AppType, IconMap, ModelProvider } from '@/constant/enums';
import { DomainNodePermissions } from '@/request/types';
import {
ConstsNodeRagInfoStatus,
DomainNodePermissions,
} from '@/request/types';
export type Paging = {
page?: number;
@ -193,6 +196,8 @@ export interface ITreeItem {
parentId?: string;
content_type?: string;
summary?: string;
rag_status?: ConstsNodeRagInfoStatus;
rag_message?: string;
children?: ITreeItem[];
type: 1 | 2;
isEditting?: boolean;
@ -318,7 +323,6 @@ export type WelcomeSetting = {
export type SEOSetting = {
keyword: string;
desc: string;
auto_sitemap: boolean;
};
export type CustomCodeSetting = {
@ -583,6 +587,7 @@ export type ChatConversationItem = {
export type ChatConversationPair = {
user: string;
assistant: string;
thinking_content: string;
created_at: string;
info: {
feedback_content: string;

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

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