mirror of https://github.com/chaitin/PandaWiki.git
Compare commits
47 Commits
12a79f7613
...
929fdd33cb
| Author | SHA1 | Date |
|---|---|---|
|
|
929fdd33cb | |
|
|
12b51f2b2b | |
|
|
a5c99fca95 | |
|
|
7b0d71b4c5 | |
|
|
61688c86c9 | |
|
|
c69e74d15d | |
|
|
26e06e69a7 | |
|
|
74e8b03975 | |
|
|
f91a8fb38f | |
|
|
8e6f7ae77c | |
|
|
cefd3fe3a2 | |
|
|
8d70727d0a | |
|
|
1ff013ac47 | |
|
|
4a787a3a6c | |
|
|
da16f5b335 | |
|
|
7e770de4df | |
|
|
681b250296 | |
|
|
3597afcc2b | |
|
|
284392c379 | |
|
|
4b54cdf4ac | |
|
|
c48b13366d | |
|
|
c31f229483 | |
|
|
8fad4d6262 | |
|
|
712e2f8af8 | |
|
|
b990b00df0 | |
|
|
c88bd58b6b | |
|
|
bddd515df2 | |
|
|
b1c564ff2a | |
|
|
bb8337a33e | |
|
|
2f56ad7f6b | |
|
|
d7948ddecc | |
|
|
9d329d21fb | |
|
|
e4dbfcb9fb | |
|
|
40c395400d | |
|
|
b7cdec0d4a | |
|
|
44126e0a11 | |
|
|
3375fcb643 | |
|
|
ef3bae6336 | |
|
|
d9d3bc4911 | |
|
|
546062470b | |
|
|
5a3b23ac75 | |
|
|
f7c0fe273b | |
|
|
a6f4688b88 | |
|
|
575f51f0ea | |
|
|
83f6853716 | |
|
|
3dae8e8d01 | |
|
|
2e1e1848c4 |
|
|
@ -56,3 +56,11 @@ type NodePermissionEditReq struct {
|
|||
|
||||
type NodePermissionEditResp struct {
|
||||
}
|
||||
|
||||
type NodeRestudyReq struct {
|
||||
NodeIds []string `json:"node_ids" validate:"required,min=1"`
|
||||
KbId string `json:"kb_id" validate:"required"`
|
||||
}
|
||||
|
||||
type NodeRestudyResp struct {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 +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
|
||||
}
|
||||
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)
|
||||
cronHandler, err := mq2.NewStatCronHandler(logger, statRepository, statUseCase, nodeUsecase)
|
||||
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,
|
||||
|
|
@ -107,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,11 +70,9 @@ func createApp() (*App, error) {
|
|||
}
|
||||
migrationNodeVersion := fns.NewMigrationNodeVersion(logger, nodeUsecase, knowledgeBaseUsecase, ragRepository)
|
||||
migrationCreateBotAuth := fns.NewMigrationCreateBotAuth(logger)
|
||||
migrationAddModelSettingMode := fns.NewMigrationAddModelSettingMode(logger, knowledgeBaseUsecase)
|
||||
migrationFuncs := &migration.MigrationFuncs{
|
||||
NodeMigration: migrationNodeVersion,
|
||||
BotAuthMigration: migrationCreateBotAuth,
|
||||
ModelSettingMigration: migrationAddModelSettingMode,
|
||||
NodeMigration: migrationNodeVersion,
|
||||
BotAuthMigration: migrationCreateBotAuth,
|
||||
}
|
||||
manager, err := migration.NewManager(db, logger, migrationFuncs)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package consts
|
|||
type AutoModeDefaultModel string
|
||||
|
||||
const (
|
||||
AutoModeDefaultChatModel AutoModeDefaultModel = "qwen-vl-max-latest"
|
||||
AutoModeDefaultChatModel AutoModeDefaultModel = "deepseek-chat"
|
||||
AutoModeDefaultEmbeddingModel AutoModeDefaultModel = "bge-m3"
|
||||
AutoModeDefaultRerankModel AutoModeDefaultModel = "bge-reranker-v2-m3"
|
||||
AutoModeDefaultAnalysisModel AutoModeDefaultModel = "qwen2.5-3b"
|
||||
AutoModeDefaultAnalysisModel AutoModeDefaultModel = "qwen2.5-3b-instruct"
|
||||
AutoModeDefaultAnalysisVLModel AutoModeDefaultModel = "qwen3-vl-max"
|
||||
)
|
||||
|
||||
|
|
@ -33,3 +33,7 @@ const (
|
|||
ModelSettingModeManual ModelSettingMode = "manual"
|
||||
ModelSettingModeAuto ModelSettingMode = "auto"
|
||||
)
|
||||
|
||||
const (
|
||||
AutoModeBaseURL = "https://model-square.app.baizhi.cloud/v1"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package consts
|
||||
|
||||
type SystemSettingKey string
|
||||
|
||||
const (
|
||||
SystemSettingModelMode SystemSettingKey = "model_setting_mode"
|
||||
)
|
||||
|
|
@ -2198,6 +2198,58 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/node/restudy": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "文档重新学习",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Node"
|
||||
],
|
||||
"summary": "文档重新学习",
|
||||
"operationId": "v1-NodeRestudy",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "para",
|
||||
"name": "param",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.NodeRestudyReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/v1.NodeRestudyResp"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/node/summary": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
|
@ -4033,6 +4085,17 @@ const docTemplate = `{
|
|||
"LicenseEditionEnterprise"
|
||||
]
|
||||
},
|
||||
"consts.ModelSettingMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"manual",
|
||||
"auto"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ModelSettingModeManual",
|
||||
"ModelSettingModeAuto"
|
||||
]
|
||||
},
|
||||
"consts.NodeAccessPerm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -6298,9 +6361,17 @@ const docTemplate = `{
|
|||
"description": "自定义对话模型名称",
|
||||
"type": "string"
|
||||
},
|
||||
"is_manual_embedding_updated": {
|
||||
"description": "手动模式下嵌入模型是否更新",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"description": "模式: manual 或 auto",
|
||||
"type": "string"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/consts.ModelSettingMode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -8400,6 +8471,28 @@ 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.NodeRestudyResp": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1.ResetPasswordReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -2191,6 +2191,58 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/node/restudy": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "文档重新学习",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Node"
|
||||
],
|
||||
"summary": "文档重新学习",
|
||||
"operationId": "v1-NodeRestudy",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "para",
|
||||
"name": "param",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.NodeRestudyReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/v1.NodeRestudyResp"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/node/summary": {
|
||||
"post": {
|
||||
"security": [
|
||||
|
|
@ -4026,6 +4078,17 @@
|
|||
"LicenseEditionEnterprise"
|
||||
]
|
||||
},
|
||||
"consts.ModelSettingMode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"manual",
|
||||
"auto"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"ModelSettingModeManual",
|
||||
"ModelSettingModeAuto"
|
||||
]
|
||||
},
|
||||
"consts.NodeAccessPerm": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
|
|
@ -6291,9 +6354,17 @@
|
|||
"description": "自定义对话模型名称",
|
||||
"type": "string"
|
||||
},
|
||||
"is_manual_embedding_updated": {
|
||||
"description": "手动模式下嵌入模型是否更新",
|
||||
"type": "boolean"
|
||||
},
|
||||
"mode": {
|
||||
"description": "模式: manual 或 auto",
|
||||
"type": "string"
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/consts.ModelSettingMode"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -8393,6 +8464,28 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"v1.NodeRestudyReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"kb_id",
|
||||
"node_ids"
|
||||
],
|
||||
"properties": {
|
||||
"kb_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"node_ids": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.NodeRestudyResp": {
|
||||
"type": "object"
|
||||
},
|
||||
"v1.ResetPasswordReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@ definitions:
|
|||
- LicenseEditionFree
|
||||
- LicenseEditionContributor
|
||||
- LicenseEditionEnterprise
|
||||
consts.ModelSettingMode:
|
||||
enum:
|
||||
- manual
|
||||
- auto
|
||||
type: string
|
||||
x-enum-varnames:
|
||||
- ModelSettingModeManual
|
||||
- ModelSettingModeAuto
|
||||
consts.NodeAccessPerm:
|
||||
enum:
|
||||
- open
|
||||
|
|
@ -1636,9 +1644,13 @@ definitions:
|
|||
chat_model:
|
||||
description: 自定义对话模型名称
|
||||
type: string
|
||||
is_manual_embedding_updated:
|
||||
description: 手动模式下嵌入模型是否更新
|
||||
type: boolean
|
||||
mode:
|
||||
allOf:
|
||||
- $ref: '#/definitions/consts.ModelSettingMode'
|
||||
description: '模式: manual 或 auto'
|
||||
type: string
|
||||
type: object
|
||||
domain.ModelType:
|
||||
enum:
|
||||
|
|
@ -3019,6 +3031,21 @@ definitions:
|
|||
$ref: '#/definitions/domain.NodeGroupDetail'
|
||||
type: array
|
||||
type: object
|
||||
v1.NodeRestudyReq:
|
||||
properties:
|
||||
kb_id:
|
||||
type: string
|
||||
node_ids:
|
||||
items:
|
||||
type: string
|
||||
minItems: 1
|
||||
type: array
|
||||
required:
|
||||
- kb_id
|
||||
- node_ids
|
||||
type: object
|
||||
v1.NodeRestudyResp:
|
||||
type: object
|
||||
v1.ResetPasswordReq:
|
||||
properties:
|
||||
id:
|
||||
|
|
@ -4455,6 +4482,36 @@ paths:
|
|||
summary: Recommend Nodes
|
||||
tags:
|
||||
- node
|
||||
/api/v1/node/restudy:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: 文档重新学习
|
||||
operationId: v1-NodeRestudy
|
||||
parameters:
|
||||
- description: para
|
||||
in: body
|
||||
name: param
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/v1.NodeRestudyReq'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/domain.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/v1.NodeRestudyResp'
|
||||
type: object
|
||||
security:
|
||||
- bearerAuth: []
|
||||
summary: 文档重新学习
|
||||
tags:
|
||||
- Node
|
||||
/api/v1/node/summary:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -175,10 +175,3 @@ type SwitchModeReq struct {
|
|||
type SwitchModeResp struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ModelModeSetting 模型配置结构体
|
||||
type ModelModeSetting struct {
|
||||
Mode string `json:"mode"` // 模式: manual 或 auto
|
||||
AutoModeAPIKey string `json:"auto_mode_api_key"` // 百智云 API Key
|
||||
ChatModel string `json:"chat_model"` // 自定义对话模型名称
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,18 +2,28 @@ package domain
|
|||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
SystemSettingModelMode = "model_setting_mode"
|
||||
"github.com/chaitin/panda-wiki/consts"
|
||||
)
|
||||
|
||||
// table: settings
|
||||
type SystemSetting struct {
|
||||
ID int `json:"id" gorm:"primary_key"`
|
||||
Key string `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"`
|
||||
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"` // 手动模式下嵌入模型是否更新
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,20 +20,19 @@ type RAGMQHandler struct {
|
|||
rag rag.RAGService
|
||||
nodeRepo *pg.NodeRepository
|
||||
kbRepo *pg.KnowledgeBaseRepository
|
||||
modelRepo *pg.ModelRepository
|
||||
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
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ func (h *ModelHandler) GetProviderSupportedModelList(c echo.Context) error {
|
|||
return h.NewResponseWithData(c, models)
|
||||
}
|
||||
|
||||
// switch mode
|
||||
// SwitchMode
|
||||
//
|
||||
// @Summary switch mode
|
||||
// @Description switch model mode between manual and auto
|
||||
|
|
@ -244,7 +244,7 @@ func (h *ModelHandler) SwitchMode(c echo.Context) error {
|
|||
return h.NewResponseWithData(c, resp)
|
||||
}
|
||||
|
||||
// get model mode setting
|
||||
// GetModelModeSetting
|
||||
//
|
||||
// @Summary get model mode setting
|
||||
// @Description get current model mode setting including mode, API key and chat model
|
||||
|
|
@ -260,7 +260,7 @@ func (h *ModelHandler) GetModelModeSetting(c echo.Context) error {
|
|||
// 如果获取失败,返回默认值(手动模式)
|
||||
h.logger.Warn("failed to get model mode setting, return default", log.Error(err))
|
||||
defaultSetting := domain.ModelModeSetting{
|
||||
Mode: string(consts.ModelSettingModeManual),
|
||||
Mode: consts.ModelSettingModeManual,
|
||||
AutoModeAPIKey: "",
|
||||
ChatModel: "",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ func NewNodeHandler(
|
|||
group.POST("/batch_move", h.BatchMoveNode)
|
||||
|
||||
group.GET("/recommend_nodes", h.RecommendNodes)
|
||||
group.POST("/restudy", h.NodeRestudy)
|
||||
|
||||
// node permission
|
||||
group.GET("/permission", h.NodePermission)
|
||||
|
|
@ -384,3 +385,32 @@ func (h *NodeHandler) NodePermissionEdit(c echo.Context) error {
|
|||
}
|
||||
return h.NewResponseWithData(c, nil)
|
||||
}
|
||||
|
||||
// NodeRestudy 文档重新学习
|
||||
//
|
||||
// @Tags Node
|
||||
// @Summary 文档重新学习
|
||||
// @Description 文档重新学习
|
||||
// @ID v1-NodeRestudy
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security bearerAuth
|
||||
// @Param param body v1.NodeRestudyReq true "para"
|
||||
// @Success 200 {object} domain.Response{data=v1.NodeRestudyResp}
|
||||
// @Router /api/v1/node/restudy [post]
|
||||
func (h *NodeHandler) NodeRestudy(c echo.Context) error {
|
||||
var req v1.NodeRestudyReq
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return h.NewResponseWithError(c, "request params is invalid", err)
|
||||
}
|
||||
|
||||
if err := c.Validate(req); err != nil {
|
||||
return h.NewResponseWithError(c, "validate request params failed", err)
|
||||
}
|
||||
|
||||
if err := h.usecase.NodeRestudy(c.Request().Context(), &req); err != nil {
|
||||
return h.NewResponseWithError(c, "node restudy failed", err)
|
||||
}
|
||||
|
||||
return h.NewResponseWithData(c, nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +0,0 @@
|
|||
package fns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/chaitin/panda-wiki/consts"
|
||||
"github.com/chaitin/panda-wiki/domain"
|
||||
"github.com/chaitin/panda-wiki/log"
|
||||
"github.com/chaitin/panda-wiki/usecase"
|
||||
)
|
||||
|
||||
type MigrationAddModelSettingMode struct {
|
||||
Name string
|
||||
logger *log.Logger
|
||||
kbUsecase *usecase.KnowledgeBaseUsecase
|
||||
}
|
||||
|
||||
func NewMigrationAddModelSettingMode(logger *log.Logger, kbUsecase *usecase.KnowledgeBaseUsecase) *MigrationAddModelSettingMode {
|
||||
return &MigrationAddModelSettingMode{
|
||||
Name: "0003_add_model_setting_mode",
|
||||
logger: logger,
|
||||
kbUsecase: kbUsecase,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MigrationAddModelSettingMode) Execute(tx *gorm.DB) error {
|
||||
ctx := context.Background()
|
||||
|
||||
// 检查是否已存在该设置
|
||||
var existingSetting domain.SystemSetting
|
||||
err := tx.WithContext(ctx).Where("key = ?", domain.SystemSettingModelMode).First(&existingSetting).Error
|
||||
if err != nil && err != gorm.ErrRecordNotFound {
|
||||
return fmt.Errorf("failed to check existing model_setting_mode setting: %w", err)
|
||||
}
|
||||
|
||||
// 如果记录不存在,则创建新记录
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// 老用户设置手动模型, 新用户设置自动模式
|
||||
mode := string(consts.ModelSettingModeManual)
|
||||
kbList, err := m.kbUsecase.GetKnowledgeBaseList(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get kb list failed: %w", err)
|
||||
}
|
||||
if len(kbList) == 0 {
|
||||
mode = string(consts.ModelSettingModeAuto)
|
||||
}
|
||||
|
||||
// 定义model_setting_mode的值结构
|
||||
modelSettingValue := map[string]interface{}{
|
||||
"mode": mode,
|
||||
"auto_mode_api_key": "", // 默认没有api key
|
||||
"chat_model": "", // 对话模型,默认为空
|
||||
}
|
||||
|
||||
// 将值转换为JSON字节数组
|
||||
valueBytes, err := json.Marshal(modelSettingValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal model setting value: %w", err)
|
||||
}
|
||||
|
||||
// 创建setting记录
|
||||
setting := &domain.SystemSetting{
|
||||
Key: domain.SystemSettingModelMode,
|
||||
Value: valueBytes,
|
||||
Description: "Model setting mode configuration",
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(setting).Error; err != nil {
|
||||
return fmt.Errorf("failed to create model_setting_mode setting: %w", err)
|
||||
}
|
||||
m.logger.Info("successfully created model_setting_mode setting")
|
||||
} else {
|
||||
m.logger.Info("model_setting_mode setting already exists, skipping creation")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
@ -7,5 +7,4 @@ import (
|
|||
var ProviderSet = wire.NewSet(
|
||||
NewMigrationNodeVersion,
|
||||
NewMigrationCreateBotAuth,
|
||||
NewMigrationAddModelSettingMode,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,8 @@ import (
|
|||
)
|
||||
|
||||
type MigrationFuncs struct {
|
||||
NodeMigration *fns.MigrationNodeVersion
|
||||
BotAuthMigration *fns.MigrationCreateBotAuth
|
||||
ModelSettingMigration *fns.MigrationAddModelSettingMode
|
||||
NodeMigration *fns.MigrationNodeVersion
|
||||
BotAuthMigration *fns.MigrationCreateBotAuth
|
||||
}
|
||||
|
||||
func (mf *MigrationFuncs) GetMigrationFuncs() []MigrationFunc {
|
||||
|
|
@ -20,9 +19,5 @@ func (mf *MigrationFuncs) GetMigrationFuncs() []MigrationFunc {
|
|||
Name: mf.BotAuthMigration.Name,
|
||||
Fn: mf.BotAuthMigration.Execute,
|
||||
})
|
||||
funcs = append(funcs, MigrationFunc{
|
||||
Name: mf.ModelSettingMigration.Name,
|
||||
Fn: mf.ModelSettingMigration.Execute,
|
||||
})
|
||||
return funcs
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 3f6d9b2aca901c53abfcad18f244496843eda3eb
|
||||
|
|
@ -16,11 +16,11 @@ type SystemSettingRepo struct {
|
|||
func NewSystemSettingRepo(db *pg.DB, logger *log.Logger) *SystemSettingRepo {
|
||||
return &SystemSettingRepo{
|
||||
db: db,
|
||||
logger: logger.WithModule("repo.pg.setting"),
|
||||
logger: logger.WithModule("repo.pg.system_setting"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SystemSettingRepo) GetModelModeSetting(ctx context.Context, key string) (*domain.SystemSetting, error) {
|
||||
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 {
|
||||
|
|
@ -30,6 +30,6 @@ func (r *SystemSettingRepo) GetModelModeSetting(ctx context.Context, key string)
|
|||
return &setting, nil
|
||||
}
|
||||
|
||||
func (r *SystemSettingRepo) UpdateModelModeSetting(ctx context.Context, key, value string) error {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
-- 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_system_settings_key ON settings (key);
|
||||
|
|
@ -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'
|
||||
);
|
||||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -16,8 +15,6 @@ import (
|
|||
"github.com/cloudwego/eino/schema"
|
||||
"github.com/pkoukk/tiktoken-go"
|
||||
"github.com/samber/lo"
|
||||
"github.com/samber/lo/parallel"
|
||||
"golang.org/x/sync/semaphore"
|
||||
|
||||
"github.com/chaitin/panda-wiki/config"
|
||||
"github.com/chaitin/panda-wiki/domain"
|
||||
|
|
@ -39,6 +36,11 @@ type LLMUsecase struct {
|
|||
modelkit *modelkit.ModelKit
|
||||
}
|
||||
|
||||
const (
|
||||
summaryChunkTokenLimit = 30720 // 30KB tokens per chunk
|
||||
summaryMaxChunks = 4 // max chunks to process for summary
|
||||
)
|
||||
|
||||
func NewLLMUsecase(config *config.Config, rag rag.RAGService, conversationRepo *pg.ConversationRepository, kbRepo *pg.KnowledgeBaseRepository, nodeRepo *pg.NodeRepository, modelRepo *pg.ModelRepository, promptRepo *pg.PromptRepo, logger *log.Logger) *LLMUsecase {
|
||||
tiktoken.SetBpeLoader(&utils.Localloader{})
|
||||
modelkit := modelkit.NewModelKit(logger.Logger)
|
||||
|
|
@ -197,52 +199,59 @@ func (u *LLMUsecase) SummaryNode(ctx context.Context, model *domain.Model, name,
|
|||
return "", err
|
||||
}
|
||||
|
||||
chunks, err := u.SplitByTokenLimit(content, int(math.Floor(1024*32*0.95)))
|
||||
chunks, err := u.SplitByTokenLimit(content, summaryChunkTokenLimit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sem := semaphore.NewWeighted(int64(10))
|
||||
summaries := parallel.Map(chunks, func(chunk string, _ int) string {
|
||||
if err := sem.Acquire(ctx, 1); err != nil {
|
||||
u.logger.Error("Failed to acquire semaphore for chunk: ", log.Error(err))
|
||||
return ""
|
||||
}
|
||||
defer sem.Release(1)
|
||||
summary, err := u.Generate(ctx, chatModel, []*schema.Message{
|
||||
{
|
||||
Role: "system",
|
||||
Content: "你是文档总结助手,请根据文档内容总结出文档的摘要。摘要是纯文本,应该简洁明了,不要超过160个字。",
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("文档名称:%s\n文档内容:%s", name, chunk),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
u.logger.Error("Failed to generate summary for chunk: ", log.Error(err))
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(summary, "<think>") {
|
||||
// remove <think> body </think>
|
||||
endIndex := strings.Index(summary, "</think>")
|
||||
if endIndex != -1 {
|
||||
summary = strings.TrimSpace(summary[endIndex+8:]) // 8 is length of "</think>"
|
||||
}
|
||||
}
|
||||
return summary
|
||||
})
|
||||
// 使用lo.Filter处理错误
|
||||
defeatSummary := lo.Filter(summaries, func(summary string, index int) bool {
|
||||
return summary == ""
|
||||
})
|
||||
if len(defeatSummary) > 0 {
|
||||
return "", fmt.Errorf("failed to generate summaries for all chunks: %d/%d", len(defeatSummary), len(chunks))
|
||||
if len(chunks) > summaryMaxChunks {
|
||||
u.logger.Debug("trim summary chunks for large document", log.String("node", name), log.Int("original_chunks", len(chunks)), log.Int("used_chunks", summaryMaxChunks))
|
||||
chunks = chunks[:summaryMaxChunks]
|
||||
}
|
||||
|
||||
contents, err := u.SplitByTokenLimit(strings.Join(summaries, "\n\n"), int(math.Floor(1024*32*0.95)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
summaries := make([]string, 0, len(chunks))
|
||||
for idx, chunk := range chunks {
|
||||
summary, err := u.requestSummary(ctx, chatModel, name, chunk)
|
||||
if err != nil {
|
||||
u.logger.Error("Failed to generate summary for chunk", log.Int("chunk_index", idx), log.Error(err))
|
||||
continue
|
||||
}
|
||||
if summary == "" {
|
||||
u.logger.Warn("Empty summary returned for chunk", log.Int("chunk_index", idx))
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
|
||||
if len(summaries) == 0 {
|
||||
return "", fmt.Errorf("failed to generate summary for document %s", name)
|
||||
}
|
||||
|
||||
// Join all summaries and generate final summary
|
||||
joined := strings.Join(summaries, "\n\n")
|
||||
finalSummary, err := u.requestSummary(ctx, chatModel, name, joined)
|
||||
if err != nil {
|
||||
u.logger.Error("Failed to generate final summary, using aggregated summaries", log.Error(err))
|
||||
// Fallback: return the joined summaries directly
|
||||
if len(joined) > 500 {
|
||||
return joined[:500] + "...", nil
|
||||
}
|
||||
return joined, nil
|
||||
}
|
||||
return finalSummary, nil
|
||||
}
|
||||
|
||||
func (u *LLMUsecase) trimThinking(summary string) string {
|
||||
if !strings.HasPrefix(summary, "<think>") {
|
||||
return summary
|
||||
}
|
||||
endIndex := strings.Index(summary, "</think>")
|
||||
if endIndex == -1 {
|
||||
return summary
|
||||
}
|
||||
return strings.TrimSpace(summary[endIndex+len("</think>"):])
|
||||
}
|
||||
|
||||
func (u *LLMUsecase) requestSummary(ctx context.Context, chatModel model.BaseChatModel, name, content string) (string, error) {
|
||||
summary, err := u.Generate(ctx, chatModel, []*schema.Message{
|
||||
{
|
||||
Role: "system",
|
||||
|
|
@ -250,20 +259,13 @@ func (u *LLMUsecase) SummaryNode(ctx context.Context, model *domain.Model, name,
|
|||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("文档名称:%s\n文档内容:%s", name, contents[0]),
|
||||
Content: fmt.Sprintf("文档名称:%s\n文档内容:%s", name, content),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(summary, "<think>") {
|
||||
// remove <think> body </think>
|
||||
endIndex := strings.Index(summary, "</think>")
|
||||
if endIndex != -1 {
|
||||
summary = strings.TrimSpace(summary[endIndex+8:]) // 8 is length of "</think>"
|
||||
}
|
||||
}
|
||||
return summary, nil
|
||||
return strings.TrimSpace(u.trimThinking(summary)), nil
|
||||
}
|
||||
|
||||
func (u *LLMUsecase) SplitByTokenLimit(text string, maxTokens int) ([]string, error) {
|
||||
|
|
|
|||
|
|
@ -47,9 +47,19 @@ func NewModelUsecase(modelRepo *pg.ModelRepository, nodeRepo *pg.NodeRepository,
|
|||
}
|
||||
|
||||
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 updatedEmbeddingModel {
|
||||
if _, err := u.updateModeSettingConfig(ctx, "", "", "", true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -98,9 +108,19 @@ 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
|
||||
}
|
||||
// 模型更新成功后,如果更新嵌入模型,则触发记录更新
|
||||
if updatedEmbeddingModel {
|
||||
if _, err := u.updateModeSettingConfig(ctx, "", "", "", true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -111,7 +131,7 @@ func (u *ModelUsecase) GetChatModel(ctx context.Context) (*domain.Model, error)
|
|||
if err != nil {
|
||||
u.logger.Error("get model mode setting failed, use manual mode", log.Error(err))
|
||||
}
|
||||
if err == nil && modelModeSetting.Mode == string(consts.ModelSettingModeAuto) && modelModeSetting.AutoModeAPIKey != "" {
|
||||
if err == nil && modelModeSetting.Mode == consts.ModelSettingModeAuto && modelModeSetting.AutoModeAPIKey != "" {
|
||||
modelName := modelModeSetting.ChatModel
|
||||
if modelName == "" {
|
||||
modelName = string(consts.AutoModeDefaultChatModel)
|
||||
|
|
@ -120,7 +140,7 @@ func (u *ModelUsecase) GetChatModel(ctx context.Context) (*domain.Model, error)
|
|||
Model: modelName,
|
||||
Type: domain.ModelTypeChat,
|
||||
IsActive: true,
|
||||
BaseURL: "https://model-square.app.baizhi.cloud/v1",
|
||||
BaseURL: consts.AutoModeBaseURL,
|
||||
APIKey: modelModeSetting.AutoModeAPIKey,
|
||||
Provider: domain.ModelProviderBrandBaiZhiCloud,
|
||||
}
|
||||
|
|
@ -156,7 +176,7 @@ func (u *ModelUsecase) SwitchMode(ctx context.Context, req *domain.SwitchModeReq
|
|||
check, err := u.modelkit.CheckModel(ctx, &modelkitDomain.CheckModelReq{
|
||||
Provider: string(domain.ModelProviderBrandBaiZhiCloud),
|
||||
Model: modelName,
|
||||
BaseURL: "https://model-square.app.baizhi.cloud/v1",
|
||||
BaseURL: consts.AutoModeBaseURL,
|
||||
APIKey: req.AutoModeAPIKey,
|
||||
Type: string(domain.ModelTypeChat),
|
||||
})
|
||||
|
|
@ -180,18 +200,29 @@ func (u *ModelUsecase) SwitchMode(ctx context.Context, req *domain.SwitchModeReq
|
|||
}
|
||||
}
|
||||
|
||||
modelModeSetting, err := u.updateAutoModeSettingConfig(ctx, req.Mode, req.AutoModeAPIKey, req.ChatModel)
|
||||
oldModelModeSetting, err := u.GetModelModeSetting(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return u.updateRAGModelsByMode(ctx, req.Mode, modelModeSetting.AutoModeAPIKey)
|
||||
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)
|
||||
}
|
||||
|
||||
// updateAutoModeSettingConfig 读取当前设置并更新,然后持久化
|
||||
func (u *ModelUsecase) updateAutoModeSettingConfig(ctx context.Context, mode, apiKey, chatModel string) (*domain.ModelModeSetting, error) {
|
||||
// updateModeSettingConfig 读取当前设置并更新,然后持久化
|
||||
func (u *ModelUsecase) updateModeSettingConfig(ctx context.Context, mode, apiKey, chatModel string, isManualEmbeddingUpdated bool) (*domain.ModelModeSetting, error) {
|
||||
// 读取当前设置
|
||||
setting, err := u.systemSettingRepo.GetModelModeSetting(ctx, domain.SystemSettingModelMode)
|
||||
setting, err := u.systemSettingRepo.GetSystemSetting(ctx, string(consts.SystemSettingModelMode))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current model setting: %w", err)
|
||||
}
|
||||
|
|
@ -209,22 +240,24 @@ func (u *ModelUsecase) updateAutoModeSettingConfig(ctx context.Context, mode, ap
|
|||
config.ChatModel = chatModel
|
||||
}
|
||||
if mode != "" {
|
||||
config.Mode = 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.UpdateModelModeSetting(ctx, domain.SystemSettingModelMode, string(updatedValue)); err != nil {
|
||||
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.GetModelModeSetting(ctx, domain.SystemSettingModelMode)
|
||||
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)
|
||||
}
|
||||
|
|
@ -240,7 +273,14 @@ func (u *ModelUsecase) GetModelModeSetting(ctx context.Context) (domain.ModelMod
|
|||
}
|
||||
|
||||
// updateRAGModelsByMode 根据模式更新 RAG 模型(embedding、rerank、analysis、analysisVL)
|
||||
func (u *ModelUsecase) updateRAGModelsByMode(ctx context.Context, mode string, autoModeAPIKey string) error {
|
||||
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,
|
||||
|
|
@ -248,7 +288,6 @@ func (u *ModelUsecase) updateRAGModelsByMode(ctx context.Context, mode string, a
|
|||
domain.ModelTypeAnalysisVL,
|
||||
}
|
||||
|
||||
var hasEmbeddingModel bool
|
||||
for _, modelType := range ragModelTypes {
|
||||
var model *domain.Model
|
||||
|
||||
|
|
@ -270,7 +309,7 @@ func (u *ModelUsecase) updateRAGModelsByMode(ctx context.Context, mode string, a
|
|||
Model: modelName,
|
||||
Type: modelType,
|
||||
IsActive: true,
|
||||
BaseURL: "https://model-square.app.baizhi.cloud/v1",
|
||||
BaseURL: consts.AutoModeBaseURL,
|
||||
APIKey: autoModeAPIKey,
|
||||
Provider: domain.ModelProviderBrandBaiZhiCloud,
|
||||
}
|
||||
|
|
@ -285,15 +324,11 @@ func (u *ModelUsecase) updateRAGModelsByMode(ctx context.Context, mode string, a
|
|||
}
|
||||
u.logger.Info("successfully updated RAG model", log.String("model name: ", string(model.Model)))
|
||||
}
|
||||
|
||||
// 检查是否有嵌入模型
|
||||
if modelType == domain.ModelTypeEmbedding {
|
||||
hasEmbeddingModel = true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果有嵌入模型,触发记录更新
|
||||
if hasEmbeddingModel {
|
||||
// 触发记录更新
|
||||
if isTriggerUpsertRecords {
|
||||
u.logger.Info("embedding model updated, triggering upsert records")
|
||||
return u.TriggerUpsertRecords(ctx)
|
||||
}
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -576,3 +576,30 @@ func (u *NodeUsecase) SyncRagNodeStatus(ctx context.Context) error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *NodeUsecase) NodeRestudy(ctx context.Context, req *v1.NodeRestudyReq) error {
|
||||
nodeReleases, err := u.nodeRepo.GetLatestNodeReleaseByNodeIDs(ctx, req.KbId, req.NodeIds)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get latest node release failed: %w", err)
|
||||
}
|
||||
|
||||
for _, nodeRelease := range nodeReleases {
|
||||
if nodeRelease.DocID == "" {
|
||||
continue
|
||||
}
|
||||
if err := u.ragRepo.AsyncUpdateNodeReleaseVector(ctx, []*domain.NodeReleaseVectorRequest{
|
||||
{
|
||||
KBID: nodeRelease.KBID,
|
||||
NodeReleaseID: nodeRelease.ID,
|
||||
Action: "upsert",
|
||||
},
|
||||
}); err != nil {
|
||||
u.logger.Error("async update node release vector failed",
|
||||
log.String("node_release_id", nodeRelease.ID),
|
||||
log.Error(err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,8 +71,8 @@ const Step1Model: React.FC<Step1ModelProps> = ({ ref }) => {
|
|||
|
||||
// 如果是 auto 模式,检查是否配置了 API key
|
||||
if (modeSetting?.mode === 'auto') {
|
||||
if (modeSetting.auto_mode_api_key === '') {
|
||||
return Promise.reject(new Error('请完成模型配置'));
|
||||
if (!modeSetting.auto_mode_api_key) {
|
||||
return Promise.reject(new Error('请点击应用完成模型配置'));
|
||||
}
|
||||
} else {
|
||||
// 手动模式检查
|
||||
|
|
@ -82,7 +82,7 @@ const Step1Model: React.FC<Step1ModelProps> = ({ ref }) => {
|
|||
!rerankModelData ||
|
||||
!analysisModelData
|
||||
) {
|
||||
return Promise.reject(new Error('请配置必要的模型'));
|
||||
return Promise.reject(new Error('请配置必要的模型后点击应用'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FooterSetting } from '@/api/type';
|
||||
import { useAppDispatch, useAppSelector } from '@/store';
|
||||
import { Icon } from '@ctzhian/ui';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
|
|
@ -18,22 +18,15 @@ import {
|
|||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Box, Button, IconButton, Stack, TextField } from '@mui/material';
|
||||
import { Icon } from '@ctzhian/ui';
|
||||
import { Box, IconButton, Stack, TextField } from '@mui/material';
|
||||
import {
|
||||
CSSProperties,
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
FieldErrors,
|
||||
useFieldArray,
|
||||
} from 'react-hook-form';
|
||||
import { Control, Controller, FieldErrors } from 'react-hook-form';
|
||||
import { BrandGroup } from '.';
|
||||
|
||||
export type ItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {
|
||||
|
|
@ -122,7 +115,7 @@ const LinkItem = forwardRef<HTMLDivElement, LinkItemProps>(
|
|||
size='small'
|
||||
onClick={onRemove}
|
||||
sx={{
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
':hover': { color: 'error.main' },
|
||||
flexShrink: 0,
|
||||
width: '28px',
|
||||
|
|
@ -395,7 +388,7 @@ const Item = forwardRef<HTMLDivElement, ItemProps>(
|
|||
size='small'
|
||||
onClick={handleRemove}
|
||||
sx={{
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
':hover': { color: 'error.main' },
|
||||
flexShrink: 0,
|
||||
width: '28px',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import UploadFile from '@/components/UploadFile';
|
||||
import { DomainSocialMediaAccount } from '@/request/types';
|
||||
import { Icon } from '@ctzhian/ui';
|
||||
import {
|
||||
Box,
|
||||
FormControl,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
Select,
|
||||
|
|
@ -11,7 +11,6 @@ import {
|
|||
ToggleButton,
|
||||
ToggleButtonGroup,
|
||||
} from '@mui/material';
|
||||
import { Icon } from '@ctzhian/ui';
|
||||
import {
|
||||
CSSProperties,
|
||||
Dispatch,
|
||||
|
|
@ -113,7 +112,7 @@ const Item = forwardRef<HTMLDivElement, SocialInfoProps>(
|
|||
setIsEdit(true);
|
||||
}}
|
||||
sx={{
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
':hover': { color: 'error.main' },
|
||||
flexShrink: 0,
|
||||
width: '28px',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import {
|
|||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
|
|
@ -62,6 +64,12 @@ function DragList<T extends { id?: string | null }>({
|
|||
}: DragListProps<T>) {
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));
|
||||
const dataRef = useRef(data);
|
||||
|
||||
// 保持 ref 与 data 同步
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
|
|
@ -71,14 +79,19 @@ function DragList<T extends { id?: string | null }>({
|
|||
(event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = data.findIndex(item => (item.id || '') === active.id);
|
||||
const newIndex = data.findIndex(item => (item.id || '') === over!.id);
|
||||
const newData = arrayMove(data, oldIndex, newIndex);
|
||||
const currentData = dataRef.current;
|
||||
const oldIndex = currentData.findIndex(
|
||||
item => (item.id || '') === active.id,
|
||||
);
|
||||
const newIndex = currentData.findIndex(
|
||||
item => (item.id || '') === over!.id,
|
||||
);
|
||||
const newData = arrayMove(currentData, oldIndex, newIndex);
|
||||
onChange(newData);
|
||||
}
|
||||
setActiveId(null);
|
||||
},
|
||||
[data, onChange],
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
|
|
@ -87,20 +100,22 @@ function DragList<T extends { id?: string | null }>({
|
|||
|
||||
const handleRemove = useCallback(
|
||||
(id: string) => {
|
||||
const newData = data.filter(item => (item.id || '') !== id);
|
||||
const currentData = dataRef.current;
|
||||
const newData = currentData.filter(item => (item.id || '') !== id);
|
||||
onChange(newData);
|
||||
},
|
||||
[data, onChange],
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleUpdateItem = useCallback(
|
||||
(updatedItem: T) => {
|
||||
const newData = data.map(item =>
|
||||
const currentData = dataRef.current;
|
||||
const newData = currentData.map(item =>
|
||||
(item.id || '') === (updatedItem.id || '') ? updatedItem : item,
|
||||
);
|
||||
onChange(newData);
|
||||
},
|
||||
[data, onChange],
|
||||
[onChange],
|
||||
);
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
|
|
|||
|
|
@ -46,14 +46,4 @@ function SortableItem<T extends { id?: string | null }>({
|
|||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function createSortableItem(ItemComponent: ComponentType<any>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const WrappedComponent = (props: any) => (
|
||||
<SortableItem {...props} ItemComponent={ItemComponent} />
|
||||
);
|
||||
WrappedComponent.displayName = `SortableItem(${ItemComponent.displayName || ItemComponent.name || 'Component'})`;
|
||||
return WrappedComponent;
|
||||
}
|
||||
|
||||
export default SortableItem;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
export { default as DragList } from './DragList';
|
||||
export type { DragListProps } from './DragList';
|
||||
|
||||
export { default as SortableItem, createSortableItem } from './SortableItem';
|
||||
export type { SortableItemProps } from './SortableItem';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import { TextField } from '@mui/material';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import type { ConfigProps } from '../type';
|
||||
|
|
@ -104,6 +104,19 @@ const Config: React.FC<ConfigProps> = ({ setIsEdit, id }) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const HotSearchSortableItem = useMemo(
|
||||
() => (props: any) => (
|
||||
<SortableItem {...props} ItemComponent={HotSearchItem} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const ButtonSortableItem = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const callback = subscribe({
|
||||
formState: {
|
||||
|
|
@ -212,9 +225,7 @@ const Config: React.FC<ConfigProps> = ({ setIsEdit, id }) => {
|
|||
data={hotSearchList}
|
||||
onChange={handleHotSearchChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={HotSearchItem} />
|
||||
)}
|
||||
SortableItemComponent={HotSearchSortableItem}
|
||||
ItemComponent={HotSearchItem}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -227,9 +238,7 @@ const Config: React.FC<ConfigProps> = ({ setIsEdit, id }) => {
|
|||
setIsEdit(true);
|
||||
}}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ButtonSortableItem}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
</CommonItem>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -42,6 +42,12 @@ const BasicDocConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -125,9 +131,7 @@ const BasicDocConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setValue('nodes', value);
|
||||
}}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -38,6 +38,12 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -93,9 +99,7 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -39,6 +39,12 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -123,9 +129,7 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -36,6 +36,12 @@ const CaseConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -92,9 +98,7 @@ const CaseConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -39,6 +39,12 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -95,9 +101,7 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -42,6 +42,12 @@ const DirDocConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
nodeRec(newList);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -126,9 +132,7 @@ const DirDocConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setValue('nodes', value);
|
||||
}}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -36,6 +36,12 @@ const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const FaqSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={FaqItem} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -116,9 +122,7 @@ const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={FaqItem} />
|
||||
)}
|
||||
SortableItemComponent={FaqSortableComponent}
|
||||
ItemComponent={FaqItem}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -36,6 +36,12 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -91,9 +97,7 @@ const Config = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -36,6 +36,12 @@ const MetricsConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -92,9 +98,7 @@ const MetricsConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -38,6 +38,12 @@ const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -94,9 +100,7 @@ const FaqConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
data={list}
|
||||
onChange={handleListChange}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { CommonItem, StyledCommonWrapper } from '../../components/StyledCommon';
|
||||
import { TextField } from '@mui/material';
|
||||
import { Controller, useForm } from 'react-hook-form';
|
||||
|
|
@ -42,6 +42,12 @@ const SimpleDocConfigConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setIsEdit(true);
|
||||
};
|
||||
|
||||
// 稳定的 SortableItemComponent 引用
|
||||
const ItemSortableComponent = useMemo(
|
||||
() => (props: any) => <SortableItem {...props} ItemComponent={Item} />,
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reset(
|
||||
findConfigById(
|
||||
|
|
@ -126,9 +132,7 @@ const SimpleDocConfigConfig = ({ setIsEdit, id }: ConfigProps) => {
|
|||
setValue('nodes', value);
|
||||
}}
|
||||
setIsEdit={setIsEdit}
|
||||
SortableItemComponent={sortableProps => (
|
||||
<SortableItem {...sortableProps} ItemComponent={Item} />
|
||||
)}
|
||||
SortableItemComponent={ItemSortableComponent}
|
||||
ItemComponent={Item}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
import RAG_SOURCES from '@/constant/rag';
|
||||
import { treeSx } from '@/constant/styles';
|
||||
import { postApiV1Node, putApiV1NodeDetail } from '@/request/Node';
|
||||
import { ConstsNodeAccessPerm, ConstsNodeRagInfoStatus } from '@/request/types';
|
||||
import { ConstsNodeAccessPerm } from '@/request/types';
|
||||
import { useAppSelector } from '@/store';
|
||||
import { AppContext, updateTree } from '@/utils/drag';
|
||||
import { handleMultiSelect, updateAllParentStatus } from '@/utils/tree';
|
||||
|
|
@ -522,20 +522,15 @@ const TreeItem = React.forwardRef<
|
|||
gap={1}
|
||||
sx={{ flexShrink: 0, fontSize: 12 }}
|
||||
>
|
||||
{item.type === 2 &&
|
||||
item.rag_status &&
|
||||
![
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusBasicSucceeded,
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceSucceeded,
|
||||
].includes(item.rag_status) && (
|
||||
<Tooltip title={item.rag_message}>
|
||||
<StyledTag
|
||||
color={RAG_SOURCES[item.rag_status].color as any}
|
||||
>
|
||||
{RAG_SOURCES[item.rag_status].name}
|
||||
</StyledTag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{item.type === 2 && item.rag_status && (
|
||||
<Tooltip title={item.rag_message}>
|
||||
<StyledTag
|
||||
color={RAG_SOURCES[item.rag_status].color as any}
|
||||
>
|
||||
{RAG_SOURCES[item.rag_status].name}
|
||||
</StyledTag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{item.status === 1 && (
|
||||
<StyledTag color='error'>更新未发布</StyledTag>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
|
||||
// 默认百智云 Chat 模型列表
|
||||
const DEFAULT_BAIZHI_CLOUD_CHAT_MODELS: string[] = [
|
||||
'deepseek-v3.1',
|
||||
'deepseek-chat',
|
||||
'deepseek-r1',
|
||||
'kimi-k2-0711-preview',
|
||||
'qwen-vl-max-latest',
|
||||
|
|
@ -85,6 +85,9 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
pl: 2,
|
||||
pr: 0,
|
||||
pt: 0,
|
||||
overflow: 'hidden',
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
|
|
@ -121,7 +124,7 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
}}
|
||||
>
|
||||
通过 API Key 连接百智云提供平台后,PandaWiki
|
||||
会自动配置好系统所需的问答模型、向量模型、文档分析模型、文档分析模型。充分利用平台配置,无需逐个手动配置。
|
||||
会自动配置好系统所需的问答模型、向量模型、重排序模型、文档分析模型。充分利用平台配置,无需逐个手动配置。
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
|
@ -130,28 +133,31 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.primary',
|
||||
mb: 1.5,
|
||||
justifyContent: 'space-between',
|
||||
mb: '16px',
|
||||
pt: '32px',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 14,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '2px',
|
||||
mr: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
/>
|
||||
API Key
|
||||
</Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
justifyContent={'flex-end'}
|
||||
>
|
||||
>
|
||||
<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'
|
||||
|
|
@ -167,7 +173,7 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
<Icon type='icon-key' sx={{ fontSize: 14 }} />
|
||||
获取百智云 API Key
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
size='medium'
|
||||
|
|
@ -199,7 +205,7 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
</Box>
|
||||
|
||||
{!showTip && (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Box sx={{ mt: 0 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
|
|
@ -207,15 +213,16 @@ const AutoModelConfig = forwardRef<AutoModelConfigRef, AutoModelConfigProps>(
|
|||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.primary',
|
||||
mb: 1.5,
|
||||
mb: '16px',
|
||||
pt: '32px',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 14,
|
||||
width: 4,
|
||||
height: 10,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '2px',
|
||||
borderRadius: '30%',
|
||||
mr: 1,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
} from '@/request/Model';
|
||||
import { GithubComChaitinPandaWikiDomainModelListItem } from '@/request/types';
|
||||
import { addOpacityToColor } from '@/utils';
|
||||
import { Icon, message } from '@ctzhian/ui';
|
||||
import { Icon, message, Modal } from '@ctzhian/ui';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
|
|
@ -43,6 +43,7 @@ const ModelModal = lazy(() =>
|
|||
|
||||
export interface ModelConfigRef {
|
||||
getAutoConfigFormData: () => { apiKey: string; selectedModel: string } | null;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface ModelConfigProps {
|
||||
|
|
@ -153,6 +154,26 @@ const ModelConfig = forwardRef<ModelConfigRef, ModelConfigProps>(
|
|||
switchToAutoMode();
|
||||
}, [autoSwitchToAutoMode, hasAutoSwitched, getModelList]);
|
||||
|
||||
// 处理关闭弹窗
|
||||
const handleCloseModal = () => {
|
||||
// 判断是否有未应用的更改
|
||||
const hasUnappliedChanges = tempMode !== savedMode || hasConfigChanged;
|
||||
|
||||
if (hasUnappliedChanges) {
|
||||
Modal.confirm({
|
||||
title: '提示',
|
||||
content: '有未应用的设置,是否确认关闭?',
|
||||
onOk: () => {
|
||||
onCloseModal();
|
||||
},
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
});
|
||||
} else {
|
||||
onCloseModal();
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
getAutoConfigFormData: () => {
|
||||
|
|
@ -161,6 +182,7 @@ const ModelConfig = forwardRef<ModelConfigRef, ModelConfigProps>(
|
|||
}
|
||||
return null;
|
||||
},
|
||||
handleClose: handleCloseModal,
|
||||
}));
|
||||
|
||||
const handleSave = async () => {
|
||||
|
|
@ -208,8 +230,14 @@ const ModelConfig = forwardRef<ModelConfigRef, ModelConfigProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<Box sx={{ pl: 2, display: 'flex', alignItems: 'flex-start', gap: 2 }}>
|
||||
<Stack gap={0}>
|
||||
<Box
|
||||
sx={{
|
||||
pl: 2,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -218,15 +246,15 @@ const ModelConfig = forwardRef<ModelConfigRef, ModelConfigProps>(
|
|||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.primary',
|
||||
mb: 1,
|
||||
mb: '16px',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 3,
|
||||
height: 14,
|
||||
width: 4,
|
||||
height: 10,
|
||||
bgcolor: 'primary.main',
|
||||
borderRadius: '2px',
|
||||
borderRadius: '30%',
|
||||
mr: 1,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,10 @@ import {
|
|||
Tooltip,
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import LottieIcon from '../LottieIcon';
|
||||
import Member from './component/Member';
|
||||
import ModelConfig from './component/ModelConfig';
|
||||
import ModelConfig, { ModelConfigRef } from './component/ModelConfig';
|
||||
|
||||
const SystemTabs = [
|
||||
{ label: '模型配置', id: 'model-config' },
|
||||
|
|
@ -33,6 +33,7 @@ const System = () => {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('model-config');
|
||||
const dispatch = useAppDispatch();
|
||||
const modelConfigRef = useRef<ModelConfigRef>(null);
|
||||
const [chatModelData, setChatModelData] =
|
||||
useState<GithubComChaitinPandaWikiDomainModelListItem | null>(null);
|
||||
const [embeddingModelData, setEmbeddingModelData] =
|
||||
|
|
@ -103,29 +104,73 @@ const System = () => {
|
|||
open={open}
|
||||
disableEnforceFocus={true}
|
||||
footer={null}
|
||||
onCancel={() => setOpen(false)}
|
||||
onCancel={() => {
|
||||
if (activeTab === 'model-config' && modelConfigRef.current) {
|
||||
modelConfigRef.current.handleClose();
|
||||
} else {
|
||||
setOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(event, newValue) => setActiveTab(newValue)}
|
||||
aria-label='system tabs'
|
||||
sx={{ mb: 2 }}
|
||||
sx={{
|
||||
mb: 2,
|
||||
ml: -2,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
'& .MuiTabs-indicator': {
|
||||
display: 'none',
|
||||
},
|
||||
'& .MuiTab-root': {
|
||||
minHeight: 48,
|
||||
textTransform: 'none',
|
||||
fontSize: '14px',
|
||||
fontWeight: 400,
|
||||
color: theme.palette.text.secondary,
|
||||
position: 'relative',
|
||||
'&.Mui-selected': {
|
||||
color: theme.palette.primary.main,
|
||||
fontWeight: 500,
|
||||
},
|
||||
'&.Mui-selected::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '40px',
|
||||
height: '2px',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
zIndex: 1,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{SystemTabs.map(tab => (
|
||||
<Tab key={tab.id} label={tab.label} value={tab.id} />
|
||||
))}
|
||||
</Tabs>
|
||||
{activeTab === 'user-management' && <Member />}
|
||||
{activeTab === 'user-management' && (
|
||||
<Box sx={{ ml: -2 }}>
|
||||
<Member />
|
||||
</Box>
|
||||
)}
|
||||
{activeTab === 'model-config' && (
|
||||
<ModelConfig
|
||||
onCloseModal={() => setOpen(false)}
|
||||
chatModelData={chatModelData}
|
||||
embeddingModelData={embeddingModelData}
|
||||
rerankModelData={rerankModelData}
|
||||
analysisModelData={analysisModelData}
|
||||
analysisVLModelData={analysisVLModelData}
|
||||
getModelList={getModelList}
|
||||
/>
|
||||
<Box sx={{ ml: -2 }}>
|
||||
<ModelConfig
|
||||
ref={modelConfigRef}
|
||||
onCloseModal={() => setOpen(false)}
|
||||
chatModelData={chatModelData}
|
||||
embeddingModelData={embeddingModelData}
|
||||
rerankModelData={rerankModelData}
|
||||
analysisModelData={analysisModelData}
|
||||
analysisVLModelData={analysisVLModelData}
|
||||
getModelList={getModelList}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export default function ContributePreviewModal(
|
|||
? '新增'
|
||||
: '修改'}
|
||||
</Box>
|
||||
<Box sx={{ fontSize: 14, color: 'text.auxiliary', fontWeight: 400 }}>
|
||||
<Box sx={{ fontSize: 14, color: 'text.tertiary', fontWeight: 400 }}>
|
||||
{dayjs(row?.created_at).fromNow()}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const MarkdownPreviewModal = ({
|
|||
? '新增'
|
||||
: '修改'}
|
||||
</Box>
|
||||
<Box sx={{ fontSize: 14, color: 'text.auxiliary', fontWeight: 400 }}>
|
||||
<Box sx={{ fontSize: 14, color: 'text.tertiary', fontWeight: 400 }}>
|
||||
{dayjs(row?.created_at).fromNow()}
|
||||
</Box>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { ITreeItem } from '@/api';
|
||||
import Card from '@/components/Card';
|
||||
import DragTree from '@/components/Drag/DragTree';
|
||||
import { postApiV1NodeRestudy } from '@/request';
|
||||
import { getApiV1NodeList } from '@/request/Node';
|
||||
import {
|
||||
ConstsNodeRagInfoStatus,
|
||||
DomainNodeListItemResp,
|
||||
} from '@/request/types';
|
||||
import { useAppSelector } from '@/store';
|
||||
import { convertToTree } from '@/utils/drag';
|
||||
import { message, Modal } from '@ctzhian/ui';
|
||||
import { Box, Checkbox, Stack } from '@mui/material';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface RagErrorReStartProps {
|
||||
open: boolean;
|
||||
defaultSelected?: string[];
|
||||
onClose: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
const RagErrorReStart = ({
|
||||
open,
|
||||
defaultSelected = [],
|
||||
onClose,
|
||||
refresh,
|
||||
}: RagErrorReStartProps) => {
|
||||
const { kb_id } = useAppSelector(state => state.config);
|
||||
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [treeList, setTreeList] = useState<ITreeItem[]>([]);
|
||||
const [list, setList] = useState<DomainNodeListItemResp[]>([]);
|
||||
|
||||
const getData = () => {
|
||||
getApiV1NodeList({ kb_id }).then(res => {
|
||||
const ragErrorData =
|
||||
res?.filter(
|
||||
item =>
|
||||
item.rag_info?.status &&
|
||||
[
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusBasicFailed,
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceFailed,
|
||||
].includes(item.rag_info.status),
|
||||
) || [];
|
||||
setList(ragErrorData);
|
||||
setSelected(
|
||||
defaultSelected.length > 0
|
||||
? defaultSelected
|
||||
: ragErrorData.map(it => it.id!),
|
||||
);
|
||||
const showTreeData = convertToTree(ragErrorData || []);
|
||||
setTreeList(showTreeData);
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = () => {
|
||||
if (selected.length > 0) {
|
||||
postApiV1NodeRestudy({
|
||||
kb_id,
|
||||
node_ids: [...selected],
|
||||
}).then(() => {
|
||||
message.success('正在重新学习');
|
||||
setSelected([]);
|
||||
onClose();
|
||||
refresh();
|
||||
});
|
||||
} else {
|
||||
message.error(
|
||||
list.length > 0 ? '请选择要重新学习的文档' : '暂无学习失败的文档',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
getData();
|
||||
}
|
||||
}, [open, kb_id]);
|
||||
|
||||
const selectedTotal = useMemo(() => {
|
||||
return list.filter(item => selected.includes(item.id!)).length;
|
||||
}, [selected, list]);
|
||||
|
||||
return (
|
||||
<Modal title='重新学习' open={open} onCancel={onClose} onOk={onSubmit}>
|
||||
<Stack
|
||||
direction='row'
|
||||
component='label'
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
gap={1}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: '10px',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<Box>
|
||||
学习失败文档
|
||||
<Box
|
||||
component='span'
|
||||
sx={{ color: 'text.tertiary', fontSize: 12, pl: 1 }}
|
||||
>
|
||||
共 {list.length} 个,已选中 {selectedTotal} 个
|
||||
</Box>
|
||||
</Box>
|
||||
<Stack direction='row' alignItems={'center'}>
|
||||
<Box sx={{ color: 'text.tertiary', fontSize: 12 }}>全选</Box>
|
||||
<Checkbox
|
||||
size='small'
|
||||
sx={{
|
||||
p: 0,
|
||||
color: 'text.disabled',
|
||||
width: '35px',
|
||||
height: '35px',
|
||||
}}
|
||||
checked={selectedTotal === list.length}
|
||||
onChange={() => {
|
||||
setSelected(
|
||||
selectedTotal === list.length ? [] : list.map(item => item.id!),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Card sx={{ bgcolor: 'background.paper3', py: 1 }}>
|
||||
<Stack
|
||||
gap={0.25}
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
maxHeight: 'calc(100vh - 520px)',
|
||||
overflowY: 'auto',
|
||||
px: 2,
|
||||
}}
|
||||
>
|
||||
<DragTree
|
||||
ui='select'
|
||||
readOnly
|
||||
selected={selected}
|
||||
data={treeList}
|
||||
refresh={getData}
|
||||
onSelectChange={ids => setSelected(ids)}
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default RagErrorReStart;
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,8 +195,31 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
|
|||
onAiWritingGetSuggestion: handleAiWritingGetSuggestion,
|
||||
});
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (type: string) => {
|
||||
let value = editorRef?.getContent() || '';
|
||||
if (isMarkdown) {
|
||||
value = nodeDetail?.content || '';
|
||||
}
|
||||
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() || '';
|
||||
let currentContent = editorRef?.getContent() || '';
|
||||
if (isMarkdown) {
|
||||
currentContent = nodeDetail?.content || '';
|
||||
}
|
||||
const currentSummary = summary;
|
||||
const currentEmoji = nodeDetail?.meta?.emoji || '';
|
||||
|
||||
|
|
@ -218,7 +229,13 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
|
|||
currentEmoji !== initialStateRef.current.emoji;
|
||||
|
||||
setIsEditing(hasChanges);
|
||||
}, [editorRef, summary, nodeDetail?.meta?.emoji, isMarkdown]);
|
||||
}, [
|
||||
editorRef,
|
||||
summary,
|
||||
nodeDetail?.meta?.emoji,
|
||||
nodeDetail?.content,
|
||||
isMarkdown,
|
||||
]);
|
||||
|
||||
const handleAiGenerate = useCallback(() => {
|
||||
if (editorRef.editor) {
|
||||
|
|
@ -235,10 +252,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 +267,24 @@ 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(
|
||||
(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();
|
||||
}
|
||||
},
|
||||
[editorRef, onSave, id, summary, nodeDetail?.meta?.emoji, isMarkdown],
|
||||
[changeCatalogItem],
|
||||
);
|
||||
|
||||
const renderEditorTitleEmojiSummary = () => {
|
||||
|
|
@ -540,11 +556,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 +574,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 +596,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 () => {
|
||||
|
|
@ -630,17 +661,18 @@ 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}
|
||||
onAceChange={value => {
|
||||
updateDetail({
|
||||
content: value,
|
||||
});
|
||||
}}
|
||||
height='calc(100vh - 340px)'
|
||||
height='calc(100vh - 360px)'
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@ import {
|
|||
} from '@/components/Drag/DragTree/TreeMenu';
|
||||
import { useURLSearchParams } from '@/hooks';
|
||||
import { getApiV1NodeList } from '@/request/Node';
|
||||
import { ConstsCrawlerSource, DomainNodeListItemResp } from '@/request/types';
|
||||
import {
|
||||
ConstsCrawlerSource,
|
||||
ConstsNodeRagInfoStatus,
|
||||
DomainNodeListItemResp,
|
||||
} from '@/request/types';
|
||||
import { useAppDispatch, useAppSelector } from '@/store';
|
||||
import { setIsRefreshDocList } from '@/store/slices/config';
|
||||
import { addOpacityToColor } from '@/utils';
|
||||
|
|
@ -32,6 +36,7 @@ import DocSearch from './component/DocSearch';
|
|||
import DocStatus from './component/DocStatus';
|
||||
import DocSummary from './component/DocSummary';
|
||||
import MoveDocs from './component/MoveDocs';
|
||||
import RagErrorReStart from './component/RagErrorReStart';
|
||||
import Summary from './component/Summary';
|
||||
|
||||
const Content = () => {
|
||||
|
|
@ -44,10 +49,15 @@ 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 [publish, setPublish] = useState({
|
||||
// published: 0,
|
||||
unpublished: 0,
|
||||
});
|
||||
const [publishIds, setPublishIds] = useState<string[]>([]);
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
const [list, setList] = useState<DomainNodeListItemResp[]>([]);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [data, setData] = useState<ITreeItem[]>([]);
|
||||
|
|
@ -58,8 +68,6 @@ const Content = () => {
|
|||
const [moreSummaryOpen, setMoreSummaryOpen] = useState(false);
|
||||
const [moveOpen, setMoveOpen] = useState(false);
|
||||
const [urlOpen, setUrlOpen] = useState(false);
|
||||
const [publishIds, setPublishIds] = useState<string[]>([]);
|
||||
const [publishOpen, setPublishOpen] = useState(false);
|
||||
const [key, setKey] = useState<ConstsCrawlerSource | null>(null);
|
||||
const [propertiesOpen, setPropertiesOpen] = useState(false);
|
||||
const [isBatch, setIsBatch] = useState(false);
|
||||
|
|
@ -119,6 +127,11 @@ const Content = () => {
|
|||
setPublishIds([item.id]);
|
||||
};
|
||||
|
||||
const handleRestudy = (item: ITreeItem) => {
|
||||
setRagErrorOpen(true);
|
||||
setRagErrorIds([item.id]);
|
||||
};
|
||||
|
||||
const handleProperties = (item: ITreeItem) => {
|
||||
setPropertiesOpen(true);
|
||||
setOpraData(getOperationData(item));
|
||||
|
|
@ -252,6 +265,19 @@ const Content = () => {
|
|||
// },
|
||||
]
|
||||
: []),
|
||||
...(item?.rag_status &&
|
||||
[
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusBasicFailed,
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceFailed,
|
||||
].includes(item.rag_status)
|
||||
? [
|
||||
{
|
||||
label: '重新学习',
|
||||
key: 'restudy',
|
||||
onClick: () => handleRestudy(item),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(!isEditing
|
||||
? [{ label: '重命名', key: 'rename', onClick: renameItem }]
|
||||
: []),
|
||||
|
|
@ -308,8 +334,17 @@ const Content = () => {
|
|||
setList(res || []);
|
||||
setPublish({
|
||||
unpublished: res.filter(it => it.status === 1).length,
|
||||
// published: res.filter(it => it.status === 2).length,
|
||||
});
|
||||
setRagErrorCount(
|
||||
res.filter(
|
||||
it =>
|
||||
it.rag_info?.status &&
|
||||
[
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusBasicFailed,
|
||||
ConstsNodeRagInfoStatus.NodeRagStatusEnhanceFailed,
|
||||
].includes(it.rag_info.status),
|
||||
).length,
|
||||
);
|
||||
const collapsedAll = collapseAllFolders(convertToTree(res || []), true);
|
||||
const next = openIds.size
|
||||
? reopenFolders(collapsedAll, openIds)
|
||||
|
|
@ -386,6 +421,29 @@ const Content = () => {
|
|||
</Button>
|
||||
</>
|
||||
)}
|
||||
{ragErrorCount > 0 && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
color: 'error.main',
|
||||
fontSize: 12,
|
||||
fontWeight: 'normal',
|
||||
ml: 2,
|
||||
}}
|
||||
>
|
||||
{ragErrorCount} 个文档学习失败,
|
||||
</Box>
|
||||
<Button
|
||||
size='small'
|
||||
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
|
||||
onClick={() => {
|
||||
setRagErrorOpen(true);
|
||||
}}
|
||||
>
|
||||
重新学习
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={2}>
|
||||
<DocSearch />
|
||||
|
|
@ -670,6 +728,15 @@ const Content = () => {
|
|||
}}
|
||||
refresh={getData}
|
||||
/>
|
||||
<RagErrorReStart
|
||||
open={ragErrorOpen}
|
||||
defaultSelected={ragErrorIds}
|
||||
onClose={() => {
|
||||
setRagErrorOpen(false);
|
||||
setRagErrorIds([]);
|
||||
}}
|
||||
refresh={getData}
|
||||
/>
|
||||
<MoveDocs
|
||||
open={moveOpen}
|
||||
data={list}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
// @ts-nocheck
|
||||
/*
|
||||
* ---------------------------------------------------------------
|
||||
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
|
||||
* ## ##
|
||||
* ## AUTHOR: acacode ##
|
||||
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
|
||||
* ---------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import httpRequest, { ContentType, RequestParams } from "./httpClient";
|
||||
import { DomainResponse, V1NodeRestudyReq, V1NodeRestudyResp } from "./types";
|
||||
|
||||
/**
|
||||
* @description 文档重新学习
|
||||
*
|
||||
* @tags NodeRestudy
|
||||
* @name PostApiV1NodeRestudy
|
||||
* @summary 文档重新学习
|
||||
* @request POST:/api/v1/node/restudy
|
||||
* @secure
|
||||
* @response `200` `(DomainResponse & {
|
||||
data?: V1NodeRestudyResp,
|
||||
|
||||
})` OK
|
||||
*/
|
||||
|
||||
export const postApiV1NodeRestudy = (
|
||||
param: V1NodeRestudyReq,
|
||||
params: RequestParams = {},
|
||||
) =>
|
||||
httpRequest<
|
||||
DomainResponse & {
|
||||
data?: V1NodeRestudyResp;
|
||||
}
|
||||
>({
|
||||
path: `/api/v1/node/restudy`,
|
||||
method: "POST",
|
||||
body: param,
|
||||
secure: true,
|
||||
type: ContentType.Json,
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
|
|
@ -10,6 +10,7 @@ export * from './Message'
|
|||
export * from './Model'
|
||||
export * from './Node'
|
||||
export * from './NodePermission'
|
||||
export * from './NodeRestudy'
|
||||
export * from './Stat'
|
||||
export * from './User'
|
||||
export * from './types'
|
||||
|
|
|
|||
|
|
@ -1644,12 +1644,39 @@ export interface V1NodePermissionResp {
|
|||
visitable_groups?: DomainNodeGroupDetail[];
|
||||
}
|
||||
|
||||
export interface V1NodeRestudyReq {
|
||||
kb_id?: string;
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const dark = {
|
||||
cssVariables: true,
|
||||
primary: {
|
||||
main: '#fdfdfd',
|
||||
contrastText: '#000',
|
||||
|
|
@ -50,7 +51,7 @@ const dark = {
|
|||
text: {
|
||||
primary: '#fff',
|
||||
secondary: 'rgba(255,255,255,0.7)',
|
||||
auxiliary: 'rgba(255,255,255,0.5)',
|
||||
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)',
|
||||
|
|
@ -58,15 +59,14 @@ const dark = {
|
|||
},
|
||||
divider: '#ededed',
|
||||
background: {
|
||||
paper0: '#060608',
|
||||
paper: '#18181b',
|
||||
paper2: '#27272a',
|
||||
paper2: '#060608',
|
||||
paper3: '#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',
|
||||
|
|
@ -88,47 +88,4 @@ const dark = {
|
|||
},
|
||||
};
|
||||
|
||||
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;
|
||||
export default dark;
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
const light = {
|
||||
cssVariables: true,
|
||||
primary: {
|
||||
main: '#3248F2',
|
||||
contrastText: '#fff',
|
||||
|
|
@ -55,7 +56,7 @@ const light = {
|
|||
text: {
|
||||
primary: '#21222D',
|
||||
secondary: 'rgba(33,34,35,0.7)',
|
||||
auxiliary: 'rgba(33,34,35,0.5)',
|
||||
tertiary: '#646a73',
|
||||
slave: 'rgba(33,34,35,0.3)',
|
||||
disabled: 'rgba(33,34,35,0.2)',
|
||||
inverse: '#FFFFFF',
|
||||
|
|
@ -63,15 +64,13 @@ const light = {
|
|||
inverseDisabled: 'rgba(255,255,255,0.15)',
|
||||
},
|
||||
background: {
|
||||
paper0: '#F1F2F8',
|
||||
paper: '#FFFFFF',
|
||||
paper2: '#F8F9FA',
|
||||
|
||||
paper2: '#F1F2F8',
|
||||
paper3: '#F8F9FA',
|
||||
default: '#FFFFFF',
|
||||
chip: '#FFFFFF',
|
||||
circle: '#E6E8EC',
|
||||
hover: 'rgba(243, 244, 245, 0.5)',
|
||||
footer: '#14141B',
|
||||
},
|
||||
shadows: 'rgba(68, 80 ,91, 0.1)',
|
||||
table: {
|
||||
|
|
@ -93,47 +92,4 @@ const light = {
|
|||
},
|
||||
};
|
||||
|
||||
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;
|
||||
export default light;
|
||||
|
|
@ -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': 转换为 https(ftp://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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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%' }}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 || '/';
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import SSEClient from '@/utils/fetch';
|
||||
import { Box, Divider, Stack } from '@mui/material';
|
||||
import { Editor, useTiptap, UseTiptapReturn } from '@ctzhian/tiptap';
|
||||
import { Modal } from '@ctzhian/ui';
|
||||
import { Box, Divider, Stack } from '@mui/material';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface AIGenerateProps {
|
||||
|
|
@ -126,7 +126,7 @@ const AIGenerate = ({
|
|||
ml: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
原文
|
||||
|
|
@ -150,7 +150,7 @@ const AIGenerate = ({
|
|||
ml: 1,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
润色后
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client';
|
||||
import { V1NodeDetailResp } from '@/request/types';
|
||||
import { Ellipsis, Icon } from '@ctzhian/ui';
|
||||
import { Box, Button, Skeleton, Stack } from '@mui/material';
|
||||
import { IconBaocun } from '@panda-wiki/icons';
|
||||
import { Box, Button, IconButton, Skeleton, Stack } from '@mui/material';
|
||||
import { Ellipsis, Icon, message } from '@ctzhian/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useWrapContext } from '..';
|
||||
|
||||
interface HeaderProps {
|
||||
|
|
@ -46,7 +46,7 @@ const Header = ({ edit, detail, handleSave }: HeaderProps) => {
|
|||
onClick={() => setCatalogOpen(true)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
':hover': {
|
||||
color: 'text.primary',
|
||||
},
|
||||
|
|
@ -78,7 +78,7 @@ const Header = ({ edit, detail, handleSave }: HeaderProps) => {
|
|||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{ fontSize: 12, color: 'text.auxiliary' }}
|
||||
sx={{ fontSize: 12, color: 'text.tertiary' }}
|
||||
>
|
||||
<IconBaocun sx={{ fontSize: 12 }} />
|
||||
{nodeDetail?.updated_at ? (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
import { Box, Skeleton, Stack } from '@mui/material';
|
||||
import { useTiptap } from '@ctzhian/tiptap';
|
||||
import { Icon } from '@ctzhian/ui';
|
||||
import { Box, Skeleton, Stack } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import Header from './Header';
|
||||
import Toolbar from './Toolbar';
|
||||
|
|
@ -61,11 +61,11 @@ const LoadingEditorWrap = () => {
|
|||
</Stack>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={2} sx={{ mb: 4 }}>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
|
||||
<Icon type='icon-a-shijian2' sx={{ color: 'text.auxiliary' }} />
|
||||
<Icon type='icon-a-shijian2' sx={{ color: 'text.tertiary' }} />
|
||||
<Skeleton variant='text' width={130} height={24} />
|
||||
</Stack>
|
||||
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
|
||||
<Icon type='icon-ziti' sx={{ color: 'text.auxiliary' }} />
|
||||
<Icon type='icon-ziti' sx={{ color: 'text.tertiary' }} />
|
||||
<Skeleton variant='text' width={80} height={24} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const HeadingIcon = [
|
|||
|
||||
const HeadingSx = [
|
||||
{ fontSize: 14, fontWeight: 700, color: 'text.secondary' },
|
||||
{ fontSize: 14, fontWeight: 400, color: 'text.auxiliary' },
|
||||
{ fontSize: 14, fontWeight: 400, color: 'text.tertiary' },
|
||||
{ fontSize: 14, fontWeight: 400, color: 'text.disabled' },
|
||||
];
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ const Toc = ({
|
|||
sx={{
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
mb: 1,
|
||||
p: 1,
|
||||
pb: 0,
|
||||
|
|
|
|||
|
|
@ -267,10 +267,10 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
|
|||
gap={0.5}
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
cursor: 'text',
|
||||
':hover': {
|
||||
color: 'text.auxiliary',
|
||||
color: 'text.tertiary',
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -286,7 +286,7 @@ const Wrap = ({ detail: defaultDetail = {} }: WrapProps) => {
|
|||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{ fontSize: 12, color: 'text.auxiliary' }}
|
||||
sx={{ fontSize: 12, color: 'text.tertiary' }}
|
||||
>
|
||||
<IconZiti />
|
||||
{characterCount} 字
|
||||
|
|
|
|||
|
|
@ -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%',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"dependencies": {
|
||||
"@ctzhian/tiptap": "^1.11.4",
|
||||
"@ctzhian/tiptap": "^1.12.20",
|
||||
"@ctzhian/ui": "^7.0.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ const StyledTab = styled(Tab)(({ theme }) => ({
|
|||
},
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
color: theme.palette.background.default,
|
||||
color: theme.palette.primary.contrastText,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue