Compare commits

...

37 Commits

Author SHA1 Message Date
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
61 changed files with 4251 additions and 2476 deletions

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, userRepository, 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 {

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,22 +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, userRepository, 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,
@ -105,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

@ -60,7 +60,9 @@ func createApp() (*App, error) {
return nil, err
}
authRepo := pg2.NewAuthRepo(db, logger, cacheCache)
nodeUsecase := usecase.NewNodeUsecase(nodeRepository, appRepository, ragRepository, userRepository, 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)
kbRepo := cache2.NewKBRepo(cacheCache)
knowledgeBaseUsecase, err := usecase.NewKnowledgeBaseUsecase(knowledgeBaseRepository, nodeRepository, ragRepository, userRepository, ragService, kbRepo, logger, configConfig)
if err != nil {

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"
)

View File

@ -1565,6 +1565,41 @@ const docTemplate = `{
}
}
},
"/api/v1/model/mode-setting": {
"get": {
"description": "get current model mode setting including mode, API key and chat model",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"model"
],
"summary": "get model mode setting",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.ModelModeSetting"
}
}
}
]
}
}
}
}
},
"/api/v1/model/provider/supported": {
"post": {
"description": "get provider supported model list",
@ -1611,6 +1646,52 @@ const docTemplate = `{
}
}
},
"/api/v1/model/switch-mode": {
"post": {
"description": "switch model mode between manual and auto",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"model"
],
"summary": "switch mode",
"parameters": [
{
"description": "switch mode request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.SwitchModeReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.SwitchModeResp"
}
}
}
]
}
}
}
}
},
"/api/v1/node": {
"post": {
"security": [
@ -4004,6 +4085,17 @@ const docTemplate = `{
"LicenseEditionEnterprise"
]
},
"consts.ModelSettingMode": {
"type": "string",
"enum": [
"manual",
"auto"
],
"x-enum-varnames": [
"ModelSettingModeManual",
"ModelSettingModeAuto"
]
},
"consts.NodeAccessPerm": {
"type": "string",
"enum": [
@ -6258,6 +6350,31 @@ const docTemplate = `{
}
}
},
"domain.ModelModeSetting": {
"type": "object",
"properties": {
"auto_mode_api_key": {
"description": "百智云 API Key",
"type": "string"
},
"chat_model": {
"description": "自定义对话模型名称",
"type": "string"
},
"is_manual_embedding_updated": {
"description": "手动模式下嵌入模型是否更新",
"type": "boolean"
},
"mode": {
"description": "模式: manual 或 auto",
"allOf": [
{
"$ref": "#/definitions/consts.ModelSettingMode"
}
]
}
}
},
"domain.ModelType": {
"type": "string",
"enum": [
@ -7097,6 +7214,37 @@ const docTemplate = `{
"StatPageSceneLogin"
]
},
"domain.SwitchModeReq": {
"type": "object",
"required": [
"mode"
],
"properties": {
"auto_mode_api_key": {
"description": "百智云 API Key",
"type": "string"
},
"chat_model": {
"description": "自定义对话模型名称",
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"manual",
"auto"
]
}
}
},
"domain.SwitchModeResp": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"domain.TextConfig": {
"type": "object",
"properties": {
@ -8325,12 +8473,17 @@ const docTemplate = `{
},
"v1.NodeRestudyReq": {
"type": "object",
"required": [
"kb_id",
"node_ids"
],
"properties": {
"kb_id": {
"type": "string"
},
"node_ids": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}

View File

@ -1558,6 +1558,41 @@
}
}
},
"/api/v1/model/mode-setting": {
"get": {
"description": "get current model mode setting including mode, API key and chat model",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"model"
],
"summary": "get model mode setting",
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.ModelModeSetting"
}
}
}
]
}
}
}
}
},
"/api/v1/model/provider/supported": {
"post": {
"description": "get provider supported model list",
@ -1604,6 +1639,52 @@
}
}
},
"/api/v1/model/switch-mode": {
"post": {
"description": "switch model mode between manual and auto",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"model"
],
"summary": "switch mode",
"parameters": [
{
"description": "switch mode request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.SwitchModeReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.SwitchModeResp"
}
}
}
]
}
}
}
}
},
"/api/v1/node": {
"post": {
"security": [
@ -3997,6 +4078,17 @@
"LicenseEditionEnterprise"
]
},
"consts.ModelSettingMode": {
"type": "string",
"enum": [
"manual",
"auto"
],
"x-enum-varnames": [
"ModelSettingModeManual",
"ModelSettingModeAuto"
]
},
"consts.NodeAccessPerm": {
"type": "string",
"enum": [
@ -6251,6 +6343,31 @@
}
}
},
"domain.ModelModeSetting": {
"type": "object",
"properties": {
"auto_mode_api_key": {
"description": "百智云 API Key",
"type": "string"
},
"chat_model": {
"description": "自定义对话模型名称",
"type": "string"
},
"is_manual_embedding_updated": {
"description": "手动模式下嵌入模型是否更新",
"type": "boolean"
},
"mode": {
"description": "模式: manual 或 auto",
"allOf": [
{
"$ref": "#/definitions/consts.ModelSettingMode"
}
]
}
}
},
"domain.ModelType": {
"type": "string",
"enum": [
@ -7090,6 +7207,37 @@
"StatPageSceneLogin"
]
},
"domain.SwitchModeReq": {
"type": "object",
"required": [
"mode"
],
"properties": {
"auto_mode_api_key": {
"description": "百智云 API Key",
"type": "string"
},
"chat_model": {
"description": "自定义对话模型名称",
"type": "string"
},
"mode": {
"type": "string",
"enum": [
"manual",
"auto"
]
}
}
},
"domain.SwitchModeResp": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
}
},
"domain.TextConfig": {
"type": "object",
"properties": {
@ -8318,12 +8466,17 @@
},
"v1.NodeRestudyReq": {
"type": "object",
"required": [
"kb_id",
"node_ids"
],
"properties": {
"kb_id": {
"type": "string"
},
"node_ids": {
"type": "array",
"minItems": 1,
"items": {
"type": "string"
}

View File

@ -131,6 +131,14 @@ definitions:
- LicenseEditionFree
- LicenseEditionContributor
- LicenseEditionEnterprise
consts.ModelSettingMode:
enum:
- manual
- auto
type: string
x-enum-varnames:
- ModelSettingModeManual
- ModelSettingModeAuto
consts.NodeAccessPerm:
enum:
- open
@ -1628,6 +1636,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
@ -2178,6 +2202,27 @@ definitions:
- StatPageSceneNodeDetail
- StatPageSceneChat
- StatPageSceneLogin
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:
@ -2993,7 +3038,11 @@ definitions:
node_ids:
items:
type: string
minItems: 1
type: array
required:
- kb_id
- node_ids
type: object
v1.NodeRestudyResp:
type: object
@ -4061,6 +4110,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:
@ -4088,6 +4158,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:

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

@ -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

@ -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

@ -40,6 +40,8 @@ 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
}
@ -211,3 +213,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

@ -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 fdc289e24b6ca534c99f49395bd277ff70904d95
Subproject commit c4dc498df094cb617d31c95580db8239a445d652

View File

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

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

@ -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

@ -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

@ -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

@ -27,17 +27,18 @@ import (
)
type NodeUsecase struct {
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
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(
@ -52,19 +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,
userRepo: userRepo,
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,
}
}
@ -220,7 +223,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

View File

@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { Box, Stepper, Step, StepLabel } from '@mui/material';
import { Modal } from '@ctzhian/ui';
import { Modal, message } from '@ctzhian/ui';
import { useLocation } from 'react-router-dom';
import {
setKbC,
@ -10,18 +10,20 @@ import {
import { useAppSelector, useAppDispatch } from '@/store';
import { postApiV1KnowledgeBaseRelease } from '@/request/KnowledgeBase';
import {
Step1Config,
Step2Import,
Step3Publish,
Step4Test,
Step5Decorate,
Step6Complete,
Step1Model,
Step2Config,
Step3Import,
Step4Publish,
Step5Test,
Step6Decorate,
Step7Complete,
} from './steps';
import dayjs from 'dayjs';
// Remove interface as we're using Redux state
const steps = [
'模型配置',
'配置监听',
'录入文档',
'发布内容',
@ -31,20 +33,19 @@ const steps = [
];
const CreateWikiModal = () => {
const { kb_c, kb_id, kbList, modelStatus } = useAppSelector(
state => state.config,
);
const { kb_c, kb_id, kbList } = useAppSelector(state => state.config);
const dispatch = useAppDispatch();
const location = useLocation();
const [open, setOpen] = useState(false);
const [activeStep, setActiveStep] = useState(0);
const [nodeIds, setNodeIds] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const step1ConfigRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const step2ImportRef = useRef<{
const Step1ModelRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const step2ConfigRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const step3ImportRef = useRef<{
onSubmit: () => Promise<Record<'id', string>[]>;
}>(null);
const step5DecorateRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const step6DecorateRef = useRef<{ onSubmit: () => Promise<void> }>(null);
const onCancel = () => {
dispatch(setKbC(false));
@ -66,7 +67,20 @@ const CreateWikiModal = () => {
const handleNext = () => {
if (activeStep === 0) {
setLoading(true);
step1ConfigRef.current
Step1ModelRef.current
?.onSubmit?.()
.then(() => {
setActiveStep(prev => prev + 1);
})
.catch(error => {
message.error(error.message || '模型配置验证失败');
})
.finally(() => {
setLoading(false);
});
} else if (activeStep === 1) {
setLoading(true);
step2ConfigRef.current
?.onSubmit?.()
.then(() => {
setActiveStep(prev => prev + 1);
@ -74,9 +88,9 @@ const CreateWikiModal = () => {
.finally(() => {
setLoading(false);
});
} else if (activeStep === 1) {
} else if (activeStep === 2) {
setLoading(true);
step2ImportRef.current
step3ImportRef.current
?.onSubmit?.()
.then(res => {
setNodeIds(res.map(item => item.id));
@ -85,17 +99,17 @@ const CreateWikiModal = () => {
.finally(() => {
setLoading(false);
});
} else if (activeStep === 2) {
} else if (activeStep === 3) {
setLoading(true);
onPublish().finally(() => {
setActiveStep(prev => prev + 1);
setLoading(false);
});
} else if (activeStep === 3) {
setActiveStep(prev => prev + 1);
} else if (activeStep === 4) {
setActiveStep(prev => prev + 1);
} else if (activeStep === 5) {
setLoading(true);
step5DecorateRef.current
step6DecorateRef.current
?.onSubmit?.()
.then(() => {
setActiveStep(prev => prev + 1);
@ -103,7 +117,7 @@ const CreateWikiModal = () => {
.finally(() => {
setLoading(false);
});
} else if (activeStep === 5) {
} else if (activeStep === 6) {
onCancel();
}
};
@ -117,17 +131,19 @@ const CreateWikiModal = () => {
const renderStepContent = () => {
switch (activeStep) {
case 0:
return <Step1Config ref={step1ConfigRef} />;
return <Step1Model ref={Step1ModelRef} />;
case 1:
return <Step2Import ref={step2ImportRef} />;
return <Step2Config ref={step2ConfigRef} />;
case 2:
return <Step3Publish />;
return <Step3Import ref={step3ImportRef} />;
case 3:
return <Step4Test />;
return <Step4Publish />;
case 4:
return <Step5Decorate ref={step5DecorateRef} nodeIds={nodeIds} />;
return <Step5Test />;
case 5:
return <Step6Complete />;
return <Step6Decorate ref={step6DecorateRef} nodeIds={nodeIds} />;
case 6:
return <Step7Complete />;
default:
return null;
}
@ -148,8 +164,8 @@ const CreateWikiModal = () => {
}, [kb_c]);
useEffect(() => {
if (kbList?.length === 0 && modelStatus) setOpen(true);
}, [kbList, modelStatus]);
if (kbList?.length === 0) setOpen(true);
}, [kbList]);
return (
<Modal

View File

@ -0,0 +1,120 @@
import React, {
useState,
useImperativeHandle,
Ref,
useEffect,
useRef,
} from 'react';
import { Box } from '@mui/material';
import { useAppSelector, useAppDispatch } from '@/store';
import { setModelList } from '@/store/slices/config';
import { getApiV1ModelList, getApiV1ModelModeSetting } from '@/request/Model';
import { GithubComChaitinPandaWikiDomainModelListItem } from '@/request/types';
import ModelConfig, {
ModelConfigRef,
} from '@/components/System/component/ModelConfig';
interface Step1ModelProps {
ref: Ref<{ onSubmit: () => Promise<void> }>;
}
const Step1Model: React.FC<Step1ModelProps> = ({ ref }) => {
const { modelList } = useAppSelector(state => state.config);
const dispatch = useAppDispatch();
const modelConfigRef = useRef<ModelConfigRef>(null);
const [chatModelData, setChatModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [embeddingModelData, setEmbeddingModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [rerankModelData, setRerankModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [analysisModelData, setAnalysisModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const [analysisVLModelData, setAnalysisVLModelData] =
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
const getModelList = () => {
getApiV1ModelList().then(res => {
dispatch(
setModelList(res as GithubComChaitinPandaWikiDomainModelListItem[]),
);
});
};
const handleModelList = (
list: GithubComChaitinPandaWikiDomainModelListItem[],
) => {
const chat = list.find(it => it.type === 'chat') || null;
const embedding = list.find(it => it.type === 'embedding') || null;
const rerank = list.find(it => it.type === 'rerank') || null;
const analysis = list.find(it => it.type === 'analysis') || null;
const analysisVL = list.find(it => it.type === 'analysis-vl') || null;
setChatModelData(chat);
setEmbeddingModelData(embedding);
setRerankModelData(rerank);
setAnalysisModelData(analysis);
setAnalysisVLModelData(analysisVL);
};
useEffect(() => {
if (modelList) {
handleModelList(modelList);
}
}, [modelList]);
const onSubmit = async () => {
// 检查模型模式设置
try {
const modeSetting = await getApiV1ModelModeSetting();
// 如果是 auto 模式,检查是否配置了 API key
if (modeSetting?.mode === 'auto') {
if (!modeSetting.auto_mode_api_key) {
return Promise.reject(new Error('请点击应用完成模型配置'));
}
} else {
// 手动模式检查
if (
!chatModelData ||
!embeddingModelData ||
!rerankModelData ||
!analysisModelData
) {
return Promise.reject(new Error('请配置必要的模型后点击应用'));
}
}
} catch (error) {
if (error instanceof Error) {
return Promise.reject(error);
}
return Promise.reject(new Error('配置模型失败'));
}
return Promise.resolve();
};
useImperativeHandle(ref, () => ({
onSubmit,
}));
return (
<Box>
<ModelConfig
ref={modelConfigRef}
onCloseModal={() => {}}
chatModelData={chatModelData}
embeddingModelData={embeddingModelData}
rerankModelData={rerankModelData}
analysisModelData={analysisModelData}
analysisVLModelData={analysisVLModelData}
getModelList={getModelList}
hideDocumentationHint={true}
showTip={true}
/>
</Box>
);
};
export default Step1Model;

View File

@ -73,11 +73,11 @@ const VALIDATION_RULES = {
},
};
interface Step1ConfigProps {
interface Step2ConfigProps {
ref: Ref<{ onSubmit: () => Promise<unknown> }>;
}
const Step1Config: React.FC<Step1ConfigProps> = ({ ref }) => {
const Step2Config: React.FC<Step2ConfigProps> = ({ ref }) => {
const {
control,
formState: { errors },
@ -358,4 +358,4 @@ const Step1Config: React.FC<Step1ConfigProps> = ({ ref }) => {
);
};
export default Step1Config;
export default Step2Config;

View File

@ -5,11 +5,11 @@ import { postApiV1Node } from '@/request/Node';
import { INIT_DOC_DATA } from './initData';
import { useAppSelector } from '@/store';
interface Step2ImportProps {
interface Step3ImportProps {
ref: Ref<{ onSubmit: () => Promise<Record<'id', string>[]> }>;
}
const Step2Import: React.FC<Step2ImportProps> = ({ ref }) => {
const Step3Import: React.FC<Step3ImportProps> = ({ ref }) => {
const { kb_id } = useAppSelector(state => state.config);
const onSubmit = () => {
return Promise.all(
@ -42,4 +42,4 @@ const Step2Import: React.FC<Step2ImportProps> = ({ ref }) => {
);
};
export default Step2Import;
export default Step3Import;

View File

@ -1,7 +1,7 @@
import { Box, Stack, FormControlLabel, Checkbox } from '@mui/material';
import publish from '@/assets/images/init/publish.png';
const Step3Publish = () => {
const Step4Publish = () => {
return (
<Stack gap={2} sx={{ textAlign: 'center', py: 4 }}>
<Box component='img' src={publish} sx={{ width: '100%' }}></Box>
@ -18,4 +18,4 @@ const Step3Publish = () => {
);
};
export default Step3Publish;
export default Step4Publish;

View File

@ -1,7 +1,7 @@
import { Box, Stack } from '@mui/material';
import test from '@/assets/images/init/test.png';
const Step4Test = () => {
const Step5Test = () => {
return (
<Stack gap={2} sx={{ textAlign: 'center', py: 4 }}>
<Box component='img' src={test} sx={{ width: '100%' }}></Box>
@ -9,4 +9,4 @@ const Step4Test = () => {
);
};
export default Step4Test;
export default Step5Test;

View File

@ -5,12 +5,12 @@ import { INIT_LADING_DATA } from './initData';
import { getApiV1AppDetail, putApiV1App } from '@/request/App';
import { useAppSelector } from '@/store';
interface Step5DecorateProps {
interface Step6DecorateProps {
ref: Ref<{ onSubmit: () => void }>;
nodeIds: string[];
}
const Step5Decorate: React.FC<Step5DecorateProps> = ({ ref, nodeIds }) => {
const Step6Decorate: React.FC<Step6DecorateProps> = ({ ref, nodeIds }) => {
const { kb_id } = useAppSelector(state => state.config);
const onSubmit = () => {
return getApiV1AppDetail({
@ -60,4 +60,4 @@ const Step5Decorate: React.FC<Step5DecorateProps> = ({ ref, nodeIds }) => {
);
};
export default Step5Decorate;
export default Step6Decorate;

View File

@ -3,7 +3,7 @@ import { Box, Stack, Button } from '@mui/material';
import complete from '@/assets/images/init/complete.png';
import { useAppSelector } from '@/store';
const Step6Complete = () => {
const Step7Complete = () => {
const { kbDetail } = useAppSelector(state => state.config);
const wikiUrl = useMemo(() => {
@ -56,4 +56,4 @@ const Step6Complete = () => {
);
};
export default Step6Complete;
export default Step7Complete;

View File

@ -1,6 +1,7 @@
export { default as Step1Config } from './Step1Config';
export { default as Step2Import } from './Step2Import';
export { default as Step3Publish } from './Step3Publish';
export { default as Step4Test } from './Step4Test';
export { default as Step5Decorate } from './Step5Decorate';
export { default as Step6Complete } from './Step6Complete';
export { default as Step1Model } from './Step1Model';
export { default as Step2Config } from './Step2Config';
export { default as Step3Import } from './Step3Import';
export { default as Step4Publish } from './Step4Publish';
export { default as Step5Test } from './Step5Test';
export { default as Step6Decorate } from './Step6Decorate';
export { default as Step7Complete } from './Step7Complete';

View File

@ -1,134 +0,0 @@
const dark = {
primary: {
main: '#fdfdfd',
contrastText: '#000',
},
secondary: {
main: '#2196F3',
lighter: '#D6E4FF',
light: '#84A9FF',
dark: '#1939B7',
darker: '#091A7A',
contrastText: '#fff',
},
info: {
main: '#1890FF',
lighter: '#D0F2FF',
light: '#74CAFF',
dark: '#0C53B7',
darker: '#04297A',
contrastText: '#fff',
},
success: {
main: '#00DF98',
lighter: '#E9FCD4',
light: '#AAF27F',
dark: '#229A16',
darker: '#08660D',
contrastText: 'rgba(0,0,0,0.7)',
},
warning: {
main: '#F7B500',
lighter: '#FFF7CD',
light: '#FFE16A',
dark: '#B78103',
darker: '#7A4F01',
contrastText: 'rgba(0,0,0,0.7)',
},
neutral: {
main: '#1A1A1A',
contrastText: 'rgba(255, 255, 255, 0.60)',
},
error: {
main: '#D93940',
lighter: '#FFE7D9',
light: '#FFA48D',
dark: '#B72136',
darker: '#7A0C2E',
contrastText: '#fff',
},
text: {
primary: '#fff',
secondary: 'rgba(255,255,255,0.7)',
tertiary: 'rgba(255,255,255,0.5)',
disabled: 'rgba(255,255,255,0.26)',
slave: 'rgba(255,255,255,0.05)',
inverseAuxiliary: 'rgba(0,0,0,0.5)',
inverseDisabled: 'rgba(0,0,0,0.15)',
},
divider: '#ededed',
background: {
paper0: '#060608',
paper: '#18181b',
paper2: '#27272a',
default: 'rgba(255,255,255,0.6)',
disabled: 'rgba(15,15,15,0.8)',
chip: 'rgba(145,147,171,0.16)',
circle: '#3B476A',
focus: '#542996',
footer: '#242425',
},
common: {},
shadows: 'transparent',
table: {
head: {
backgroundColor: '#484848',
color: '#fff',
},
row: {
backgroundColor: 'transparent',
hoverColor: 'rgba(48, 58, 70, 0.4)',
},
cell: {
borderColor: '#484848',
},
},
charts: {
color: ['#7267EF', '#36B37E'],
},
};
const darkTheme = {
...dark,
primary: {
...dark.primary,
main: '#6E73FE',
contrastText: '#FFFFFF',
},
error: {
...dark.error,
main: '#F64E54',
},
success: {
...dark.success,
main: '#00DF98',
},
disabled: {
main: '#666',
},
dark: {
dark: '#000',
main: '#14141B',
light: '#202531',
contrastText: '#fff',
},
light: {
main: '#fff',
contrastText: '#000',
},
background: {
...dark.background,
default: '#141923',
paper: '#202531',
footer: '#242425',
},
text: {
...dark.text,
primary: '#FFFFFF',
secondary: 'rgba(255, 255, 255, 0.7)',
tertiary: 'rgba(255, 255, 255, 0.5)',
disabled: 'rgba(255, 255, 255, 0.3)',
},
divider: '#525770',
};
export default darkTheme;

View File

@ -1,139 +0,0 @@
const light = {
primary: {
main: '#3248F2',
contrastText: '#fff',
lighter: '#E6E8EC',
},
secondary: {
main: '#3366FF',
lighter: '#D6E4FF',
light: '#84A9FF',
dark: '#1939B7',
darker: '#091A7A',
contrastText: '#fff',
},
info: {
main: '#0063FF',
lighter: '#D0F2FF',
light: '#74CAFF',
dark: '#0C53B7',
darker: '#04297A',
contrastText: '#fff',
},
success: {
main: '#82DDAF',
lighter: '#E9FCD4',
light: '#AAF27F',
mainShadow: '#36B37E',
dark: '#229A16',
darker: '#08660D',
contrastText: 'rgba(0,0,0,0.7)',
},
warning: {
main: '#FEA145',
lighter: '#FFF7CD',
light: '#FFE16A',
shadow: 'rgba(255, 171, 0, 0.15)',
dark: '#B78103',
darker: '#7A4F01',
contrastText: 'rgba(0,0,0,0.7)',
},
neutral: {
main: '#FFFFFF',
contrastText: 'rgba(0, 0, 0, 0.60)',
},
error: {
main: '#FE4545',
lighter: '#FFE7D9',
light: '#FFA48D',
shadow: 'rgba(255, 86, 48, 0.15)',
dark: '#B72136',
darker: '#7A0C2E',
contrastText: '#FFFFFF',
},
divider: '#ECEEF1',
text: {
primary: '#21222D',
secondary: 'rgba(33,34,35,0.7)',
tertiary: 'rgba(33,34,35,0.5)',
slave: 'rgba(33,34,35,0.3)',
disabled: 'rgba(33,34,35,0.2)',
inverse: '#FFFFFF',
inverseAuxiliary: 'rgba(255,255,255,0.5)',
inverseDisabled: 'rgba(255,255,255,0.15)',
},
background: {
paper0: '#F1F2F8',
paper: '#FFFFFF',
paper2: '#F8F9FA',
default: '#FFFFFF',
chip: '#FFFFFF',
circle: '#E6E8EC',
hover: 'rgba(243, 244, 245, 0.5)',
footer: '#14141B',
},
shadows: 'rgba(68, 80 ,91, 0.1)',
table: {
head: {
height: '50px',
backgroundColor: '#FFFFFF',
color: '#000',
},
row: {
hoverColor: '#F8F9FA',
},
cell: {
height: '72px',
borderColor: '#ECEEF1',
},
},
charts: {
color: ['#673AB7', '#36B37E'],
},
};
const lightTheme = {
...light,
mode: 'light',
primary: {
...light.primary,
main: '#3248F2',
},
error: {
...light.error,
main: '#F64E54',
},
success: {
...light.success,
main: '#00DF98',
},
disabled: {
main: '#666',
},
dark: {
dark: '#000',
main: '#14141B',
light: '#20232A',
contrastText: '#fff',
},
light: {
main: '#fff',
contrastText: '#000',
},
background: {
...light.background,
default: '#fff',
paper: '#F8F9FA',
footer: '#14141B',
},
text: {
...light.text,
primary: '#21222D',
secondary: 'rgba(33,34,45, 0.7)',
tertiary: 'rgba(33,34,45, 0.5)',
disabled: 'rgba(33,34,45, 0.3)',
},
divider: '#ECEEF1',
};
export default lightTheme;

View File

@ -0,0 +1,279 @@
import Card from '@/components/Card';
import { Icon, message } from '@ctzhian/ui';
import {
Box,
Button,
Stack,
TextField,
Select,
MenuItem,
InputAdornment,
IconButton,
} from '@mui/material';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
export interface AutoModelConfigRef {
getFormData: () => {
apiKey: string;
selectedModel: string;
};
}
interface AutoModelConfigProps {
showTip?: boolean;
initialApiKey?: string;
initialChatModel?: string;
onDataChange?: () => void;
}
const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
(props, ref) => {
const {
showTip = false,
initialApiKey = '',
initialChatModel = '',
onDataChange,
} = props;
const [autoConfigApiKey, setAutoConfigApiKey] = useState(initialApiKey);
const [selectedAutoChatModel, setSelectedAutoChatModel] =
useState(initialChatModel);
const [showApiKey, setShowApiKey] = useState(false);
// 默认百智云 Chat 模型列表
const DEFAULT_BAIZHI_CLOUD_CHAT_MODELS: string[] = [
'deepseek-chat',
'deepseek-r1',
'kimi-k2-0711-preview',
'qwen-vl-max-latest',
'glm-4.5',
];
const modelList = DEFAULT_BAIZHI_CLOUD_CHAT_MODELS;
// 当从父组件接收到新的初始值时,更新状态
useEffect(() => {
if (initialApiKey) {
setAutoConfigApiKey(initialApiKey);
}
}, [initialApiKey]);
useEffect(() => {
if (initialChatModel) {
setSelectedAutoChatModel(initialChatModel);
}
}, [initialChatModel]);
// 如果没有选中模型且有可用模型,默认选择第一个
useEffect(() => {
if (modelList.length && !selectedAutoChatModel) {
setSelectedAutoChatModel(modelList[0]);
}
}, [modelList, selectedAutoChatModel]);
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
getFormData: () => ({
apiKey: autoConfigApiKey,
selectedModel: selectedAutoChatModel,
}),
}));
return (
<Stack
sx={{
flex: 1,
p: 2,
pl: 2,
pr: 0,
pt: 0,
overflow: 'hidden',
overflowY: 'auto',
}}
>
<Box>
{/* 提示信息 */}
{showTip && (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
gap: 1,
p: 1.5,
mb: 2,
bgcolor: 'rgba(25, 118, 210, 0.08)',
borderRadius: '8px',
border: '1px solid rgba(25, 118, 210, 0.2)',
}}
>
<Icon
type='icon-info-circle'
sx={{
fontSize: 16,
color: 'primary.main',
flexShrink: 0,
mt: 0.2,
}}
/>
<Box
sx={{
fontSize: 12,
lineHeight: 1.6,
color: 'text.secondary',
}}
>
API Key PandaWiki
</Box>
</Box>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: '16px',
pt: '32px',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
fontSize: 14,
fontWeight: 'bold',
color: 'text.primary',
}}
>
<Box
sx={{
width: 4,
height: 10,
bgcolor: 'primary.main',
borderRadius: '30%',
mr: 1,
}}
/>
API Key
</Box>
<Box
component='a'
href='https://model-square.app.baizhi.cloud/token'
target='_blank'
sx={{
color: 'primary.main',
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
<Icon type='icon-key' sx={{ fontSize: 14 }} />
API Key
</Box>
</Box>
<TextField
fullWidth
size='medium'
type={showApiKey ? 'text' : 'password'}
value={autoConfigApiKey}
onChange={e => {
setAutoConfigApiKey(e.target.value);
onDataChange?.();
}}
InputProps={{
endAdornment: (
<InputAdornment position='end'>
<IconButton
size='small'
onClick={() => setShowApiKey(s => !s)}
>
{showApiKey ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
sx={{
'& .MuiInputBase-root': {
borderRadius: '10px',
height: '52px',
},
}}
/>
</Box>
{!showTip && (
<Box sx={{ mt: 0 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
fontSize: 14,
fontWeight: 'bold',
color: 'text.primary',
mb: '16px',
pt: '32px',
}}
>
<Box
sx={{
width: 4,
height: 10,
bgcolor: 'primary.main',
borderRadius: '30%',
mr: 1,
}}
/>
</Box>
<Stack direction='row' alignItems='center' gap={2}>
<Box sx={{ fontSize: 12, color: 'text.secondary', minWidth: 80 }}>
</Box>
<Select
size='medium'
displayEmpty
value={selectedAutoChatModel}
onChange={e => {
setSelectedAutoChatModel(e.target.value as string);
onDataChange?.();
}}
sx={{
width: 300,
height: '52px',
'& .MuiInputBase-root': {
borderRadius: '10px',
bgcolor: '#F8F8FA',
height: '52px',
},
'& .MuiSelect-select': {
height: '52px',
lineHeight: '52px',
padding: '0 14px',
display: 'flex',
alignItems: 'center',
},
}}
renderValue={sel =>
sel && (sel as string).length
? (sel as string)
: '请选择聊天模型'
}
>
{modelList.map((model: string) => (
<MenuItem key={model} value={model}>
{model}
</MenuItem>
))}
</Select>
</Stack>
</Box>
)}
</Stack>
);
},
);
export default AutoModelConfig;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -37,10 +37,12 @@ const RagErrorReStart = ({
const ragErrorData =
res?.filter(
item =>
item.type === 2 &&
item.rag_info?.status &&
[
ConstsNodeRagInfoStatus.NodeRagStatusBasicFailed,
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceFailed,
ConstsNodeRagInfoStatus.NodeRagStatusBasicPending,
].includes(item.rag_info.status),
) || [];
setList(ragErrorData);
@ -60,14 +62,14 @@ const RagErrorReStart = ({
kb_id,
node_ids: [...selected],
}).then(() => {
message.success('正在重新学习');
message.success('正在学习');
setSelected([]);
onClose();
refresh();
});
} else {
message.error(
list.length > 0 ? '请选择重新学习的文档' : '暂无学习失败的文档',
list.length > 0 ? '请选择要学习的文档' : '暂无需要学习的文档',
);
}
};
@ -83,7 +85,7 @@ const RagErrorReStart = ({
}, [selected, list]);
return (
<Modal title='重新学习' open={open} onCancel={onClose} onOk={onSubmit}>
<Modal title='学习文档' open={open} onCancel={onClose} onOk={onSubmit}>
<Stack
direction='row'
component='label'
@ -97,7 +99,7 @@ const RagErrorReStart = ({
}}
>
<Box>
/
<Box
component='span'
sx={{ color: 'text.tertiary', fontSize: 12, pl: 1 }}

View File

@ -25,12 +25,6 @@ import DocDelete from '../../component/DocDelete';
interface HeaderProps {
edit: boolean;
collaborativeUsers?: Array<{
id: string;
name: string;
color: string;
}>;
isSyncing?: boolean;
detail: V1NodeDetailResp;
updateDetail: (detail: V1NodeDetailResp) => void;
handleSave: () => void;
@ -39,8 +33,6 @@ interface HeaderProps {
const Header = ({
edit,
collaborativeUsers = [],
isSyncing = false,
detail,
updateDetail,
handleSave,
@ -54,10 +46,6 @@ const Header = ({
const { catalogOpen, nodeDetail, setCatalogOpen } =
useOutletContext<WrapContext>();
// const docWidth = useMemo(() => {
// return nodeDetail?.meta?.doc_width || 'full';
// }, [nodeDetail]);
const [renameOpen, setRenameOpen] = useState(false);
const [delOpen, setDelOpen] = useState(false);
const [publishOpen, setPublishOpen] = useState(false);
@ -68,22 +56,6 @@ const Header = ({
return license.edition === 2;
}, [license]);
// const updateDocWidth = (doc_width: string) => {
// if (!nodeDetail) return;
// putApiV1NodeDetail({
// id: nodeDetail.id!,
// kb_id,
// doc_width,
// }).then(() => {
// updateDetail({
// meta: {
// ...nodeDetail.meta,
// doc_width,
// },
// });
// });
// };
const handlePublish = useCallback(() => {
if (nodeDetail?.status === 2 && !edit) {
message.info('当前已是最新版本!');

View File

@ -39,8 +39,6 @@ const LoadingEditorWrap = () => {
>
<Header
edit={false}
isSyncing={isSyncing}
collaborativeUsers={collaborativeUsers}
detail={{}}
updateDetail={() => {}}
handleSave={() => {}}

View File

@ -3,6 +3,7 @@ import Emoji from '@/components/Emoji';
import { postApiV1CreationTabComplete, putApiV1NodeDetail } from '@/request';
import { V1NodeDetailResp } from '@/request/types';
import { useAppSelector } from '@/store';
import { completeIncompleteLinks } from '@/utils';
import {
EditorMarkdown,
MarkdownEditorRef,
@ -39,7 +40,7 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
const { license } = useAppSelector(state => state.config);
const state = useLocation().state as { node?: V1NodeDetailResp };
const { catalogOpen, nodeDetail, setNodeDetail, onSave, docWidth } =
const { catalogOpen, setCatalogOpen, nodeDetail, setNodeDetail, onSave } =
useOutletContext<WrapContext>();
const storageTocOpen = localStorage.getItem('toc-open');
@ -117,19 +118,6 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
});
};
const handleExport = async (type: string) => {
const value = editorRef?.getContent() || '';
if (!value) return;
const blob = new Blob([value], { type: `text/${type}` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${nodeDetail?.name}.${type}`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
};
const handleUpload = async (
file: File,
onProgress?: (progress: { progress: number }) => void,
@ -207,18 +195,50 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
onAiWritingGetSuggestion: handleAiWritingGetSuggestion,
});
const handleExport = useCallback(
async (type: string) => {
if (editorRef) {
let value = nodeDetail?.content || '';
if (!isMarkdown) {
value = editorRef.getContent() || '';
}
if (!value) return;
const content = completeIncompleteLinks(value);
const blob = new Blob([content], { type: `text/${type}` });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${nodeDetail?.name}.${type}`;
a.click();
URL.revokeObjectURL(url);
message.success('导出成功');
}
},
[editorRef, nodeDetail?.content, nodeDetail?.name, isMarkdown],
);
const checkIfEdited = useCallback(() => {
const currentContent = editorRef?.getContent() || '';
const currentSummary = summary;
const currentEmoji = nodeDetail?.meta?.emoji || '';
if (editorRef) {
let value = nodeDetail?.content || '';
if (!isMarkdown) {
value = editorRef.getContent() || '';
}
const currentSummary = summary;
const currentEmoji = nodeDetail?.meta?.emoji || '';
const hasChanges =
value !== initialStateRef.current.content ||
currentSummary !== initialStateRef.current.summary ||
currentEmoji !== initialStateRef.current.emoji;
const hasChanges =
currentContent !== initialStateRef.current.content ||
currentSummary !== initialStateRef.current.summary ||
currentEmoji !== initialStateRef.current.emoji;
setIsEditing(hasChanges);
}, [editorRef, summary, nodeDetail?.meta?.emoji, isMarkdown]);
setIsEditing(hasChanges);
}
}, [
editorRef,
summary,
nodeDetail?.meta?.emoji,
nodeDetail?.content,
isMarkdown,
]);
const handleAiGenerate = useCallback(() => {
if (editorRef.editor) {
@ -235,10 +255,13 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
const changeCatalogItem = useCallback(() => {
if (editorRef && editorRef.editor) {
const content = editorRef.getContent();
updateDetail({
content: content,
});
let content = nodeDetail?.content || '';
if (!isMarkdown) {
content = editorRef.getContent();
updateDetail({
content: content,
});
}
onSave(content);
initialStateRef.current = {
content: content,
@ -247,28 +270,28 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
};
setIsEditing(false);
}
}, [id, editorRef, onSave, summary, nodeDetail?.meta?.emoji, isMarkdown]);
}, [
id,
editorRef,
onSave,
summary,
nodeDetail?.meta?.emoji,
nodeDetail?.content,
isMarkdown,
]);
const handleGlobalSave = useCallback(
const handleGlobalKeydown = useCallback(
(event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
event.preventDefault();
if (editorRef && editorRef.editor) {
const content = editorRef.getContent();
updateDetail({
content: content,
});
onSave(content);
initialStateRef.current = {
content: content,
summary: summary,
emoji: nodeDetail?.meta?.emoji || '',
};
setIsEditing(false);
}
changeCatalogItem();
}
if ((event.ctrlKey || event.metaKey) && event.key === 'b') {
event.preventDefault();
setCatalogOpen(!catalogOpen);
}
},
[editorRef, onSave, id, summary, nodeDetail?.meta?.emoji, isMarkdown],
[changeCatalogItem, catalogOpen, setCatalogOpen],
);
const renderEditorTitleEmojiSummary = () => {
@ -506,11 +529,11 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
}, [defaultDetail]);
useEffect(() => {
document.addEventListener('keydown', handleGlobalSave);
document.addEventListener('keydown', handleGlobalKeydown);
return () => {
document.removeEventListener('keydown', handleGlobalSave);
document.removeEventListener('keydown', handleGlobalKeydown);
};
}, [handleGlobalSave]);
}, [handleGlobalKeydown]);
useEffect(() => {
if (state && state.node && editorRef.editor) {
@ -540,11 +563,14 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
useEffect(() => {
const handleTabClose = () => {
if (isEditing) {
const content = editorRef?.getContent() || '';
let content = nodeDetail?.content || '';
if (!isMarkdown) {
content = editorRef.getContent();
updateDetail({
content: content,
});
}
onSave(content);
updateDetail({
content: content,
});
// 更新初始状态引用
initialStateRef.current = {
content: content,
@ -555,9 +581,14 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
};
const handleVisibilityChange = () => {
if (document.hidden && isEditing) {
const content = editorRef?.getContent() || '';
let content = nodeDetail?.content || '';
if (!isMarkdown) {
content = editorRef.getContent();
updateDetail({
content: content,
});
}
onSave(content);
updateDetail({});
// 更新初始状态引用
initialStateRef.current = {
content: content,
@ -572,7 +603,14 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
window.removeEventListener('beforeunload', handleTabClose);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [editorRef, isEditing, summary, nodeDetail?.meta?.emoji]);
}, [
editorRef,
isEditing,
summary,
nodeDetail?.meta?.emoji,
nodeDetail?.content,
isMarkdown,
]);
useEffect(() => {
return () => {
@ -602,17 +640,22 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
detail={nodeDetail!}
updateDetail={updateDetail}
handleSave={async () => {
const content = editorRef?.getContent() || '';
updateDetail({
content: content,
});
await onSave(content);
initialStateRef.current = {
content: content,
summary: summary,
emoji: nodeDetail?.meta?.emoji || '',
};
setIsEditing(false);
if (editorRef) {
let content = nodeDetail?.content || '';
if (!isMarkdown) {
content = editorRef.getContent();
updateDetail({
content: content,
});
}
await onSave(content);
initialStateRef.current = {
content: content,
summary: summary,
emoji: nodeDetail?.meta?.emoji || '',
};
setIsEditing(false);
}
}}
handleExport={handleExport}
/>
@ -620,7 +663,10 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
<Toolbar editorRef={editorRef} handleAiGenerate={handleAiGenerate} />
)}
</Box>
<Box sx={{ ...(fixedToc && { display: 'flex' }) }}>
<Box
sx={{ ...(fixedToc && { display: 'flex' }) }}
onKeyDown={event => event.stopPropagation()}
>
{isMarkdown ? (
<Box
sx={{
@ -630,17 +676,19 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
flex: 1,
}}
>
<Box sx={{}}>{renderEditorTitleEmojiSummary()}</Box>
<Box>{renderEditorTitleEmojiSummary()}</Box>
<EditorMarkdown
ref={markdownEditorRef}
editor={editorRef.editor}
value={nodeDetail?.content || ''}
onUpload={handleUpload}
placeholder='请输入文档内容'
onAceChange={value => {
updateDetail({
content: value,
});
}}
height='calc(100vh - 340px)'
height='calc(100vh - 103px)'
/>
</Box>
) : (

View File

@ -49,9 +49,9 @@ const Content = () => {
const search = searchParams.get('search') || '';
const [supportSelect, setBatchOpen] = useState(false);
const [ragErrorCount, setRagErrorCount] = useState(0);
const [ragErrorIds, setRagErrorIds] = useState<string[]>([]);
const [ragErrorOpen, setRagErrorOpen] = useState(false);
const [ragReStartCount, setRagStartCount] = useState(0);
const [ragIds, setRagIds] = useState<string[]>([]);
const [ragOpen, setRagOpen] = useState(false);
const [publish, setPublish] = useState({
// published: 0,
unpublished: 0,
@ -128,8 +128,8 @@ const Content = () => {
};
const handleRestudy = (item: ITreeItem) => {
setRagErrorOpen(true);
setRagErrorIds([item.id]);
setRagOpen(true);
setRagIds([item.id]);
};
const handleProperties = (item: ITreeItem) => {
@ -265,23 +265,25 @@ const Content = () => {
// },
]
: []),
...(item?.rag_status &&
...(item.type === 2 &&
item.rag_status &&
[
ConstsNodeRagInfoStatus.NodeRagStatusBasicFailed,
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceFailed,
ConstsNodeRagInfoStatus.NodeRagStatusBasicPending,
].includes(item.rag_status)
? [
{
label: '重新学习',
label:
item.rag_status ===
ConstsNodeRagInfoStatus.NodeRagStatusBasicPending
? '学习文档'
: '重新学习',
key: 'restudy',
onClick: () => handleRestudy(item),
},
]
: []),
...(!isEditing
? [{ label: '重命名', key: 'rename', onClick: renameItem }]
: []),
{ label: '删除', key: 'delete', onClick: () => handleDelete(item) },
...(item.type === 2
? [
{
@ -291,6 +293,10 @@ const Content = () => {
},
]
: []),
...(!isEditing
? [{ label: '重命名', key: 'rename', onClick: renameItem }]
: []),
{ label: '删除', key: 'delete', onClick: () => handleDelete(item) },
];
};
@ -335,13 +341,15 @@ const Content = () => {
setPublish({
unpublished: res.filter(it => it.status === 1).length,
});
setRagErrorCount(
setRagStartCount(
res.filter(
it =>
it.type === 2 &&
it.rag_info?.status &&
[
ConstsNodeRagInfoStatus.NodeRagStatusBasicFailed,
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceFailed,
ConstsNodeRagInfoStatus.NodeRagStatusBasicPending,
].includes(it.rag_info.status),
).length,
);
@ -421,7 +429,7 @@ const Content = () => {
</Button>
</>
)}
{ragErrorCount > 0 && (
{ragReStartCount > 0 && (
<>
<Box
sx={{
@ -431,16 +439,16 @@ const Content = () => {
ml: 2,
}}
>
{ragErrorCount}
{ragReStartCount}
</Box>
<Button
size='small'
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
onClick={() => {
setRagErrorOpen(true);
setRagOpen(true);
}}
>
</Button>
</>
)}
@ -729,11 +737,11 @@ const Content = () => {
refresh={getData}
/>
<RagErrorReStart
open={ragErrorOpen}
defaultSelected={ragErrorIds}
open={ragOpen}
defaultSelected={ragIds}
onClose={() => {
setRagErrorOpen(false);
setRagErrorIds([]);
setRagOpen(false);
setRagIds([]);
}}
refresh={getData}
/>

View File

@ -137,9 +137,7 @@ const ActionMenu = ({
{record.status! !== -1 && (
<MenuItem onClick={handleReject}></MenuItem>
)}
<MenuItem color='error' onClick={handleDelete}>
</MenuItem>
<MenuItem onClick={handleDelete}></MenuItem>
</Menu>
</>
);
@ -164,11 +162,8 @@ const Comments = ({
useState<DomainWebAppCommentSettings | null>(null);
const isEnableReview = useMemo(() => {
return !!(
appSetting?.moderation_enable &&
(license.edition === 1 || license.edition === 2)
);
}, [appSetting, license]);
return !!(license.edition === 1 || license.edition === 2);
}, [license]);
useEffect(() => {
setShowCommentsFilter(isEnableReview);
@ -311,7 +306,8 @@ const Comments = ({
title: '操作',
width: 120,
render: (text: string, record: DomainCommentListItem) => {
return isEnableReview ? (
return isEnableReview &&
(appSetting?.moderation_enable || record.status === 0) ? (
<ActionMenu
record={record}
onDeleteComment={onDeleteComment}

View File

@ -15,8 +15,11 @@ import {
DomainCreateModelReq,
DomainGetProviderModelListReq,
DomainGetProviderModelListResp,
DomainModelModeSetting,
DomainPWResponse,
DomainResponse,
DomainSwitchModeReq,
DomainSwitchModeResp,
DomainUpdateModelReq,
GithubComChaitinPandaWikiDomainCheckModelReq,
GithubComChaitinPandaWikiDomainCheckModelResp,
@ -124,6 +127,32 @@ export const getApiV1ModelList = (params: RequestParams = {}) =>
...params,
});
/**
* @description get current model mode setting including mode, API key and chat model
*
* @tags model
* @name GetApiV1ModelModeSetting
* @summary get model mode setting
* @request GET:/api/v1/model/mode-setting
* @response `200` `(DomainResponse & {
data?: DomainModelModeSetting,
})` OK
*/
export const getApiV1ModelModeSetting = (params: RequestParams = {}) =>
httpRequest<
DomainResponse & {
data?: DomainModelModeSetting;
}
>({
path: `/api/v1/model/mode-setting`,
method: "GET",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description get provider supported model list
*
@ -153,3 +182,33 @@ export const postApiV1ModelProviderSupported = (
format: "json",
...requestParams,
});
/**
* @description switch model mode between manual and auto
*
* @tags model
* @name PostApiV1ModelSwitchMode
* @summary switch mode
* @request POST:/api/v1/model/switch-mode
* @response `200` `(DomainResponse & {
data?: DomainSwitchModeResp,
})` OK
*/
export const postApiV1ModelSwitchMode = (
request: DomainSwitchModeReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: DomainSwitchModeResp;
}
>({
path: `/api/v1/model/switch-mode`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -922,6 +922,15 @@ export interface DomainMetricsConfig {
type?: string;
}
export interface DomainModelModeSetting {
/** 百智云 API Key */
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
/** 模式: manual 或 auto */
mode?: string;
}
export interface DomainMoveNodeReq {
id: string;
kb_id: string;
@ -1195,6 +1204,18 @@ export interface DomainStatPageReq {
scene: 1 | 2 | 3 | 4;
}
export interface DomainSwitchModeReq {
/** 百智云 API Key */
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
mode: "manual" | "auto";
}
export interface DomainSwitchModeResp {
message?: string;
}
export interface DomainTextConfig {
title?: string;
type?: string;

View File

@ -152,3 +152,196 @@ export const validateUrl = (url: string): boolean => {
return false;
}
};
/**
*
*/
export interface CompleteLinksOptions {
/**
* //example.com的处理策略
* - 'preserve':
* - 'current': 使http https
* - 'https': 使 https
* - 'http': 使 http
*/
schemaRelative?: 'preserve' | 'current' | 'https' | 'http';
/**
* FTP
* - 'preserve':
* - 'https': httpsftp://example.com -> https://example.com
* - 'remove': ftp:// 前缀,转为普通域名
*/
ftpProtocol?: 'preserve' | 'https' | 'remove';
/**
* HTTP
* - 'preserve':
* - 'https': https
*/
httpProtocol?: 'preserve' | 'https';
/**
* 使
* - 'https': 使 https
* - 'http': 使 http
* - 'current': 使
*/
bareDomainProtocol?: 'https' | 'http' | 'current';
}
/**
*
* - Markdown : [title](href)
* - HTML : <a href="...">...</a>
* - HTML src : <img src="...">, <iframe src="...">, <script src="...">
* - // window.location.href
* - / example.com / sub.example.com
* - (http/https/ftp/mailto/tel/data等)(#)
*
* @param text
* @param options
*/
export function completeIncompleteLinks(
text: string,
options: CompleteLinksOptions = {},
): string {
if (!text) return text;
const {
schemaRelative = 'https',
ftpProtocol = 'preserve',
httpProtocol = 'preserve',
bareDomainProtocol = 'https',
} = options;
const baseHref =
typeof window !== 'undefined' && window.location
? window.location.href
: '';
const currentProtocol =
typeof window !== 'undefined' && window.location
? window.location.protocol
: 'https:';
const isProtocolLike = (href: string) =>
/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href);
const isHash = (href: string) => href.startsWith('#');
const isSchemaRelative = (href: string) => href.startsWith('//');
const isBareDomain = (href: string) => {
if (/[\s"'<>]/.test(href)) return false;
if (href.startsWith('/') || href.startsWith('.')) return false;
return /^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+(?::\d+)?(\/.*)?$/.test(href);
};
const getProtocolForBareDomain = (): string => {
if (bareDomainProtocol === 'current') {
return currentProtocol;
}
return bareDomainProtocol === 'http' ? 'http:' : 'https:';
};
const resolveHref = (href: string): string => {
const trimmed = href.trim();
if (!trimmed) return href;
// 锚点链接保持原样
if (isHash(trimmed)) return trimmed;
// 处理协议相对链接(//example.com
if (isSchemaRelative(trimmed)) {
if (schemaRelative === 'preserve') return trimmed;
if (schemaRelative === 'current') return currentProtocol + trimmed;
if (schemaRelative === 'http') return 'http:' + trimmed;
return 'https:' + trimmed; // 默认 https
}
// 处理已有协议的链接
if (isProtocolLike(trimmed)) {
const protocolMatch = trimmed.match(/^([a-zA-Z][a-zA-Z\d+\-.]*):/);
if (protocolMatch) {
const protocol = protocolMatch[1].toLowerCase();
// 处理 FTP 协议
if (protocol === 'ftp') {
if (ftpProtocol === 'preserve') return trimmed;
if (ftpProtocol === 'https') {
return trimmed.replace(/^ftp:/i, 'https:');
}
if (ftpProtocol === 'remove') {
return trimmed.replace(/^ftp:\/\//i, '');
}
}
// 处理 HTTP 协议
if (protocol === 'http') {
if (httpProtocol === 'preserve') return trimmed;
if (httpProtocol === 'https') {
return trimmed.replace(/^http:/i, 'https:');
}
}
// 其他协议https, mailto, tel, data 等)保持原样
return trimmed;
}
}
// 处理裸域名
if (isBareDomain(trimmed)) {
const protocol = getProtocolForBareDomain();
return `${protocol}//${trimmed}`;
}
// 处理相对路径、根路径、上级路径
try {
if (baseHref) {
return new URL(trimmed, baseHref).toString();
}
} catch {
// ignore
}
return trimmed;
};
// 处理 Markdown: [text](href)
const mdRe = /\[([^\]]+)\]\(([^)]+)\)/g;
text = text.replace(mdRe, (_m, label: string, href: string) => {
const completed = resolveHref(href);
return `[${label}](${completed})`;
});
// 处理 HTML: <a href="..."> / <a href='...'>
const htmlRe = /(<a\b[^>]*?\bhref=(["']))([^"']+)(\2)/gi;
text = text.replace(
htmlRe,
(
_m: string,
pre: string,
quote: string,
href: string,
postQuote: string,
) => {
const completed = resolveHref(href);
return `${pre}${completed}${postQuote}`;
},
);
// 处理 HTML 标签中的 src 属性: <img src="...">, <iframe src="...">, <script src="..."> 等
const srcRe = /(<[a-zA-Z][a-zA-Z0-9]*\b[^>]*?\bsrc=(["']))([^"']+)(\2)/gi;
text = text.replace(
srcRe,
(
_m: string,
pre: string,
quote: string,
src: string,
postQuote: string,
) => {
const completed = resolveHref(src);
return `${pre}${completed}${postQuote}`;
},
);
return text;
}

View File

@ -13,7 +13,7 @@ import Feedback from '@/components/feedback';
import { handleThinkingContent } from './utils';
import { useSmartScroll } from '@/hooks';
import { useTheme } from '@mui/material';
import { v4 as uuidv4 } from 'uuid';
import {
IconCai,
IconCaied,
@ -21,7 +21,6 @@ import {
IconZan,
IconZaned,
} from '@/components/icons';
import MarkDown from '@/components/markdown';
import MarkDown2 from '@/components/markdown2';
import { postShareV1ChatFeedback } from '@/request/ShareChat';
import { copyText } from '@/utils';
@ -84,6 +83,7 @@ export interface ConversationItem {
source: 'history' | 'chat';
chunk_result: ChunkResultItem[];
thinking_content: string;
id: string;
}
dayjs.extend(relativeTime);
@ -153,10 +153,9 @@ const AiQaContent: React.FC<{
const searchParams = useSearchParams();
// 使用智能滚动 hook
const { scrollToBottom, setShouldAutoScroll } = useSmartScroll({
// 使用智能滚动 hook(内置 ResizeObserver 自动监听内容高度变化,自动滚动)
const { setShouldAutoScroll } = useSmartScroll({
container: '.conversation-container',
threshold: 10,
behavior: 'smooth',
});
@ -514,6 +513,7 @@ const AiQaContent: React.FC<{
source: 'chat',
chunk_result: [],
thinking_content: '',
id: uuidv4(),
});
messageIdRef.current = '';
setConversation(newConversation);
@ -527,7 +527,7 @@ const AiQaContent: React.FC<{
setThinking(4);
};
const { mobile = false, themeMode = 'light', kbDetail } = useStore();
const { mobile = false, kbDetail } = useStore();
const isFeedbackEnabled =
// @ts-ignore
@ -631,6 +631,7 @@ const AiQaContent: React.FC<{
source: 'history',
chunk_result: [],
thinking_content: '',
id: uuidv4(),
});
}
current = {
@ -648,6 +649,7 @@ const AiQaContent: React.FC<{
current.message_id = '';
current.thinking_content = thinkingContent;
current.source = 'history';
current.id = uuidv4();
conversation.push(current as ConversationItem);
current = {};
}
@ -664,6 +666,7 @@ const AiQaContent: React.FC<{
source: 'history',
chunk_result: [],
thinking_content: '',
id: uuidv4(),
});
}
}
@ -673,18 +676,6 @@ const AiQaContent: React.FC<{
}
}, []);
useEffect(() => {
if (!loading) {
scrollToBottom();
}
}, [loading]);
useEffect(() => {
if (conversation.length > 0) {
scrollToBottom();
}
}, [conversation]);
return (
<StyledMainContainer className={palette.mode === 'dark' ? 'md-dark' : ''}>
{/* 无对话时显示欢迎界面 */}
@ -784,82 +775,26 @@ const AiQaContent: React.FC<{
{/* 有对话时显示对话历史 */}
<StyledConversationContainer
direction='column'
gap={2}
className='conversation-container'
sx={{
mb: conversation?.length > 0 ? 2 : 0,
display: conversation.length > 0 ? 'flex' : 'none',
}}
>
{conversation.map((item, index) => (
<StyledConversationItem key={index}>
{/* 用户问题气泡 - 右对齐 */}
<StyledUserBubble>{item.q}</StyledUserBubble>
<Stack gap={2}>
{conversation.map((item, index) => (
<StyledConversationItem key={item.id}>
{/* 用户问题气泡 - 右对齐 */}
<StyledUserBubble>{item.q}</StyledUserBubble>
{/* AI回答气泡 - 左对齐 */}
<StyledAiBubble>
{/* 搜索结果 */}
{item.chunk_result.length > 0 && (
<StyledChunkAccordion defaultExpanded>
<StyledChunkAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
<Typography
variant='body2'
sx={theme => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.5),
})}
{/* AI回答气泡 - 左对齐 */}
<StyledAiBubble>
{/* 搜索结果 */}
{item.chunk_result.length > 0 && (
<StyledChunkAccordion defaultExpanded>
<StyledChunkAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
{item.chunk_result.length}
</Typography>
</StyledChunkAccordionSummary>
<StyledChunkAccordionDetails>
<Stack gap={1}>
{item.chunk_result.map((chunk, chunkIndex) => (
<StyledChunkItem key={chunkIndex}>
<Typography
variant='body2'
className='hover-primary'
sx={theme => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.5),
})}
onClick={() => {
window.open(`/node/${chunk.node_id}`, '_blank');
}}
>
{chunk.name}
</Typography>
</StyledChunkItem>
))}
</Stack>
</StyledChunkAccordionDetails>
</StyledChunkAccordion>
)}
{/* 加载状态 */}
{index === conversation.length - 1 && loading && (
<LoadingContent thinking={thinking} />
)}
{/* 思考过程 */}
{!!item.thinking_content && (
<StyledThinkingAccordion defaultExpanded>
<StyledThinkingAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
<Stack direction='row' alignItems='center' gap={1}>
{thinking === 2 && index === conversation.length - 1 && (
<Image
src={aiLoading}
alt='ai-loading'
width={20}
height={20}
/>
)}
<Typography
variant='body2'
sx={theme => ({
@ -867,85 +802,139 @@ const AiQaContent: React.FC<{
color: alpha(theme.palette.text.primary, 0.5),
})}
>
{thinking === 2 && index === conversation.length - 1
? '思考中...'
: '已思考'}
{item.chunk_result.length}
</Typography>
</Stack>
</StyledThinkingAccordionSummary>
</StyledChunkAccordionSummary>
<StyledThinkingAccordionDetails>
<MarkDown2
content={item.thinking_content || ''}
autoScroll={false}
/>
</StyledThinkingAccordionDetails>
</StyledThinkingAccordion>
)}
{/* AI回答内容 */}
<StyledAiBubbleContent>
{item.source === 'history' ? (
<MarkDown content={item.a} />
) : (
<MarkDown2 content={item.a} autoScroll={false} />
<StyledChunkAccordionDetails>
<Stack gap={1}>
{item.chunk_result.map((chunk, chunkIndex) => (
<StyledChunkItem key={chunkIndex}>
<Typography
variant='body2'
className='hover-primary'
sx={theme => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.5),
})}
onClick={() => {
window.open(`/node/${chunk.node_id}`, '_blank');
}}
>
{chunk.name}
</Typography>
</StyledChunkItem>
))}
</Stack>
</StyledChunkAccordionDetails>
</StyledChunkAccordion>
)}
</StyledAiBubbleContent>
{/* 操作按钮 */}
{(index !== conversation.length - 1 || !loading) && (
<StyledActionStack
direction={mobile ? 'column' : 'row'}
alignItems={mobile ? 'flex-start' : 'center'}
justifyContent='space-between'
gap={mobile ? 1 : 3}
>
<Stack direction='row' gap={3} alignItems='center'>
<span> {dayjs(item.update_time).fromNow()}</span>
{/* 加载状态 */}
{index === conversation.length - 1 && loading && (
<LoadingContent thinking={thinking} />
)}
<IconCopy
sx={{ cursor: 'pointer' }}
onClick={() => {
copyText(item.a);
}}
/>
{/* 思考过程 */}
{!!item.thinking_content && (
<StyledThinkingAccordion defaultExpanded>
<StyledThinkingAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
<Stack direction='row' alignItems='center' gap={1}>
{thinking === 2 &&
index === conversation.length - 1 && (
<Image
src={aiLoading}
alt='ai-loading'
width={20}
height={20}
/>
)}
{isFeedbackEnabled && item.source === 'chat' && (
<>
{item.score === 1 && (
<IconZaned sx={{ cursor: 'pointer' }} />
)}
{item.score !== 1 && (
<IconZan
sx={{ cursor: 'pointer' }}
onClick={() => {
if (item.score === 0)
handleScore(item.message_id, 1);
}}
/>
)}
{item.score !== -1 && (
<IconCai
sx={{ cursor: 'pointer' }}
onClick={() => {
if (item.score === 0) {
setConversationItem(item);
setOpen(true);
}
}}
/>
)}
{item.score === -1 && (
<IconCaied sx={{ cursor: 'pointer' }} />
)}
</>
)}
</Stack>
</StyledActionStack>
)}
</StyledAiBubble>
</StyledConversationItem>
))}
<Typography
variant='body2'
sx={theme => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.5),
})}
>
{thinking === 2 && index === conversation.length - 1
? '思考中...'
: '已思考'}
</Typography>
</Stack>
</StyledThinkingAccordionSummary>
<StyledThinkingAccordionDetails>
<MarkDown2
content={item.thinking_content || ''}
autoScroll={false}
/>
</StyledThinkingAccordionDetails>
</StyledThinkingAccordion>
)}
{/* AI回答内容 */}
<StyledAiBubbleContent>
<MarkDown2 content={item.a} autoScroll={false} />
</StyledAiBubbleContent>
{/* 操作按钮 */}
{(index !== conversation.length - 1 || !loading) && (
<StyledActionStack
direction={mobile ? 'column' : 'row'}
alignItems={mobile ? 'flex-start' : 'center'}
justifyContent='space-between'
gap={mobile ? 1 : 3}
>
<Stack direction='row' gap={3} alignItems='center'>
<span> {dayjs(item.update_time).fromNow()}</span>
<IconCopy
sx={{ cursor: 'pointer' }}
onClick={() => {
copyText(item.a);
}}
/>
{isFeedbackEnabled && item.source === 'chat' && (
<>
{item.score === 1 && (
<IconZaned sx={{ cursor: 'pointer' }} />
)}
{item.score !== 1 && (
<IconZan
sx={{ cursor: 'pointer' }}
onClick={() => {
if (item.score === 0)
handleScore(item.message_id, 1);
}}
/>
)}
{item.score !== -1 && (
<IconCai
sx={{ cursor: 'pointer' }}
onClick={() => {
if (item.score === 0) {
setConversationItem(item);
setOpen(true);
}
}}
/>
)}
{item.score === -1 && (
<IconCaied sx={{ cursor: 'pointer' }} />
)}
</>
)}
</Stack>
</StyledActionStack>
)}
</StyledAiBubble>
</StyledConversationItem>
))}
</Stack>
</StyledConversationContainer>
{conversation.length > 0 && (
<Button

View File

@ -1,9 +1,56 @@
'use client';
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { createRoot } from 'react-dom/client';
import { styled, SvgIcon, SvgIconProps } from '@mui/material';
// ==================== 图片数据缓存 ====================
// 全局图片 blob URL 缓存,避免重复请求 OSS
const imageBlobCache = new Map<string, string>();
// 下载图片并转换为 blob URL
const fetchImageAsBlob = async (src: string): Promise<string> => {
// 检查缓存
if (imageBlobCache.has(src)) {
return imageBlobCache.get(src)!;
}
try {
const response = await fetch(src, {
method: 'GET',
mode: 'cors',
credentials: 'omit',
});
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
// 缓存 blob URL
imageBlobCache.set(src, blobUrl);
return blobUrl;
} catch (error) {
console.error('Error fetching image as blob:', error);
throw error;
}
};
// 导出获取图片 blob URL 的函数
export const getImageBlobUrl = (src: string): string | null => {
return imageBlobCache.get(src) || null;
};
export const clearImageBlobCache = () => {
imageBlobCache.forEach(url => {
URL.revokeObjectURL(url);
});
imageBlobCache.clear();
};
const StyledErrorContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
@ -22,7 +69,7 @@ const StyledErrorContainer = styled('div')(({ theme }) => ({
fontSize: '14px',
}));
const StyledErrorText = styled('div')(({ theme }) => ({
const StyledErrorText = styled('div')(() => ({
fontSize: '12px',
marginBottom: 16,
}));
@ -52,7 +99,7 @@ export const ImageErrorIcon = (props: SvgIconProps) => {
};
// 错误展示组件
const ImageErrorDisplay = () => (
const ImageErrorDisplay: React.FC = () => (
<StyledErrorContainer>
<ImageErrorIcon
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 160 }}
@ -85,6 +132,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
'loading',
);
const [blobUrl, setBlobUrl] = useState<string>('');
// 基础样式对象
const baseStyleObj = {
@ -98,6 +146,28 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
backgroundColor: 'var(--color-canvas-default)',
};
// 获取图片 blob URL
useEffect(() => {
let mounted = true;
fetchImageAsBlob(src)
.then(url => {
if (mounted) {
setBlobUrl(url);
}
})
.catch(err => {
console.error('Failed to fetch image blob:', err);
if (mounted) {
// 如果获取 blob 失败,回退到使用原始 URL
setBlobUrl(src);
}
});
return () => {
mounted = false;
};
}, [src]);
// 解析自定义样式
const parseStyleString = (styleStr: string) => {
if (!styleStr) return {};
@ -160,16 +230,31 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
<>
{status === 'error' ? (
<ImageErrorDisplay />
) : (
) : blobUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={src}
src={blobUrl}
alt={alt || 'markdown-img'}
referrerPolicy='no-referrer'
onLoad={handleLoad}
onError={handleError}
onClick={() => onImageClick(src)}
onClick={() => onImageClick(src)} // 传递原始 src 用于预览
{...getOtherProps()}
/>
) : (
// 加载中显示占位符
<div
style={{
...baseStyleObj,
minHeight: '100px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
}}
>
...
</div>
)}
</>
);
@ -211,7 +296,7 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
img.addEventListener('click', () => {
try {
onImageClick(img.src);
} catch (e) {
} catch {
// noop
}
});
@ -228,10 +313,6 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
const placeholder = document.querySelector(
`.image-container-${imageIndex}`,
);
console.log(
`Looking for placeholder with index ${imageIndex}:`,
placeholder,
);
if (placeholder) {
const root = createRoot(placeholder);
root.render(

View File

@ -1,6 +1,5 @@
'use client';
import { useStore } from '@/provider';
import { copyText } from '@/utils';
import { Box, Dialog, useTheme } from '@mui/material';
import mk from '@vscode/markdown-it-katex';
@ -16,7 +15,11 @@ import React, {
useState,
} from 'react';
import { useSmartScroll } from '@/hooks';
import { createImageRenderer } from './imageRenderer';
import {
clearImageBlobCache,
createImageRenderer,
getImageBlobUrl,
} from './imageRenderer';
import { incrementalRender } from './incrementalRenderer';
import { createMermaidRenderer } from './mermaidRenderer';
import {
@ -73,12 +76,12 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
autoScroll = true,
}) => {
const theme = useTheme();
const { themeMode = 'light' } = useStore();
const themeMode = theme.palette.mode;
// 状态管理
const [showThink, setShowThink] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewImgSrc, setPreviewImgSrc] = useState('');
const [previewImgBlobUrl, setPreviewImgBlobUrl] = useState('');
// Refs
const containerRef = useRef<HTMLDivElement>(null);
@ -90,16 +93,11 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
// 使用智能滚动 hook
const { scrollToBottom } = useSmartScroll({
container: '.conversation-container',
threshold: 10,
threshold: 50, // 距离底部 50px 内认为是在底部附近
behavior: 'smooth',
enabled: autoScroll,
});
// ==================== 事件处理函数 ====================
const handleCodeClick = useCallback((code: string) => {
copyText(code);
}, []);
const handleThinkToggle = useCallback(() => {
setShowThink(prev => !prev);
}, []);
@ -110,6 +108,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
*/
const handleImageLoad = useCallback((index: number, html: string) => {
imageRenderCacheRef.current.set(index, html);
// 图片加载完成后useSmartScroll 的 ResizeObserver 会自动触发滚动
}, []);
/**
@ -117,6 +116,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
*/
const handleImageError = useCallback((index: number, html: string) => {
imageRenderCacheRef.current.set(index, html);
// 图片加载失败后useSmartScroll 的 ResizeObserver 会自动触发滚动
}, []);
// 创建图片渲染器
@ -126,7 +126,9 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
onImageLoad: handleImageLoad,
onImageError: handleImageError,
onImageClick: (src: string) => {
setPreviewImgSrc(src);
// 尝试获取缓存的 blob URL如果不存在则使用原始 src
const blobUrl = getImageBlobUrl(src);
setPreviewImgBlobUrl(blobUrl || src);
setPreviewOpen(true);
},
imageRenderCache: imageRenderCacheRef.current,
@ -182,15 +184,6 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
? defaultRender(tokens, idx, options, env, renderer)
: `<pre><code>${code}</code></pre>`;
// 添加点击复制功能
// result = result.replace(
// /<pre[^>]*>/,
// `<pre style="cursor: pointer; position: relative;" onclick="window.handleCodeCopy && window.handleCodeCopy(\`${code.replace(
// /`/g,
// '\\`'
// )}\`)">`
// );
return result;
};
@ -198,7 +191,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
md.renderer.rules.code_inline = (tokens, idx) => {
const token = tokens[idx];
const code = token.content;
return `<code onclick="window.handleCodeCopy && window.handleCodeCopy('${code}')" style="cursor: pointer;">${code}</code>`;
return `<code style="cursor: pointer;">${code}</code>`;
};
// 自定义标题渲染h1 -> h2
@ -321,7 +314,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
setupCustomHtmlHandlers();
},
[renderImage, renderMermaid, renderThinking, showThink, theme],
[renderImage, renderMermaid, renderThinking, theme],
);
// ==================== Effects ====================
@ -332,15 +325,6 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
}
}, []);
// 设置全局函数
useEffect(() => {
(window as any).handleCodeCopy = handleCodeClick;
return () => {
delete (window as any).handleCodeCopy;
};
}, [handleCodeClick]);
// 主要的内容渲染 Effect
useEffect(() => {
if (!containerRef.current || !mdRef.current || !content) return;
@ -368,6 +352,39 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
}
}, [content, customizeRenderer, scrollToBottom]);
// 添加代码块点击复制功能
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 检查是否点击了代码块
const preElement = target.closest('pre.hljs');
if (preElement) {
const codeElement = preElement.querySelector('code');
if (codeElement) {
const code = codeElement.textContent || '';
copyText(code.replace(/\n$/, ''));
}
}
// 检查是否点击了行内代码
if (target.tagName === 'CODE' && !target.closest('pre')) {
const code = target.textContent || '';
copyText(code);
}
};
container.addEventListener('click', handleClick);
return () => {
clearImageBlobCache();
container.removeEventListener('click', handleClick);
};
}, []);
// ==================== 组件样式 ====================
const componentStyles = {
fontSize: '14px',
@ -445,11 +462,12 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
open={previewOpen}
onClose={() => {
setPreviewOpen(false);
setPreviewImgSrc('');
setPreviewImgBlobUrl('');
}}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={previewImgSrc}
src={previewImgBlobUrl}
alt='preview'
style={{ width: '100%', height: '100%' }}
/>

View File

@ -24,6 +24,12 @@ export interface UseSmartScrollOptions {
* @default true
*/
enabled?: boolean;
/**
*
* @default 150
*/
resumeDebounceMs?: number;
}
export interface UseSmartScrollReturn {
@ -60,28 +66,6 @@ export interface UseSmartScrollReturn {
/**
* Hook
*
*
*
*
* @example
* ```tsx
* const { scrollToBottom, setShouldAutoScroll } = useSmartScroll({
* container: '.my-container',
* threshold: 20,
* });
*
* // 在新消息到达时
* useEffect(() => {
* scrollToBottom();
* }, [messages]);
*
* // 开始新对话时重置自动滚动
* const startNewChat = () => {
* setShouldAutoScroll(true);
* // ...
* };
* ```
*/
export function useSmartScroll(
options: UseSmartScrollOptions = {},
@ -91,6 +75,7 @@ export function useSmartScroll(
threshold = 10,
behavior = 'smooth',
enabled = true,
resumeDebounceMs = 150,
} = options;
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
@ -99,18 +84,20 @@ export function useSmartScroll(
*
*
* 1. SSE scrollToBottom()
* 2. scroll
* 3. scroll setShouldAutoScroll(false) -
* 2.
* 3. setShouldAutoScroll(false) -
* 4. SSE scrollToBottom()
* 5. shouldAutoScroll true
*
*
* - ref scroll ref
* - ref ref
* - scrollToBottom() ref state
* - state
*/
const shouldAutoScrollRef = useRef(true);
const containerRef = useRef<HTMLElement | null>(null);
const userInteractingRef = useRef(false); // 标记用户是否正在交互
const resumeTimerRef = useRef<NodeJS.Timeout | null>(null);
/**
*
@ -157,34 +144,115 @@ export function useSmartScroll(
}, [getContainer, threshold]);
/**
*
* -
*
*/
const handleScrollEvent = useCallback(
(event: Event) => {
const handleWheel = useCallback(
(event: WheelEvent) => {
if (!enabled) return;
const target = event.target as HTMLElement;
if (target) {
const isAtBottom =
target.scrollHeight - target.scrollTop - target.clientHeight <=
threshold;
// 同步更新 ref避免竞争条件
shouldAutoScrollRef.current = isAtBottom;
// 异步更新 state用于响应式更新
setShouldAutoScroll(isAtBottom);
const element = getContainer();
if (!element) return;
// deltaY > 0 表示向下滚动,< 0 表示向上滚动
const isScrollingUp = event.deltaY < 0;
// 只有向上滚动且不在底部时才禁用自动滚动
if (isScrollingUp) {
userInteractingRef.current = true;
shouldAutoScrollRef.current = false;
setShouldAutoScroll(false);
// 清除之前的恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
}
},
[threshold, enabled],
[enabled, getContainer],
);
/**
* shouldAutoScroll
* / -
*/
const handleUserInteraction = useCallback(() => {
if (!enabled) return;
const element = getContainer();
if (!element) return;
// 检查是否在底部阈值内
const distanceFromBottom =
element.scrollHeight - element.scrollTop - element.clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
// 如果不在底部,才禁用自动滚动
if (!isAtBottom) {
userInteractingRef.current = true;
shouldAutoScrollRef.current = false;
setShouldAutoScroll(false);
// 清除之前的恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
}
}, [enabled, threshold, getContainer]);
/**
*
* 便
*/
const handleScrollEvent = useCallback(() => {
if (!enabled) return;
// 清除之前的恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
// 使用防抖检查是否在底部
resumeTimerRef.current = setTimeout(() => {
const element = getContainer();
if (!element) return;
const scrollTop = element.scrollTop;
const scrollHeight = element.scrollHeight;
const clientHeight = element.clientHeight;
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
const isAtBottom = distanceFromBottom <= threshold;
// 如果用户滚动到底部,恢复自动滚动
if (isAtBottom && !shouldAutoScrollRef.current) {
userInteractingRef.current = false;
shouldAutoScrollRef.current = true;
setShouldAutoScroll(true);
}
}, resumeDebounceMs);
}, [enabled, threshold, resumeDebounceMs, getContainer]);
/**
* shouldAutoScroll
*/
const forceScrollToBottom = useCallback(() => {
if (!enabled) return;
const element = getContainer();
if (element) {
// 强制滚动时,重置为允许自动滚动状态
userInteractingRef.current = false;
shouldAutoScrollRef.current = true;
setShouldAutoScroll(true);
// 清除恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
element.scrollTo({
top: element.scrollHeight,
behavior,
@ -202,7 +270,7 @@ export function useSmartScroll(
}, [forceScrollToBottom, enabled]);
/**
*
*
*/
useEffect(() => {
if (!enabled) return;
@ -210,12 +278,66 @@ export function useSmartScroll(
const element = getContainer();
if (!element) return;
element.addEventListener('scroll', handleScrollEvent);
// 监听用户交互事件(表明用户主动操作)
element.addEventListener('wheel', handleWheel as EventListener, {
passive: true,
});
element.addEventListener('touchstart', handleUserInteraction, {
passive: true,
});
// 监听滚动事件(用于检测是否回到底部)
element.addEventListener('scroll', handleScrollEvent, { passive: true });
return () => {
element.removeEventListener('wheel', handleWheel as EventListener);
element.removeEventListener('touchstart', handleUserInteraction);
element.removeEventListener('scroll', handleScrollEvent);
// 清理恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
};
}, [getContainer, handleScrollEvent, enabled]);
}, [
getContainer,
handleScrollEvent,
handleWheel,
handleUserInteraction,
enabled,
]);
/**
* 使 ResizeObserver
*
*/
useEffect(() => {
if (!enabled) return;
const element = getContainer();
if (!element) return;
// 获取滚动容器的第一个子元素(实际包含内容的元素)
const contentElement = element.firstElementChild as HTMLElement;
if (!contentElement) return;
// 使用 ResizeObserver 监听内容元素的尺寸变化
const resizeObserver = new ResizeObserver(() => {
// 只有在允许自动滚动时才触发
if (shouldAutoScrollRef.current) {
// 使用 requestAnimationFrame 确保在 DOM 更新后滚动
requestAnimationFrame(() => {
scrollToBottom();
});
}
});
resizeObserver.observe(contentElement);
return () => {
resizeObserver.disconnect();
};
}, [enabled, getContainer, scrollToBottom]);
/**
* state ref
@ -223,6 +345,17 @@ export function useSmartScroll(
const setShouldAutoScrollWrapper = useCallback((value: boolean) => {
shouldAutoScrollRef.current = value;
setShouldAutoScroll(value);
// 如果设置为 true重置用户交互状态
if (value) {
userInteractingRef.current = false;
// 清除恢复计时器
if (resumeTimerRef.current) {
clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = null;
}
}
}, []);
return {

View File

@ -11,7 +11,11 @@
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import { DomainResponse, GetShareV1NodeDetailParams } from "./types";
import {
DomainResponse,
GetShareV1NodeDetailParams,
V1ShareNodeDetailResp,
} from "./types";
/**
* @description GetNodeDetail
@ -20,14 +24,21 @@ import { DomainResponse, GetShareV1NodeDetailParams } from "./types";
* @name GetShareV1NodeDetail
* @summary GetNodeDetail
* @request GET:/share/v1/node/detail
* @response `200` `DomainResponse` OK
* @response `200` `(DomainResponse & {
data?: V1ShareNodeDetailResp,
})` OK
*/
export const getShareV1NodeDetail = (
query: GetShareV1NodeDetailParams,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
httpRequest<
DomainResponse & {
data?: V1ShareNodeDetailResp;
}
>({
path: `/share/v1/node/detail`,
method: "GET",
query: query,

View File

@ -106,8 +106,15 @@ export interface DomainDocumentFeedbackListItem {
export interface DomainGetNodeReleaseDetailResp {
content?: string;
creator_account?: string;
creator_id?: string;
editor_account?: string;
editor_id?: string;
meta?: DomainNodeMeta;
name?: string;
node_id?: string;
publisher_account?: string;
publisher_id?: string;
}
export interface DomainIPAddress {
@ -131,11 +138,16 @@ export interface DomainNodeMeta {
}
export interface DomainNodeReleaseListItem {
creator_account?: string;
creator_id?: string;
editor_account?: string;
editor_id?: string;
id?: string;
meta?: DomainNodeMeta;
name?: string;
node_id?: string;
/** release */
publisher_account?: string;
publisher_id?: string;
release_id?: string;
release_message?: string;
release_name?: string;
@ -453,6 +465,7 @@ export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp {
}
export interface GithubComChaitinPandaWikiProApiShareV1AuthWecomReq {
is_app?: boolean;
kb_id?: string;
redirect_url?: string;
}
@ -632,6 +645,7 @@ export interface GetApiProV1DocumentListParams {
export interface GetApiProV1NodeReleaseDetailParams {
id: string;
kb_id: string;
}
export interface GetApiProV1NodeReleaseListParams {

View File

@ -1339,6 +1339,8 @@ export interface DomainWidgetBotSettings {
btn_logo?: string;
btn_text?: string;
is_open?: boolean;
recommend_node_ids?: string[];
recommend_questions?: string[];
theme_mode?: string;
}
@ -1579,12 +1581,18 @@ export interface V1LoginResp {
export interface V1NodeDetailResp {
content?: string;
created_at?: string;
creator_account?: string;
creator_id?: string;
editor_account?: string;
editor_id?: string;
id?: string;
kb_id?: string;
meta?: DomainNodeMeta;
name?: string;
parent_id?: string;
permissions?: DomainNodePermissions;
publisher_account?: string;
publisher_id?: string;
status?: DomainNodeStatus;
type?: DomainNodeType;
updated_at?: string;
@ -1615,12 +1623,40 @@ export interface V1NodePermissionResp {
visitable_groups?: DomainNodeGroupDetail[];
}
export interface V1NodeRestudyReq {
kb_id: string;
/** @minItems 1 */
node_ids: string[];
}
export type V1NodeRestudyResp = Record<string, any>;
export interface V1ResetPasswordReq {
id: string;
/** @minLength 8 */
new_password: string;
}
export interface V1ShareNodeDetailResp {
content?: string;
created_at?: string;
creator_account?: string;
creator_id?: string;
editor_account?: string;
editor_id?: string;
id?: string;
kb_id?: string;
meta?: DomainNodeMeta;
name?: string;
parent_id?: string;
permissions?: DomainNodePermissions;
publisher_account?: string;
publisher_id?: string;
status?: DomainNodeStatus;
type?: DomainNodeType;
updated_at?: string;
}
export interface V1StatCountResp {
conversation_count?: number;
ip_count?: number;

View File

@ -49,6 +49,19 @@ import Image from 'next/image';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
function isWeComByUA() {
if (typeof navigator === 'undefined') {
return false;
}
const ua = navigator.userAgent.toLowerCase();
// 1. 必须包含 MicroMessenger (表示微信/企业微信内核)
// 2. 必须包含 wxwork 或 wecom (表示企业微信)
return (
ua.includes('micromessenger') &&
(ua.includes('wxwork') || ua.includes('wecom'))
);
}
export default function Login() {
const searchParams = useSearchParams();
const [password, setPassword] = useState('');
@ -126,6 +139,7 @@ export default function Login() {
clearCookie();
postShareProV1AuthWecom({
redirect_url: redirectUrl,
is_app: isWeComByUA(),
}).then(res => {
window.location.href = res.url || '/';
});

View File

@ -1,5 +1,5 @@
import LoadingIcon from '@/assets/images/loading.png';
import { Box, Stack } from '@mui/material';
import { alpha, Box, Stack } from '@mui/material';
import Image from 'next/image';
import { AnswerStatus } from './constant';
@ -44,7 +44,14 @@ const ChatLoading = ({ thinking, onClick }: ChatLoadingProps) => {
}}
></Box>
</Stack>
<Box sx={{ lineHeight: 1 }}>{AnswerStatus[thinking]}</Box>
<Box
sx={theme => ({
lineHeight: 1,
color: alpha(theme.palette.text.primary, 0.5),
})}
>
{AnswerStatus[thinking]}
</Box>
</Stack>
);
};

View File

@ -8,19 +8,11 @@ import { useEffect, useRef } from 'react';
import { useWrapContext } from '..';
interface HeaderProps {
edit: boolean;
collaborativeUsers?: Array<{
id: string;
name: string;
color: string;
}>;
isSyncing?: boolean;
detail: V1NodeDetailResp;
updateDetail: (detail: V1NodeDetailResp) => void;
handleSave: () => void;
}
const Header = ({ edit, detail, handleSave }: HeaderProps) => {
const Header = ({ detail, handleSave }: HeaderProps) => {
const firstLoad = useRef(true);
const { catalogOpen, nodeDetail, setCatalogOpen, saveLoading } =

View File

@ -1,4 +1,5 @@
'use client';
import { V1NodeDetailResp } from '@/request/types';
import { useTiptap } from '@ctzhian/tiptap';
import { Icon } from '@ctzhian/ui';
import { Box, Skeleton, Stack } from '@mui/material';
@ -35,14 +36,7 @@ const LoadingEditorWrap = () => {
transition: 'left 0.3s ease-in-out',
}}
>
<Header
edit={false}
isSyncing={isSyncing}
collaborativeUsers={collaborativeUsers}
detail={{}}
updateDetail={() => {}}
handleSave={() => {}}
/>
<Header detail={{} as V1NodeDetailResp} handleSave={() => {}} />
{editorRef.editor && <Toolbar editorRef={editorRef} />}
</Box>
<Box>

View File

@ -179,10 +179,14 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
}}
>
<Header
edit={isEditing}
detail={nodeDetail!}
updateDetail={updateDetail}
handleSave={async () => {
if (!isMarkdown) {
const value = editorRef.getContent();
updateDetail({
content: value,
});
}
if (checkRequiredFields()) {
setConfirmModalOpen(true);
}
@ -299,6 +303,7 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
ref={markdownEditorRef}
editor={editorRef.editor}
value={nodeDetail?.content || defaultDetail?.content || ''}
placeholder='请输入文档内容'
onAceChange={value => {
updateDetail({
content: value,
@ -351,12 +356,17 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
open={confirmModalOpen}
onCancel={() => setConfirmModalOpen(false)}
onOk={async (reason: string, token: string) => {
const value = editorRef.getContent();
updateDetail({
content: value,
});
await onSave(value, reason, token, isMarkdown ? 'md' : 'html');
setConfirmModalOpen(false);
if (editorRef) {
let value = nodeDetail?.content || '';
if (!isMarkdown) {
value = editorRef.getContent();
updateDetail({
content: value,
});
}
await onSave(value, reason, token, isMarkdown ? 'md' : 'html');
setConfirmModalOpen(false);
}
}}
/>
</>

View File

@ -115,7 +115,11 @@ const DocContent = ({
reset();
commentInputRef.current?.clearImages();
setCommentImages([]);
message.success('评论成功');
message.success(
appDetail?.web_app_comment_settings?.moderation_enable
? '正在审核中...'
: '评论成功',
);
} catch (error: any) {
console.log(error.message || '评论发布失败');
} finally {
@ -263,6 +267,9 @@ const DocContent = ({
? '100%'
: DocWidth[docWidth as keyof typeof DocWidth].value,
overflowX: 'auto',
...(docWidth !== 'full' && {
maxWidth: '100%',
}),
...(mobile && {
width: '100%',
}),

View File

@ -18,7 +18,7 @@
"license": "ISC",
"packageManager": "pnpm@10.12.1",
"dependencies": {
"@ctzhian/tiptap": "^1.12.5",
"@ctzhian/tiptap": "^1.12.21",
"@ctzhian/ui": "^7.0.5",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@ -121,7 +121,7 @@ const Footer = React.memo(
fontWeight: 'bold',
lineHeight: '32px',
fontSize: 24,
color: 'white',
color: 'text.primary',
}}
>
{footerSetting?.brand_name}
@ -129,12 +129,12 @@ const Footer = React.memo(
</Stack>
{footerSetting?.brand_desc && (
<Box
sx={{
sx={theme => ({
fontSize: 12,
lineHeight: '26px',
mt: 2,
color: 'rgba(255, 255, 255, 0.70)',
}}
color: alpha(theme.palette.text.primary, 0.7),
})}
>
{footerSetting.brand_desc}
</Box>
@ -193,7 +193,6 @@ const Footer = React.memo(
<Stack
direction={'column'}
alignItems={'center'}
bgcolor={'#fff'}
p={1.5}
sx={theme => ({
position: 'absolute',
@ -222,7 +221,7 @@ const Footer = React.memo(
sx={{
fontSize: '12px',
lineHeight: '16px',
color: '#21222D',
color: 'text.primary',
maxWidth: '83px',
textAlign: 'center',
@ -263,7 +262,7 @@ const Footer = React.memo(
fontSize: 16,
lineHeight: '24px',
mb: 1,
color: '#ffffff',
color: 'text.primary',
}}
>
{group.name}
@ -317,11 +316,11 @@ const Footer = React.memo(
)}
{!!footerSetting?.icp && (
<Box
sx={{
sx={theme => ({
height: 40,
lineHeight: '40px',
color: 'rgba(255, 255, 255, 0.30)',
}}
color: alpha(theme.palette.text.primary, 0.3),
})}
>
{footerSetting?.icp}
</Box>
@ -449,7 +448,6 @@ const Footer = React.memo(
justifyContent: 'center',
fontSize: '12px',
zIndex: 1,
color: '#fff',
bgcolor: alpha(theme.palette.text.primary, 0.05),
'.MuiLink-root': {
color: 'inherit',
@ -578,7 +576,6 @@ const Footer = React.memo(
className={'popup'}
direction={'column'}
alignItems={'center'}
bgcolor={'#fff'}
p={1.5}
sx={theme => ({
position: 'absolute',
@ -619,7 +616,6 @@ const Footer = React.memo(
{account.channel === 'phone' && account?.phone && (
<Stack
className={'popup'}
bgcolor={'#fff'}
px={1.5}
py={1}
sx={theme => ({

File diff suppressed because it is too large Load Diff