Compare commits

..

25 Commits

Author SHA1 Message Date
xiaomakuaiz b8f2b95f22 完善 OpenAI API 兼容性:增强 OpenAIContentPart 结构体
- 添加 ImageURL 字段支持 image_url 类型的内容部分
- 新增 OpenAIContentPartURL 结构体处理嵌套的 URL 对象
- 修复 MarshalJSON 使用指针接收器以保持与 UnmarshalJSON 的一致性
- 修复 String 方法的空格逻辑,使用 builder.Len() 替代索引判断

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: monkeycode-ai <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
Co-authored-by: MonkeyCode-AI <monkeycode-ai@chaitin.com>
2025-11-14 19:26:29 +08:00
Coltea 2b372fd81d
Merge pull request #1521 from coltea/fix-widget-search-path
fix widget search path
2025-11-14 19:01:45 +08:00
Coltea 74c6ba131d
Merge pull request #1520 from guanweiwang/hotfix/bug
fix: 修复 浏览器将http自动升级为 https
2025-11-14 19:01:32 +08:00
Coltea 6c77246e34
Merge pull request #1522 from KuaiYu95/fe/apiurl
fix: api url
2025-11-14 19:01:09 +08:00
coltea d347482d31 fix widget search path 2025-11-14 19:00:15 +08:00
yu.kuai 92443dfafe fix: api url 2025-11-14 18:59:52 +08:00
Gavan 1dd2d93010 fix: 修复 浏览器将http自动升级为 https 2025-11-14 18:49:25 +08:00
Coltea 54bf1fd108
Merge pull request #1518 from guanweiwang/feature/more_feat
feat: 退出登录 & 对话记录 & md 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖
2025-11-14 18:34:28 +08:00
Coltea c23ce398d7
Merge pull request #1517 from coltea/feat-widget
feat 新版挂件机器人 && 用户登出 &&  文档目录页
2025-11-14 18:34:14 +08:00
coltea 2c90932f60 feat node detail folder 2025-11-14 18:33:30 +08:00
Gavan 035ce0284d feat: 退出登录 & 对话记录 & d 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖 2025-11-14 18:30:49 +08:00
Coltea 93559125c2
Merge pull request #1519 from KuaiYu95/fe/widget
网页挂件支持三种模式
2025-11-14 18:23:04 +08:00
yu.kuai 6c5cf256ac feat: 后台网页挂件新增配置项
feat: 前台网页挂件支持三种模式:悬浮球,侧边吸附,自定义按钮
fix: markdown 编辑器新增配置项:showAutocomplete,highlightActiveLine
fix: markdown 编辑器输入中午拼音与 placeholder 重叠
fix: 使用官方修复后的 migrateMathStrings.ts
fix: 使用官方修复后的 table-of-contents
2025-11-14 17:54:37 +08:00
coltea 23bdfc3d50 feat widget search 2025-11-14 15:53:21 +08:00
coltea 9008c537cd feat logout 2025-11-14 10:44:00 +08:00
Coltea 63a4a964b1
Merge pull request #1516 from guanweiwang/feature/perm
feat: 权限
2025-11-13 19:00:28 +08:00
Coltea 62f2b2eaf5
Merge pull request #1514 from guanweiwang/feature/ai_search
feat: 优化 ai 搜索弹窗
2025-11-13 18:59:54 +08:00
Gavan 5c1c6368b8 feat: add expandable sections for results and thinking content in AiQaContent; update styles and remove unused hooks 2025-11-13 18:59:13 +08:00
Gavan 7282503acf feat: 权限 2025-11-13 18:55:25 +08:00
Coltea 60a4177229
Merge pull request #1511 from coltea/feat-edition
feat edition
2025-11-13 18:52:20 +08:00
coltea 2f706a6100 fix share nodes position 2025-11-13 18:40:33 +08:00
coltea febcb06654 feat edition 2025-11-13 15:50:05 +08:00
113 changed files with 6865 additions and 2723 deletions

View File

@ -7,21 +7,22 @@ import (
)
type ShareNodeDetailResp struct {
ID string `json:"id"`
KbID string `json:"kb_id"`
Type domain.NodeType `json:"type"`
Status domain.NodeStatus `json:"status"`
Name string `json:"name"`
Content string `json:"content"`
Meta domain.NodeMeta `json:"meta"`
ParentID string `json:"parent_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Permissions domain.NodePermissions `json:"permissions"`
CreatorId string `json:"creator_id"`
EditorId string `json:"editor_id"`
PublisherId string `json:"publisher_id"`
CreatorAccount string `json:"creator_account"`
EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account"`
ID string `json:"id"`
KbID string `json:"kb_id"`
Type domain.NodeType `json:"type"`
Status domain.NodeStatus `json:"status"`
Name string `json:"name"`
Content string `json:"content"`
Meta domain.NodeMeta `json:"meta"`
ParentID string `json:"parent_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Permissions domain.NodePermissions `json:"permissions"`
CreatorId string `json:"creator_id"`
EditorId string `json:"editor_id"`
PublisherId string `json:"publisher_id"`
CreatorAccount string `json:"creator_account"`
EditorAccount string `json:"editor_account"`
PublisherAccount string `json:"publisher_account"`
List []*domain.ShareNodeListItemResp `json:"list" gorm:"-"`
}

View File

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

View File

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

View File

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

View File

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

View File

@ -3478,7 +3478,7 @@ const docTemplate = `{
"application/json"
],
"tags": [
"share_chat"
"Widget"
],
"summary": "ChatWidget",
"parameters": [
@ -3509,6 +3509,52 @@ const docTemplate = `{
}
}
},
"/share/v1/chat/widget/search": {
"post": {
"description": "WidgetSearch",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Widget"
],
"summary": "WidgetSearch",
"parameters": [
{
"description": "Comment",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChatSearchReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.ChatSearchResp"
}
}
}
]
}
}
}
}
},
"/share/v1/comment": {
"post": {
"description": "CreateComment",
@ -4067,22 +4113,26 @@ const docTemplate = `{
"enum": [
0,
1,
2
2,
3
],
"x-enum-comments": {
"LicenseEditionContributor": "联创版",
"LicenseEditionBusiness": "商业版",
"LicenseEditionEnterprise": "企业版",
"LicenseEditionFree": "开源版"
"LicenseEditionFree": "开源版",
"LicenseEditionProfession": "专业版"
},
"x-enum-descriptions": [
"开源版",
"联创版",
"企业版"
"专业版",
"企业版",
"商业版"
],
"x-enum-varnames": [
"LicenseEditionFree",
"LicenseEditionContributor",
"LicenseEditionEnterprise"
"LicenseEditionProfession",
"LicenseEditionEnterprise",
"LicenseEditionBusiness"
]
},
"consts.ModelSettingMode": {
@ -6311,6 +6361,9 @@ const docTemplate = `{
}
}
},
"domain.MessageContent": {
"type": "object"
},
"domain.MessageFrom": {
"type": "integer",
"enum": [
@ -6712,6 +6765,9 @@ const docTemplate = `{
"stream": {
"type": "boolean"
},
"stream_options": {
"$ref": "#/definitions/domain.OpenAIStreamOptions"
},
"temperature": {
"type": "number"
},
@ -6834,7 +6890,7 @@ const docTemplate = `{
],
"properties": {
"content": {
"type": "string"
"$ref": "#/definitions/domain.MessageContent"
},
"name": {
"type": "string"
@ -6864,6 +6920,14 @@ const docTemplate = `{
}
}
},
"domain.OpenAIStreamOptions": {
"type": "object",
"properties": {
"include_usage": {
"type": "boolean"
}
}
},
"domain.OpenAITool": {
"type": "object",
"required": [
@ -7130,6 +7194,35 @@ const docTemplate = `{
}
}
},
"domain.ShareNodeListItemResp": {
"type": "object",
"properties": {
"emoji": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"parent_id": {
"type": "string"
},
"permissions": {
"$ref": "#/definitions/domain.NodePermissions"
},
"position": {
"type": "number"
},
"type": {
"$ref": "#/definitions/domain.NodeType"
},
"updated_at": {
"type": "string"
}
}
},
"domain.SimpleAuth": {
"type": "object",
"properties": {
@ -7647,15 +7740,33 @@ const docTemplate = `{
"domain.WidgetBotSettings": {
"type": "object",
"properties": {
"btn_id": {
"type": "string"
},
"btn_logo": {
"type": "string"
},
"btn_position": {
"type": "string"
},
"btn_style": {
"type": "string"
},
"btn_text": {
"type": "string"
},
"disclaimer": {
"type": "string"
},
"is_open": {
"type": "boolean"
},
"modal_position": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"recommend_node_ids": {
"type": "array",
"items": {
@ -7668,6 +7779,9 @@ const docTemplate = `{
"type": "string"
}
},
"search_mode": {
"type": "string"
},
"theme_mode": {
"type": "string"
}
@ -8536,6 +8650,12 @@ const docTemplate = `{
"kb_id": {
"type": "string"
},
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShareNodeListItemResp"
}
},
"meta": {
"$ref": "#/definitions/domain.NodeMeta"
},

View File

@ -3471,7 +3471,7 @@
"application/json"
],
"tags": [
"share_chat"
"Widget"
],
"summary": "ChatWidget",
"parameters": [
@ -3502,6 +3502,52 @@
}
}
},
"/share/v1/chat/widget/search": {
"post": {
"description": "WidgetSearch",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"Widget"
],
"summary": "WidgetSearch",
"parameters": [
{
"description": "Comment",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/domain.ChatSearchReq"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"allOf": [
{
"$ref": "#/definitions/domain.Response"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/domain.ChatSearchResp"
}
}
}
]
}
}
}
}
},
"/share/v1/comment": {
"post": {
"description": "CreateComment",
@ -4060,22 +4106,26 @@
"enum": [
0,
1,
2
2,
3
],
"x-enum-comments": {
"LicenseEditionContributor": "联创版",
"LicenseEditionBusiness": "商业版",
"LicenseEditionEnterprise": "企业版",
"LicenseEditionFree": "开源版"
"LicenseEditionFree": "开源版",
"LicenseEditionProfession": "专业版"
},
"x-enum-descriptions": [
"开源版",
"联创版",
"企业版"
"专业版",
"企业版",
"商业版"
],
"x-enum-varnames": [
"LicenseEditionFree",
"LicenseEditionContributor",
"LicenseEditionEnterprise"
"LicenseEditionProfession",
"LicenseEditionEnterprise",
"LicenseEditionBusiness"
]
},
"consts.ModelSettingMode": {
@ -6304,6 +6354,9 @@
}
}
},
"domain.MessageContent": {
"type": "object"
},
"domain.MessageFrom": {
"type": "integer",
"enum": [
@ -6705,6 +6758,9 @@
"stream": {
"type": "boolean"
},
"stream_options": {
"$ref": "#/definitions/domain.OpenAIStreamOptions"
},
"temperature": {
"type": "number"
},
@ -6827,7 +6883,7 @@
],
"properties": {
"content": {
"type": "string"
"$ref": "#/definitions/domain.MessageContent"
},
"name": {
"type": "string"
@ -6857,6 +6913,14 @@
}
}
},
"domain.OpenAIStreamOptions": {
"type": "object",
"properties": {
"include_usage": {
"type": "boolean"
}
}
},
"domain.OpenAITool": {
"type": "object",
"required": [
@ -7123,6 +7187,35 @@
}
}
},
"domain.ShareNodeListItemResp": {
"type": "object",
"properties": {
"emoji": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"parent_id": {
"type": "string"
},
"permissions": {
"$ref": "#/definitions/domain.NodePermissions"
},
"position": {
"type": "number"
},
"type": {
"$ref": "#/definitions/domain.NodeType"
},
"updated_at": {
"type": "string"
}
}
},
"domain.SimpleAuth": {
"type": "object",
"properties": {
@ -7640,15 +7733,33 @@
"domain.WidgetBotSettings": {
"type": "object",
"properties": {
"btn_id": {
"type": "string"
},
"btn_logo": {
"type": "string"
},
"btn_position": {
"type": "string"
},
"btn_style": {
"type": "string"
},
"btn_text": {
"type": "string"
},
"disclaimer": {
"type": "string"
},
"is_open": {
"type": "boolean"
},
"modal_position": {
"type": "string"
},
"placeholder": {
"type": "string"
},
"recommend_node_ids": {
"type": "array",
"items": {
@ -7661,6 +7772,9 @@
"type": "string"
}
},
"search_mode": {
"type": "string"
},
"theme_mode": {
"type": "string"
}
@ -8529,6 +8643,12 @@
"kb_id": {
"type": "string"
},
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/domain.ShareNodeListItemResp"
}
},
"meta": {
"$ref": "#/definitions/domain.NodeMeta"
},

View File

@ -117,20 +117,24 @@ definitions:
- 0
- 1
- 2
- 3
format: int32
type: integer
x-enum-comments:
LicenseEditionContributor: 联创
LicenseEditionBusiness: 商业
LicenseEditionEnterprise: 企业版
LicenseEditionFree: 开源版
LicenseEditionProfession: 专业版
x-enum-descriptions:
- 开源版
- 联创
- 专业
- 企业版
- 商业版
x-enum-varnames:
- LicenseEditionFree
- LicenseEditionContributor
- LicenseEditionProfession
- LicenseEditionEnterprise
- LicenseEditionBusiness
consts.ModelSettingMode:
enum:
- manual
@ -1610,6 +1614,8 @@ definitions:
url:
type: string
type: object
domain.MessageContent:
type: object
domain.MessageFrom:
enum:
- 1
@ -1871,6 +1877,8 @@ definitions:
type: array
stream:
type: boolean
stream_options:
$ref: '#/definitions/domain.OpenAIStreamOptions'
temperature:
type: number
tool_choice:
@ -1952,7 +1960,7 @@ definitions:
domain.OpenAIMessage:
properties:
content:
type: string
$ref: '#/definitions/domain.MessageContent'
name:
type: string
role:
@ -1973,6 +1981,11 @@ definitions:
required:
- type
type: object
domain.OpenAIStreamOptions:
properties:
include_usage:
type: boolean
type: object
domain.OpenAITool:
properties:
function:
@ -2146,6 +2159,25 @@ definitions:
role:
$ref: '#/definitions/schema.RoleType'
type: object
domain.ShareNodeListItemResp:
properties:
emoji:
type: string
id:
type: string
name:
type: string
parent_id:
type: string
permissions:
$ref: '#/definitions/domain.NodePermissions'
position:
type: number
type:
$ref: '#/definitions/domain.NodeType'
updated_at:
type: string
type: object
domain.SimpleAuth:
properties:
enabled:
@ -2488,12 +2520,24 @@ definitions:
type: object
domain.WidgetBotSettings:
properties:
btn_id:
type: string
btn_logo:
type: string
btn_position:
type: string
btn_style:
type: string
btn_text:
type: string
disclaimer:
type: string
is_open:
type: boolean
modal_position:
type: string
placeholder:
type: string
recommend_node_ids:
items:
type: string
@ -2502,6 +2546,8 @@ definitions:
items:
type: string
type: array
search_mode:
type: string
theme_mode:
type: string
type: object
@ -3075,6 +3121,10 @@ definitions:
type: string
kb_id:
type: string
list:
items:
$ref: '#/definitions/domain.ShareNodeListItemResp'
type: array
meta:
$ref: '#/definitions/domain.NodeMeta'
name:
@ -5296,7 +5346,34 @@ paths:
$ref: '#/definitions/domain.Response'
summary: ChatWidget
tags:
- share_chat
- Widget
/share/v1/chat/widget/search:
post:
consumes:
- application/json
description: WidgetSearch
parameters:
- description: Comment
in: body
name: request
required: true
schema:
$ref: '#/definitions/domain.ChatSearchReq'
produces:
- application/json
responses:
"200":
description: OK
schema:
allOf:
- $ref: '#/definitions/domain.Response'
- properties:
data:
$ref: '#/definitions/domain.ChatSearchResp'
type: object
summary: WidgetSearch
tags:
- Widget
/share/v1/comment:
post:
consumes:

View File

@ -405,6 +405,13 @@ type WidgetBotSettings struct {
BtnLogo string `json:"btn_logo,omitempty"`
RecommendQuestions []string `json:"recommend_questions,omitempty"`
RecommendNodeIDs []string `json:"recommend_node_ids,omitempty"`
BtnStyle string `json:"btn_style,omitempty"`
BtnID string `json:"btn_id,omitempty"`
BtnPosition string `json:"btn_position,omitempty"`
ModalPosition string `json:"modal_position,omitempty"`
SearchMode string `json:"search_mode,omitempty"`
Placeholder string `json:"placeholder,omitempty"`
Disclaimer string `json:"disclaimer,omitempty"`
}
type BrandGroup struct {

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

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

View File

@ -37,8 +37,14 @@ type MessageContent struct {
// OpenAIContentPart 表示内容数组中的单个元素
type OpenAIContentPart struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Type string `json:"type"`
Text string `json:"text,omitempty"`
ImageURL *OpenAIContentPartURL `json:"image_url,omitempty"`
}
// OpenAIContentPartURL represents the image_url field in content parts
type OpenAIContentPartURL struct {
URL string `json:"url"`
}
// UnmarshalJSON 自定义解析,支持 string 或 array 格式
@ -63,7 +69,7 @@ func (mc *MessageContent) UnmarshalJSON(data []byte) error {
}
// MarshalJSON 自定义序列化
func (mc MessageContent) MarshalJSON() ([]byte, error) {
func (mc *MessageContent) MarshalJSON() ([]byte, error) {
if mc.isString {
return json.Marshal(mc.strValue)
}
@ -93,9 +99,9 @@ func (mc *MessageContent) String() string {
}
// 从数组中提取文本
var builder strings.Builder
for i, part := range mc.arrValue {
for _, part := range mc.arrValue {
if part.Type == "text" {
if i > 0 && part.Text != "" {
if builder.Len() > 0 && part.Text != "" {
builder.WriteString(" ")
}
builder.WriteString(part.Text)
@ -181,9 +187,9 @@ type OpenAIStreamResponse struct {
}
type OpenAIStreamChoice struct {
Index int `json:"index"`
Delta OpenAIMessage `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
Index int `json:"index"`
Delta OpenAIMessage `json:"delta"`
FinishReason *string `json:"finish_reason,omitempty"`
}
// OpenAI 错误响应结构体

View File

@ -61,6 +61,7 @@ func NewShareChatHandler(
share.POST("/search", h.ChatSearch, h.ShareAuthMiddleware.Authorize)
share.POST("/completions", h.ChatCompletions)
share.POST("/widget", h.ChatWidget)
share.POST("/widget/search", h.WidgetSearch)
share.POST("/feedback", h.FeedBack)
return h
}
@ -131,7 +132,7 @@ func (h *ShareChatHandler) ChatMessage(c echo.Context) error {
//
// @Summary ChatWidget
// @Description ChatWidget
// @Tags share_chat
// @Tags Widget
// @Accept json
// @Produce json
// @Param app_type query string true "app type"
@ -444,7 +445,7 @@ func stringPtr(s string) *string {
return &s
}
// ChatMessage chat search
// ChatSearch searches chat messages in shared knowledge base
//
// @Summary ChatSearch
// @Description ChatSearch
@ -487,3 +488,43 @@ func (h *ShareChatHandler) ChatSearch(c echo.Context) error {
}
return h.NewResponseWithData(c, resp)
}
// WidgetSearch
//
// @Summary WidgetSearch
// @Description WidgetSearch
// @Tags Widget
// @Accept json
// @Produce json
// @Param request body domain.ChatSearchReq true "Comment"
// @Success 200 {object} domain.Response{data=domain.ChatSearchResp}
// @Router /share/v1/chat/widget/search [post]
func (h *ShareChatHandler) WidgetSearch(c echo.Context) error {
var req domain.ChatSearchReq
if err := c.Bind(&req); err != nil {
return h.NewResponseWithError(c, "parse request failed", err)
}
req.KBID = c.Request().Header.Get("X-KB-ID")
if err := c.Validate(&req); err != nil {
return h.NewResponseWithError(c, "validate request failed", err)
}
ctx := c.Request().Context()
// validate widget info
widgetAppInfo, err := h.appUsecase.GetWidgetAppInfo(c.Request().Context(), req.KBID)
if err != nil {
h.logger.Error("get widget app info failed", log.Error(err))
return h.sendErrMsg(c, "get app info error")
}
if !widgetAppInfo.Settings.WidgetBotSettings.IsOpen {
return h.sendErrMsg(c, "widget is not open")
}
req.RemoteIP = c.RealIP()
resp, err := h.chatUsecase.Search(ctx, &req)
if err != nil {
return h.NewResponseWithError(c, "failed to search docs", err)
}
return h.NewResponseWithData(c, resp)
}

View File

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

View File

@ -91,5 +91,15 @@ func (h *ShareNodeHandler) GetNodeDetail(c echo.Context) error {
if err != nil {
return h.NewResponseWithError(c, "failed to get node detail", err)
}
// If the node is a folder, return the list of child nodes
if node.Type == domain.NodeTypeFolder {
childNodes, err := h.usecase.GetNodeReleaseListByParentID(c.Request().Context(), kbID, id, domain.GetAuthID(c))
if err != nil {
return h.NewResponseWithError(c, "failed to get child nodes", err)
}
node.List = childNodes
}
return h.NewResponseWithData(c, node)
}

View File

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

View File

@ -91,11 +91,7 @@ func (h *KnowledgeBaseHandler) CreateKnowledgeBase(c echo.Context) error {
return h.NewResponseWithError(c, "ports is required", nil)
}
req.MaxKB = 1
maxKB := c.Get("max_kb")
if maxKB != nil {
req.MaxKB = maxKB.(int)
}
req.MaxKB = domain.GetBaseEditionLimitation(c.Request().Context()).MaxKb
did, err := h.usecase.CreateKnowledgeBase(c.Request().Context(), &req)
if err != nil {

View File

@ -46,7 +46,7 @@ func NewModelHandler(echo *echo.Echo, baseHandler *handler.BaseHandler, logger *
return handler
}
// get model list
// GetModelList
//
// @Summary get model list
// @Description get model list
@ -66,7 +66,7 @@ func (h *ModelHandler) GetModelList(c echo.Context) error {
return h.NewResponseWithData(c, models)
}
// create model
// CreateModel
//
// @Summary create model
// @Description create model
@ -85,9 +85,6 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithError(c, "invalid request", err)
}
if consts.GetLicenseEdition(c) == consts.LicenseEditionContributor && req.Provider != domain.ModelProviderBrandBaiZhiCloud {
return h.NewResponseWithError(c, "联创版只能使用百智云模型哦~", nil)
}
ctx := c.Request().Context()
param := domain.ModelParam{}
@ -112,7 +109,7 @@ func (h *ModelHandler) CreateModel(c echo.Context) error {
return h.NewResponseWithData(c, model)
}
// update model
// UpdateModel
//
// @Description update model
// @Tags model
@ -130,9 +127,6 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
return h.NewResponseWithError(c, "invalid request", err)
}
if consts.GetLicenseEdition(c) == consts.LicenseEditionContributor && req.Provider != domain.ModelProviderBrandBaiZhiCloud {
return h.NewResponseWithError(c, "联创版只能使用百智云模型哦~", nil)
}
ctx := c.Request().Context()
if err := h.usecase.Update(ctx, &req); err != nil {
return h.NewResponseWithError(c, "update model failed", err)
@ -140,7 +134,7 @@ func (h *ModelHandler) UpdateModel(c echo.Context) error {
return h.NewResponseWithData(c, nil)
}
// check model
// CheckModel
//
// @Summary check model
// @Description check model

View File

@ -81,15 +81,13 @@ func (h *NodeHandler) CreateNode(c echo.Context) error {
if err := c.Validate(req); err != nil {
return h.NewResponseWithError(c, "validate request body failed", err)
}
req.MaxNode = 300
if maxNode := c.Get("max_node"); maxNode != nil {
req.MaxNode = maxNode.(int)
}
req.MaxNode = domain.GetBaseEditionLimitation(ctx).MaxNode
id, err := h.usecase.Create(c.Request().Context(), req, authInfo.UserId)
if err != nil {
if errors.Is(err, domain.ErrMaxNodeLimitReached) {
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到联创版或企业版", nil)
return h.NewResponseWithError(c, "已达到最大文档数量限制,请升级到更高版本", nil)
}
return h.NewResponseWithError(c, "create node failed", err)
}

View File

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

View File

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

@ -1 +1 @@
Subproject commit c4dc498df094cb617d31c95580db8239a445d652
Subproject commit bb1b17dd5c7d72d40f6a1198b1604f4d3c44116e

View File

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

View File

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

View File

@ -341,11 +341,12 @@ func (r *KnowledgeBaseRepository) CreateKnowledgeBase(ctx context.Context, maxKB
Name: kb.Name,
Type: domain.AppTypeWeb,
Settings: domain.AppSettings{
Title: kb.Name,
Desc: kb.Name,
Keyword: kb.Name,
Icon: domain.DefaultPandaWikiIconB64,
WelcomeStr: fmt.Sprintf("欢迎使用%s", kb.Name),
Title: kb.Name,
Desc: kb.Name,
Keyword: kb.Name,
AutoSitemap: true,
Icon: domain.DefaultPandaWikiIconB64,
WelcomeStr: fmt.Sprintf("欢迎使用%s", kb.Name),
Btns: []any{
AppBtn{
ID: uuid.New().String(),

View File

@ -683,7 +683,7 @@ func (r *NodeRepository) GetNodeReleaseListByKBID(ctx context.Context, kbID stri
Where("kb_release_node_releases.kb_id = ?", kbID).
Where("kb_release_node_releases.release_id = ?", kbRelease.ID).
Where("nodes.permissions->>'visible' != ?", consts.NodeAccessPermClosed).
Select("node_releases.node_id as id, node_releases.name, node_releases.type, node_releases.parent_id, node_releases.position, node_releases.meta->>'emoji' as emoji, node_releases.updated_at, nodes.permissions").
Select("node_releases.node_id as id, node_releases.name, node_releases.type, node_releases.parent_id, nodes.position, node_releases.meta->>'emoji' as emoji, node_releases.updated_at, nodes.permissions").
Find(&nodes).Error; err != nil {
return nil, err
}

View File

@ -60,18 +60,14 @@ func (r *UserRepository) CreateUser(ctx context.Context, user *domain.User, edit
}
user.Password = string(hashedPassword)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if edition == consts.LicenseEditionContributor || edition == consts.LicenseEditionFree {
var count int64
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
return err
}
if edition == consts.LicenseEditionFree && count >= 1 {
return errors.New("free edition only allows 1 user")
}
if edition == consts.LicenseEditionContributor && count >= 5 {
return errors.New("contributor edition only allows 5 user")
}
var count int64
if err := tx.Model(&domain.User{}).Count(&count).Error; err != nil {
return err
}
if count >= domain.GetBaseEditionLimitation(ctx).MaxAdmin {
return fmt.Errorf("exceed max admin limit, current count: %d, max limit: %d", count, domain.GetBaseEditionLimitation(ctx).MaxAdmin)
}
if err := tx.Create(user).Error; err != nil {
return err
}

View File

@ -88,34 +88,38 @@ func NewAppUsecase(
}
func (u *AppUsecase) ValidateUpdateApp(ctx context.Context, id string, req *domain.UpdateAppReq, edition consts.LicenseEdition) error {
switch edition {
case consts.LicenseEditionFree:
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
if app.Settings.WatermarkContent != req.Settings.WatermarkContent ||
app.Settings.WatermarkSetting != req.Settings.WatermarkSetting ||
app.Settings.ContributeSettings != req.Settings.ContributeSettings ||
app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
case consts.LicenseEditionContributor:
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
if app.Settings.WatermarkContent != req.Settings.WatermarkContent ||
app.Settings.WatermarkSetting != req.Settings.WatermarkSetting ||
app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
case consts.LicenseEditionEnterprise:
return nil
default:
return fmt.Errorf("unsupported license type: %d", edition)
app, err := u.repo.GetAppDetail(ctx, id)
if err != nil {
return err
}
limitation := domain.GetBaseEditionLimitation(ctx)
if !limitation.AllowCopyProtection && app.Settings.CopySetting != req.Settings.CopySetting {
return domain.ErrPermissionDenied
}
if !limitation.AllowWatermark {
if app.Settings.WatermarkSetting != req.Settings.WatermarkSetting || app.Settings.WatermarkContent != req.Settings.WatermarkContent {
return domain.ErrPermissionDenied
}
}
if !limitation.AllowAdvancedBot {
if !slices.Equal(app.Settings.WechatServiceContainKeywords, req.Settings.WechatServiceContainKeywords) ||
!slices.Equal(app.Settings.WechatServiceEqualKeywords, req.Settings.WechatServiceEqualKeywords) {
return domain.ErrPermissionDenied
}
}
if !limitation.AllowCommentAudit && app.Settings.WebAppCommentSettings.ModerationEnable != req.Settings.WebAppCommentSettings.ModerationEnable {
return domain.ErrPermissionDenied
}
if !limitation.AllowOpenAIBotSettings {
if app.Settings.OpenAIAPIBotSettings.IsEnabled != req.Settings.OpenAIAPIBotSettings.IsEnabled || app.Settings.OpenAIAPIBotSettings.SecretKey != req.Settings.OpenAIAPIBotSettings.SecretKey {
return domain.ErrPermissionDenied
}
}
return nil
}
@ -618,8 +622,8 @@ func (u *AppUsecase) ShareGetWebAppInfo(ctx context.Context, kbID string, authId
}
showBrand := true
defaultDisclaimer := "本回答由 PandaWiki 基于 AI 生成,仅供参考。"
licenseEdition, _ := ctx.Value(consts.ContextKeyEdition).(consts.LicenseEdition)
if licenseEdition < consts.LicenseEditionEnterprise {
if !domain.GetBaseEditionLimitation(ctx).AllowCustomCopyright {
appInfo.Settings.WebAppCustomSettings.ShowBrandInfo = &showBrand
appInfo.Settings.DisclaimerSettings.Content = &defaultDisclaimer
} else {

View File

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

View File

@ -350,6 +350,56 @@ func (u *NodeUsecase) GetNodeReleaseListByKBID(ctx context.Context, kbID string,
return items, nil
}
func (u *NodeUsecase) GetNodeReleaseListByParentID(ctx context.Context, kbID, parentID string, authId uint) ([]*domain.ShareNodeListItemResp, error) {
// 一次性查询所有节点
allNodes, err := u.nodeRepo.GetNodeReleaseListByKBID(ctx, kbID)
if err != nil {
return nil, err
}
nodeGroupIds, err := u.GetNodeIdsByAuthId(ctx, authId, consts.NodePermNameVisible)
if err != nil {
return nil, err
}
// 先过滤权限
visibleNodes := make([]*domain.ShareNodeListItemResp, 0)
for i, node := range allNodes {
switch node.Permissions.Visible {
case consts.NodeAccessPermOpen:
visibleNodes = append(visibleNodes, allNodes[i])
case consts.NodeAccessPermPartial:
if slices.Contains(nodeGroupIds, node.ID) {
visibleNodes = append(visibleNodes, allNodes[i])
}
}
}
// 构建父子关系映射
childrenMap := make(map[string][]*domain.ShareNodeListItemResp)
for _, node := range visibleNodes {
childrenMap[node.ParentID] = append(childrenMap[node.ParentID], node)
}
// 递归收集所有后代节点
result := make([]*domain.ShareNodeListItemResp, 0)
u.collectDescendants(parentID, childrenMap, &result)
return result, nil
}
// collectDescendants 递归收集所有后代节点
func (u *NodeUsecase) collectDescendants(parentID string, childrenMap map[string][]*domain.ShareNodeListItemResp, result *[]*domain.ShareNodeListItemResp) {
children := childrenMap[parentID]
for _, child := range children {
*result = append(*result, child)
// 如果是文件夹,递归收集其子节点
if child.Type == domain.NodeTypeFolder {
u.collectDescendants(child.ID, childrenMap, result)
}
}
}
func (u *NodeUsecase) GetNodeIdsByAuthId(ctx context.Context, authId uint, PermName consts.NodePermName) ([]string, error) {
authGroups, err := u.authRepo.GetAuthGroupWithParentsByAuthId(ctx, authId)
if err != nil {
@ -407,7 +457,7 @@ func (u *NodeUsecase) GetNodePermissionsByID(ctx context.Context, id, kbID strin
}
func (u *NodeUsecase) ValidateNodePermissionsEdit(req v1.NodePermissionEditReq, edition consts.LicenseEdition) error {
if edition != consts.LicenseEditionEnterprise {
if !slices.Contains([]consts.LicenseEdition{consts.LicenseEditionBusiness, consts.LicenseEditionEnterprise}, edition) {
if req.Permissions.Answerable == consts.NodeAccessPermPartial || req.Permissions.Visitable == consts.NodeAccessPermPartial || req.Permissions.Visible == consts.NodeAccessPermPartial {
return domain.ErrPermissionDenied
}

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strconv"
@ -67,12 +68,12 @@ func (u *StatUseCase) ValidateStatDay(statDay consts.StatDay, edition consts.Lic
case consts.StatDay1:
return nil
case consts.StatDay7:
if edition < consts.LicenseEditionContributor {
if edition == consts.LicenseEditionFree {
return domain.ErrPermissionDenied
}
return nil
case consts.StatDay30, consts.StatDay90:
if edition < consts.LicenseEditionEnterprise {
if !slices.Contains([]consts.LicenseEdition{consts.LicenseEditionBusiness, consts.LicenseEditionEnterprise}, edition) {
return domain.ErrPermissionDenied
}
return nil

View File

@ -588,6 +588,7 @@ export type ChatConversationItem = {
export type ChatConversationPair = {
user: string;
assistant: string;
thinking_content: string;
created_at: string;
info: {
feedback_content: string;

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

View File

@ -144,7 +144,7 @@ const Config: React.FC<ConfigProps> = ({ setIsEdit, id }) => {
callback();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [subscribe]);
}, [subscribe, appPreviewData, id]);
return (
<StyledCommonWrapper>

View File

@ -10,6 +10,8 @@ import { setAppPreviewData } from '@/store/slices/config';
import { DomainSocialMediaAccount } from '@/request/types';
import Switch from '../basicComponents/Switch';
import DragSocialInfo from '../basicComponents/DragSocialInfo';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
interface FooterConfigProps {
data?: AppDetail | null;
@ -75,9 +77,6 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
);
const footer_show_intro = watch('footer_show_intro');
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
useEffect(() => {
if (isEdit && appPreviewData) {
setValue(
@ -506,29 +505,33 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
)}
/>
</Stack>
{isEnterprise && (
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
PandaWiki
</Box>
<Stack direction={'column'} gap={2}>
<Box
sx={{
fontSize: 14,
lineHeight: '22px',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
fontWeight: 600,
'&::before': {
content: '""',
display: 'inline-block',
width: 4,
height: 12,
bgcolor: '#3248F2',
borderRadius: '2px',
mr: 1,
},
}}
>
PandaWiki
</Box>
<VersionMask
permission={PROFESSION_VERSION_PERMISSION}
sx={{ inset: '-8px 0' }}
>
<Controller
control={control}
name='show_brand_info'
@ -548,7 +551,6 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
<Switch
sx={{ marginLeft: 'auto' }}
{...field}
disabled={!isEnterprise}
checked={field?.value === false ? false : true}
onChange={e => {
field.onChange(e.target.checked);
@ -558,8 +560,8 @@ const FooterConfig = ({ data, setIsEdit, isEdit }: FooterConfigProps) => {
</Stack>
)}
/>
</Stack>
)}
</VersionMask>
</Stack>
</Stack>
</>
);

View File

@ -1,5 +1,6 @@
import { KnowledgeBaseListItem } from '@/api';
import { useURLSearchParams } from '@/hooks';
import { useFeatureValue } from '@/hooks';
import { ConstsUserRole } from '@/request/types';
import { useAppDispatch, useAppSelector } from '@/store';
import { setKbC, setKbId } from '@/store/slices/config';
@ -23,14 +24,14 @@ const KBSelect = () => {
const dispatch = useAppDispatch();
const [_, setSearchParams] = useURLSearchParams();
const { kb_id, kbList, license, user } = useAppSelector(
state => state.config,
);
const { kb_id, kbList, user } = useAppSelector(state => state.config);
const [modifyOpen, setModifyOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [opraData, setOpraData] = useState<KnowledgeBaseListItem | null>(null);
const wikiCount = useFeatureValue('wikiCount');
return (
<>
{(kbList || []).length > 0 && (
@ -121,8 +122,7 @@ const KBSelect = () => {
}}
fullWidth
disabled={
(license.edition === 0 && (kbList || []).length >= 1) ||
(license.edition === 1 && (kbList || []).length >= 3) ||
(kbList || []).length >= wikiCount ||
user.role === ConstsUserRole.UserRoleUser
}
onClick={event => {

View File

@ -3,27 +3,20 @@ import {
getApiV1License,
deleteApiV1License,
} from '@/request/pro/License';
import { PostApiV1LicensePayload } from '@/request/pro/types';
import HelpCenter from '@/assets/json/help-center.json';
import Takeoff from '@/assets/json/takeoff.json';
import error from '@/assets/json/error.json';
import IconUpgrade from '@/assets/json/upgrade.json';
import Upload from '@/components/UploadFile/Drag';
import { EditionType } from '@/constant/enums';
import { useVersionInfo } from '@/hooks';
import { useAppDispatch, useAppSelector } from '@/store';
import { setLicense } from '@/store/slices/config';
import {
Box,
Button,
IconButton,
MenuItem,
Stack,
TextField,
} from '@mui/material';
import { Box, Button, IconButton, Stack, TextField } from '@mui/material';
import { CusTabs, Icon, message, Modal } from '@ctzhian/ui';
import dayjs from 'dayjs';
import { useState } from 'react';
import LottieIcon from '../LottieIcon';
import { ConstsLicenseEdition } from '@/request/types';
interface AuthTypeModalProps {
open: boolean;
@ -42,10 +35,9 @@ const AuthTypeModal = ({
const { license } = useAppSelector(state => state.config);
const [selected, setSelected] = useState<'file' | 'code'>(
license.edition === 2 ? 'file' : 'code',
);
const [authVersion, setAuthVersion] = useState<'contributor' | 'enterprise'>(
license.edition === 2 ? 'enterprise' : 'contributor',
license.edition === ConstsLicenseEdition.LicenseEditionEnterprise
? 'file'
: 'code',
);
const [updateOpen, setUpdateOpen] = useState(false);
const [code, setCode] = useState('');
@ -53,16 +45,15 @@ const AuthTypeModal = ({
const [file, setFile] = useState<File | undefined>(undefined);
const [unbindLoading, setUnbindLoading] = useState(false);
const versionInfo = useVersionInfo();
const handleSubmit = () => {
const params: PostApiV1LicensePayload = {
license_edition: authVersion,
setLoading(true);
postApiV1License({
license_type: selected,
license_code: code,
license_file: file,
};
setLoading(true);
postApiV1License(params)
})
.then(() => {
message.success('激活成功');
setUpdateOpen(false);
@ -148,10 +139,8 @@ const AuthTypeModal = ({
<Stack direction={'row'} alignItems={'center'}>
<Box sx={{ width: 120, flexShrink: 0 }}></Box>
<Stack direction={'row'} alignItems={'center'} gap={2}>
<Box sx={{ minWidth: 50 }}>
{EditionType[license.edition as keyof typeof EditionType].text}
</Box>
{license.edition === 0 ? (
<Box sx={{ minWidth: 50 }}>{versionInfo.label}</Box>
{license.edition === ConstsLicenseEdition.LicenseEditionFree ? (
<Stack direction={'row'} gap={2}>
<Button
size='small'
@ -240,7 +229,7 @@ const AuthTypeModal = ({
)}
</Stack>
</Stack>
{license.edition! > 0 && (
{license.edition! !== ConstsLicenseEdition.LicenseEditionFree && (
<Box>
<Stack direction={'row'} alignItems={'center'}>
<Box sx={{ width: 120, flexShrink: 0 }}></Box>
@ -288,18 +277,6 @@ const AuthTypeModal = ({
value={selected}
change={(v: string) => setSelected(v as 'file' | 'code')}
/>
<TextField
select
fullWidth
sx={{ mt: 2 }}
value={authVersion}
onChange={e =>
setAuthVersion(e.target.value as 'contributor' | 'enterprise')
}
>
<MenuItem value='contributor'></MenuItem>
<MenuItem value='enterprise'></MenuItem>
</TextField>
{selected === 'code' && (
<TextField
sx={{ mt: 2 }}

View File

@ -1,24 +1,14 @@
import HelpCenter from '@/assets/json/help-center.json';
import IconUpgrade from '@/assets/json/upgrade.json';
import LottieIcon from '@/components/LottieIcon';
import { EditionType } from '@/constant/enums';
import { useAppSelector } from '@/store';
import { Box, Stack, Tooltip } from '@mui/material';
import { useEffect, useState } from 'react';
import packageJson from '../../../package.json';
import AuthTypeModal from './AuthTypeModal';
import freeVersion from '@/assets/images/free-version.png';
import enterpriseVersion from '@/assets/images/enterprise-version.png';
import contributorVersion from '@/assets/images/contributor-version.png';
const versionMap = {
0: freeVersion,
1: contributorVersion,
2: enterpriseVersion,
};
import { useVersionInfo } from '@/hooks';
const Version = () => {
const { license } = useAppSelector(state => state.config);
const versionInfo = useVersionInfo();
const curVersion = import.meta.env.VITE_APP_VERSION || packageJson.version;
const [latestVersion, setLatestVersion] = useState<string | undefined>(
undefined,
@ -57,11 +47,8 @@ const Version = () => {
>
<Stack direction={'row'} alignItems='center' gap={0.5}>
<Box sx={{ width: 30, color: 'text.tertiary' }}></Box>
<img
src={versionMap[license.edition!]}
style={{ height: 13, marginTop: -1 }}
/>
{EditionType[license.edition as keyof typeof EditionType].text}
<img src={versionInfo.image} style={{ height: 13, marginTop: -1 }} />
{versionInfo.label}
</Stack>
<Stack direction={'row'} gap={0.5}>
<Box sx={{ width: 30, color: 'text.tertiary' }}></Box>

View File

@ -9,7 +9,8 @@ import { Modal, message } from '@ctzhian/ui';
import { useState, useMemo, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useAppSelector } from '@/store';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
import { ConstsUserKBPermission, V1KBUserInviteReq } from '@/request/types';
import { ConstsLicenseEdition } from '@/request/pro/types';
@ -26,9 +27,13 @@ const VERSION_MAP = {
message: '开源版只支持 1 个管理员',
max: 1,
},
[ConstsLicenseEdition.LicenseEditionContributor]: {
message: '联创版最多支持 3 个管理员',
max: 3,
[ConstsLicenseEdition.LicenseEditionProfession]: {
message: '专业版最多支持 20 个管理员',
max: 20,
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
message: '商业版最多支持 50 个管理员',
max: 50,
},
};
@ -45,9 +50,6 @@ const MemberAdd = ({
const { kbList, license, refreshAdminRequest } = useAppSelector(
state => state.config,
);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const {
control,
@ -118,6 +120,10 @@ const MemberAdd = ({
});
});
const isPro = useMemo(() => {
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
return (
<>
<Button
@ -253,6 +259,14 @@ const MemberAdd = ({
fullWidth
displayEmpty
sx={{ height: 52 }}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: '1 !important',
color: 'text.disabled',
},
},
}}
renderValue={(value: V1KBUserInviteReq['perm']) => {
return value ? (
PERM_MAP[value]
@ -266,17 +280,25 @@ const MemberAdd = ({
>
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage}
disabled={!isPro}
>
{isEnterprise ? '' : '(企业版可用)'}
{' '}
<VersionCanUse
permission={PROFESSION_VERSION_PERMISSION}
/>
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate}
disabled={!isPro}
>
{isEnterprise ? '' : '(企业版可用)'}
{' '}
<VersionCanUse
permission={PROFESSION_VERSION_PERMISSION}
/>
</MenuItem>
</Select>
)}

View File

@ -0,0 +1,116 @@
import { styled } from '@mui/material';
import { useVersionInfo } from '@/hooks';
import { VersionInfoMap } from '@/constant/version';
import { ConstsLicenseEdition } from '@/request/types';
import { SxProps } from '@mui/material';
import React from 'react';
const StyledMaskWrapper = styled('div')(({ theme }) => ({
position: 'relative',
width: '100%',
height: '100%',
}));
const StyledMask = styled('div')(({ theme }) => ({
position: 'absolute',
inset: -8,
zIndex: 99,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flex: 1,
borderRadius: '10px',
border: `1px solid ${theme.palette.divider}`,
background: 'rgba(241,242,248,0.8)',
backdropFilter: 'blur(0.5px)',
}));
const StyledMaskContent = styled('div')(({ theme }) => ({
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}));
const StyledMaskVersion = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(0.5),
padding: theme.spacing(0.5, 1),
backgroundColor: theme.palette.background.paper3,
borderRadius: '10px',
fontSize: 12,
lineHeight: 1,
color: theme.palette.light.main,
}));
const VersionMask = ({
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
children,
sx,
}: {
permission?: ConstsLicenseEdition[];
children?: React.ReactNode;
sx?: SxProps;
}) => {
const versionInfo = useVersionInfo();
const hasPermission = permission.includes(versionInfo.permission);
if (hasPermission) return children;
const nextVersionInfo = VersionInfoMap[permission[0]];
return (
<StyledMaskWrapper>
{children}
<StyledMask sx={sx}>
<StyledMaskContent>
<StyledMaskVersion sx={{ backgroundColor: nextVersionInfo.bgColor }}>
<img
src={nextVersionInfo.image}
style={{ width: 12, objectFit: 'contain', marginTop: 1 }}
alt={nextVersionInfo.label}
/>
{nextVersionInfo?.label}
</StyledMaskVersion>
</StyledMaskContent>
</StyledMask>
</StyledMaskWrapper>
);
};
export const VersionCanUse = ({
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
sx,
}: {
permission?: ConstsLicenseEdition[];
sx?: SxProps;
}) => {
const versionInfo = useVersionInfo();
const hasPermission = permission.includes(versionInfo.permission);
if (hasPermission) return null;
const nextVersionInfo = VersionInfoMap[permission[0]];
return (
<StyledMaskContent sx={{ width: 'auto', ml: 1, ...sx }}>
<StyledMaskVersion sx={{ backgroundColor: nextVersionInfo.bgColor }}>
<img
src={nextVersionInfo.image}
style={{ width: 12, objectFit: 'contain', marginTop: 1 }}
alt={nextVersionInfo.label}
/>
{nextVersionInfo?.label}
</StyledMaskVersion>
</StyledMaskContent>
);
};
export default VersionMask;

View File

@ -797,21 +797,6 @@ export const FeedbackType = {
3: '其他',
};
export const Free = 0;
export const Contributor = 1;
export const Enterprise = 2;
export const EditionType = {
[Free]: {
text: '开源版',
},
[Contributor]: {
text: '联创版',
},
[Enterprise]: {
text: '企业版',
},
};
export const DocWidth = {
full: {
label: '全屏',

View File

@ -0,0 +1,293 @@
import { ConstsLicenseEdition } from '@/request/types';
import freeVersion from '@/assets/images/free-version.png';
import proVersion from '@/assets/images/pro-version.png';
import businessVersion from '@/assets/images/business-version.png';
import enterpriseVersion from '@/assets/images/enterprise-version.png';
export const PROFESSION_VERSION_PERMISSION = [
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
];
export const BUSINESS_VERSION_PERMISSION = [
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
];
export const ENTERPRISE_VERSION_PERMISSION = [
ConstsLicenseEdition.LicenseEditionEnterprise,
];
export const VersionInfoMap = {
[ConstsLicenseEdition.LicenseEditionFree]: {
permission: ConstsLicenseEdition.LicenseEditionFree,
label: '开源版',
image: freeVersion,
bgColor: '#8E9DAC',
nextVersion: ConstsLicenseEdition.LicenseEditionProfession,
},
[ConstsLicenseEdition.LicenseEditionProfession]: {
permission: ConstsLicenseEdition.LicenseEditionProfession,
label: '专业版',
image: proVersion,
bgColor: '#0933BA',
nextVersion: ConstsLicenseEdition.LicenseEditionBusiness,
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
permission: ConstsLicenseEdition.LicenseEditionBusiness,
label: '商业版',
image: businessVersion,
bgColor: '#382A79',
nextVersion: ConstsLicenseEdition.LicenseEditionEnterprise,
},
[ConstsLicenseEdition.LicenseEditionEnterprise]: {
permission: ConstsLicenseEdition.LicenseEditionEnterprise,
label: '企业版',
image: enterpriseVersion,
bgColor: '#21222D',
nextVersion: undefined,
},
};
/**
*
*/
export enum FeatureStatus {
/** 不支持 */
NOT_SUPPORTED = 'not_supported',
/** 支持 */
SUPPORTED = 'supported',
/** 基础配置 */
BASIC = 'basic',
/** 高级配置 */
ADVANCED = 'advanced',
}
/**
*
*/
export interface VersionInfo {
/** 版本名称 */
label: string;
/** 功能特性 */
features: {
/** Wiki 站点数量 */
wikiCount: number;
/** 每个 Wiki 的文档数量 */
docCountPerWiki: number;
/** 管理员数量 */
adminCount: number;
/** 管理员分权控制 */
adminPermissionControl: FeatureStatus;
/** SEO 配置 */
seoConfig: FeatureStatus;
/** 多语言支持 */
multiLanguage: FeatureStatus;
/** 自定义版权信息 */
customCopyright: FeatureStatus;
/** 访问流量分析 */
trafficAnalysis: FeatureStatus;
/** 自定义 AI 提示词 */
customAIPrompt: FeatureStatus;
/** SSO 登录 */
ssoLogin: number;
/** 访客权限控制 */
visitorPermissionControl: FeatureStatus;
/** 页面水印 */
pageWatermark: FeatureStatus;
/** 内容不可复制 */
contentNoCopy: FeatureStatus;
/** 敏感内容过滤 */
sensitiveContentFilter: FeatureStatus;
/** 网页挂件机器人 */
webWidgetRobot: FeatureStatus;
/** 飞书问答机器人 */
feishuQARobot: FeatureStatus;
/** 钉钉问答机器人 */
dingtalkQARobot: FeatureStatus;
/** 企业微信问答机器人 */
wecomQARobot: FeatureStatus;
/** 企业微信客服机器人 */
wecomServiceRobot: FeatureStatus;
/** Discord 问答机器人 */
discordQARobot: FeatureStatus;
/** 文档历史版本管理 */
docVersionHistory: FeatureStatus;
/** API 调用 */
apiCall: FeatureStatus;
/** 项目源码 */
sourceCode: FeatureStatus;
};
}
/**
*
*/
export const VERSION_INFO: Record<ConstsLicenseEdition, VersionInfo> = {
[ConstsLicenseEdition.LicenseEditionFree]: {
label: '开源版',
features: {
wikiCount: 1,
docCountPerWiki: 300,
adminCount: 1,
adminPermissionControl: FeatureStatus.NOT_SUPPORTED,
seoConfig: FeatureStatus.BASIC,
multiLanguage: FeatureStatus.NOT_SUPPORTED,
customCopyright: FeatureStatus.NOT_SUPPORTED,
trafficAnalysis: FeatureStatus.BASIC,
customAIPrompt: FeatureStatus.NOT_SUPPORTED,
ssoLogin: 0,
visitorPermissionControl: FeatureStatus.NOT_SUPPORTED,
pageWatermark: FeatureStatus.NOT_SUPPORTED,
contentNoCopy: FeatureStatus.NOT_SUPPORTED,
sensitiveContentFilter: FeatureStatus.NOT_SUPPORTED,
webWidgetRobot: FeatureStatus.BASIC,
feishuQARobot: FeatureStatus.BASIC,
dingtalkQARobot: FeatureStatus.BASIC,
wecomQARobot: FeatureStatus.BASIC,
wecomServiceRobot: FeatureStatus.BASIC,
discordQARobot: FeatureStatus.BASIC,
docVersionHistory: FeatureStatus.NOT_SUPPORTED,
apiCall: FeatureStatus.NOT_SUPPORTED,
sourceCode: FeatureStatus.SUPPORTED,
},
},
[ConstsLicenseEdition.LicenseEditionProfession]: {
label: '专业版',
features: {
wikiCount: 10,
docCountPerWiki: 10000,
adminCount: 20,
adminPermissionControl: FeatureStatus.SUPPORTED,
seoConfig: FeatureStatus.ADVANCED,
multiLanguage: FeatureStatus.SUPPORTED,
customCopyright: FeatureStatus.SUPPORTED,
trafficAnalysis: FeatureStatus.ADVANCED,
customAIPrompt: FeatureStatus.SUPPORTED,
ssoLogin: 0,
visitorPermissionControl: FeatureStatus.NOT_SUPPORTED,
pageWatermark: FeatureStatus.NOT_SUPPORTED,
contentNoCopy: FeatureStatus.NOT_SUPPORTED,
sensitiveContentFilter: FeatureStatus.NOT_SUPPORTED,
webWidgetRobot: FeatureStatus.ADVANCED,
feishuQARobot: FeatureStatus.ADVANCED,
dingtalkQARobot: FeatureStatus.ADVANCED,
wecomQARobot: FeatureStatus.ADVANCED,
wecomServiceRobot: FeatureStatus.ADVANCED,
discordQARobot: FeatureStatus.ADVANCED,
docVersionHistory: FeatureStatus.NOT_SUPPORTED,
apiCall: FeatureStatus.NOT_SUPPORTED,
sourceCode: FeatureStatus.NOT_SUPPORTED,
},
},
[ConstsLicenseEdition.LicenseEditionBusiness]: {
label: '商业版',
features: {
wikiCount: 20,
docCountPerWiki: 10000,
adminCount: 50,
adminPermissionControl: FeatureStatus.SUPPORTED,
seoConfig: FeatureStatus.ADVANCED,
multiLanguage: FeatureStatus.SUPPORTED,
customCopyright: FeatureStatus.SUPPORTED,
trafficAnalysis: FeatureStatus.ADVANCED,
customAIPrompt: FeatureStatus.SUPPORTED,
ssoLogin: 2000,
visitorPermissionControl: FeatureStatus.SUPPORTED,
pageWatermark: FeatureStatus.SUPPORTED,
contentNoCopy: FeatureStatus.SUPPORTED,
sensitiveContentFilter: FeatureStatus.SUPPORTED,
webWidgetRobot: FeatureStatus.ADVANCED,
feishuQARobot: FeatureStatus.ADVANCED,
dingtalkQARobot: FeatureStatus.ADVANCED,
wecomQARobot: FeatureStatus.ADVANCED,
wecomServiceRobot: FeatureStatus.ADVANCED,
discordQARobot: FeatureStatus.ADVANCED,
docVersionHistory: FeatureStatus.SUPPORTED,
apiCall: FeatureStatus.SUPPORTED,
sourceCode: FeatureStatus.NOT_SUPPORTED,
},
},
[ConstsLicenseEdition.LicenseEditionEnterprise]: {
label: '企业版',
features: {
wikiCount: Infinity,
docCountPerWiki: Infinity,
adminCount: Infinity,
adminPermissionControl: FeatureStatus.SUPPORTED,
seoConfig: FeatureStatus.ADVANCED,
multiLanguage: FeatureStatus.SUPPORTED,
customCopyright: FeatureStatus.SUPPORTED,
trafficAnalysis: FeatureStatus.ADVANCED,
customAIPrompt: FeatureStatus.SUPPORTED,
ssoLogin: Infinity,
visitorPermissionControl: FeatureStatus.SUPPORTED,
pageWatermark: FeatureStatus.SUPPORTED,
contentNoCopy: FeatureStatus.SUPPORTED,
sensitiveContentFilter: FeatureStatus.SUPPORTED,
webWidgetRobot: FeatureStatus.ADVANCED,
feishuQARobot: FeatureStatus.ADVANCED,
dingtalkQARobot: FeatureStatus.ADVANCED,
wecomQARobot: FeatureStatus.ADVANCED,
wecomServiceRobot: FeatureStatus.ADVANCED,
discordQARobot: FeatureStatus.ADVANCED,
docVersionHistory: FeatureStatus.SUPPORTED,
apiCall: FeatureStatus.SUPPORTED,
sourceCode: FeatureStatus.SUPPORTED,
},
},
};
/**
*
*/
export const FEATURE_LABELS: Record<string, string> = {
wikiCount: 'Wiki 站点数量',
docCountPerWiki: '每个 Wiki 的文档数量',
adminCount: '管理员数量',
adminPermissionControl: '管理员分权控制',
seoConfig: 'SEO 配置',
multiLanguage: '多语言支持',
customCopyright: '自定义版权信息',
trafficAnalysis: '访问流量分析',
customAIPrompt: '自定义 AI 提示词',
ssoLogin: 'SSO 登录',
visitorPermissionControl: '访客权限控制',
pageWatermark: '页面水印',
contentNoCopy: '内容不可复制',
sensitiveContentFilter: '敏感内容过滤',
webWidgetRobot: '网页挂件机器人',
feishuQARobot: '飞书问答机器人',
dingtalkQARobot: '钉钉问答机器人',
wecomQARobot: '企业微信问答机器人',
wecomServiceRobot: '企业微信客服机器人',
discordQARobot: 'Discord 问答机器人',
docVersionHistory: '文档历史版本管理',
apiCall: 'API 调用',
sourceCode: '项目源码',
};
/**
*
*/
export const FEATURE_STATUS_LABELS: Record<FeatureStatus, string> = {
[FeatureStatus.NOT_SUPPORTED]: '不支持',
[FeatureStatus.SUPPORTED]: '支持',
[FeatureStatus.BASIC]: '基础配置',
[FeatureStatus.ADVANCED]: '高级配置',
};
/**
*
*/
export function getFeatureValue<K extends keyof VersionInfo['features']>(
edition: ConstsLicenseEdition,
key: K,
): VersionInfo['features'][K] {
return (
VERSION_INFO[edition] ||
VERSION_INFO[ConstsLicenseEdition.LicenseEditionFree]
).features[key];
}

View File

@ -1,3 +1,8 @@
export { useBindCaptcha } from './useBindCaptcha';
export { useCommitPendingInput } from './useCommitPendingInput';
export { useURLSearchParams } from './useURLSearchParams';
export {
useFeatureValue,
useFeatureValueSupported,
useVersionInfo,
} from './useVersionFeature';

View File

@ -0,0 +1,34 @@
import {
FeatureStatus,
VersionInfoMap,
VersionInfo,
getFeatureValue,
} from '@/constant/version';
import { ConstsLicenseEdition } from '@/request/types';
import { useAppSelector } from '@/store';
export const useFeatureValue = <K extends keyof VersionInfo['features']>(
key: K,
): VersionInfo['features'][K] => {
const { license } = useAppSelector(state => state.config);
return getFeatureValue(license.edition!, key);
};
export const useFeatureValueSupported = (
key: keyof VersionInfo['features'],
) => {
const { license } = useAppSelector(state => state.config);
return (
getFeatureValue(license.edition!, key) === FeatureStatus.SUPPORTED ||
getFeatureValue(license.edition!, key) === FeatureStatus.ADVANCED
);
};
export const useVersionInfo = () => {
const { license } = useAppSelector(state => state.config);
return (
VersionInfoMap[
license.edition ?? ConstsLicenseEdition.LicenseEditionFree
] || VersionInfoMap[ConstsLicenseEdition.LicenseEditionFree]
);
};

View File

@ -8,6 +8,8 @@ import { styled } from '@mui/material/styles';
import dayjs from 'dayjs';
import { useEffect, useState } from 'react';
import DocModal from './DocModal';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { useURLSearchParams } from '@/hooks';
import {
@ -46,7 +48,7 @@ const statusColorMap = {
} as const;
export default function ContributionPage() {
const { kb_id = '', kbDetail } = useAppSelector(state => state.config);
const { kb_id = '', license } = useAppSelector(state => state.config);
const [searchParams, setSearchParams] = useURLSearchParams();
const page = Number(searchParams.get('page') || '1');
const pageSize = Number(searchParams.get('page_size') || '20');
@ -283,111 +285,114 @@ export default function ContributionPage() {
};
useEffect(() => {
if (kb_id) getData();
if (kb_id && PROFESSION_VERSION_PERMISSION.includes(license.edition!))
getData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, pageSize, nodeNameParam, authNameParam, kb_id]);
}, [page, pageSize, nodeNameParam, authNameParam, kb_id, license.edition]);
return (
<Card>
<Stack
direction='row'
alignItems={'center'}
justifyContent={'space-between'}
sx={{ p: 2 }}
>
<StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}>
<TextField
fullWidth
size='small'
label='文档'
value={searchDoc}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ node_name: searchDoc || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ node_name: e.target.value, page: '1' });
}}
onChange={e => setSearchDoc(e.target.value)}
sx={{ width: 200 }}
/>
<TextField
fullWidth
size='small'
label='用户'
value={searchUser}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ auth_name: searchUser || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ auth_name: e.target.value, page: '1' });
}}
onChange={e => setSearchUser(e.target.value)}
sx={{ width: 200 }}
/>
</StyledSearchRow>
</Stack>
<Table
columns={columns}
dataSource={data}
rowKey='id'
height='calc(100vh - 148px)'
size='small'
sx={{
overflow: 'hidden',
...tableSx,
'.MuiTableContainer-root': {
height: 'calc(100vh - 148px - 70px)',
},
}}
pagination={{
total,
page,
pageSize,
onChange: (page, pageSize) => {
setSearchParams({
page: String(page),
page_size: String(pageSize),
});
},
}}
PaginationProps={{
sx: {
borderTop: '1px solid',
borderColor: 'divider',
p: 2,
'.MuiSelect-root': {
width: 100,
<VersionMask permission={PROFESSION_VERSION_PERMISSION}>
<Stack
direction='row'
alignItems={'center'}
justifyContent={'space-between'}
sx={{ p: 2 }}
>
<StyledSearchRow direction='row' sx={{ p: 0, flex: 1 }}>
<TextField
fullWidth
size='small'
label='文档'
value={searchDoc}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ node_name: searchDoc || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ node_name: e.target.value, page: '1' });
}}
onChange={e => setSearchDoc(e.target.value)}
sx={{ width: 200 }}
/>
<TextField
fullWidth
size='small'
label='用户'
value={searchUser}
onKeyUp={e => {
if (e.key === 'Enter') {
setSearchParams({ auth_name: searchUser || '', page: '1' });
}
}}
onBlur={e => {
setSearchParams({ auth_name: e.target.value, page: '1' });
}}
onChange={e => setSearchUser(e.target.value)}
sx={{ width: 200 }}
/>
</StyledSearchRow>
</Stack>
<Table
columns={columns}
dataSource={data}
rowKey='id'
height='calc(100vh - 148px)'
size='small'
sx={{
overflow: 'hidden',
...tableSx,
'.MuiTableContainer-root': {
height: 'calc(100vh - 148px - 70px)',
},
},
}}
/>
}}
pagination={{
total,
page,
pageSize,
onChange: (page, pageSize) => {
setSearchParams({
page: String(page),
page_size: String(pageSize),
});
},
}}
PaginationProps={{
sx: {
borderTop: '1px solid',
borderColor: 'divider',
p: 2,
'.MuiSelect-root': {
width: 100,
},
},
}}
/>
{previewRow?.meta?.content_type === 'md' ? (
<MarkdownPreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
{previewRow?.meta?.content_type === 'md' ? (
<MarkdownPreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
) : (
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/>
) : (
<ContributePreviewModal
open={open}
row={previewRow}
onClose={closeDialog}
onAccept={handleAccept}
onReject={handleReject}
/>
)}
<DocModal
open={docModalOpen}
onClose={() => setDocModalOpen(false)}
onOk={handleDocModalOk}
/>
</VersionMask>
</Card>
);
}

View File

@ -2,10 +2,10 @@ import { ChatConversationPair } from '@/api';
import { getApiV1ConversationDetail } from '@/request/Conversation';
import { DomainConversationDetailResp } from '@/request/types';
import Avatar from '@/components/Avatar';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import Card from '@/components/Card';
import MarkDown from '@/components/MarkDown';
import { useAppSelector } from '@/store';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Accordion,
AccordionDetails,
@ -13,10 +13,169 @@ import {
Box,
Stack,
useTheme,
styled,
alpha,
Typography,
} from '@mui/material';
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
import { useEffect, useState } from 'react';
const handleThinkingContent = (content: string) => {
const thinkRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g;
const thinkMatches = [];
let match;
while ((match = thinkRegex.exec(content)) !== null) {
thinkMatches.push(match[1]);
}
let answerContent = content.replace(/<think>[\s\S]*?<\/think>/g, '');
answerContent = answerContent.replace(/<think>[\s\S]*$/, '');
return {
thinkingContent: thinkMatches.join(''),
answerContent: answerContent,
};
};
export const StyledConversationItem = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}));
// 聊天气泡相关组件
export const StyledUserBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-end',
maxWidth: '75%',
padding: theme.spacing(1, 2),
borderRadius: '10px 10px 0px 10px',
backgroundColor: theme.palette.primary.main,
color: theme.palette.primary.contrastText,
fontSize: 14,
wordBreak: 'break-word',
}));
export const StyledAiBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-start',
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: theme.spacing(3),
}));
export const StyledAiBubbleContent = styled(Box)(() => ({
wordBreak: 'break-word',
}));
// 对话相关组件
export const StyledAccordion = styled(Accordion)(() => ({
padding: 0,
border: 'none',
'&:before': {
content: '""',
height: 0,
},
background: 'transparent',
backgroundImage: 'none',
}));
export const StyledAccordionSummary = styled(AccordionSummary)(({ theme }) => ({
paddingLeft: theme.spacing(2),
paddingRight: theme.spacing(2),
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
userSelect: 'text',
borderRadius: '10px',
backgroundColor: theme.palette.background.paper3,
border: '1px solid',
borderColor: theme.palette.divider,
}));
export const StyledAccordionDetails = styled(AccordionDetails)(({ theme }) => ({
padding: theme.spacing(2),
borderTop: 'none',
}));
export const StyledQuestionText = styled(Box)(() => ({
fontWeight: '700',
fontSize: 16,
lineHeight: '24px',
wordBreak: 'break-all',
}));
// 搜索结果相关组件
export const StyledChunkAccordion = styled(Accordion)(({ theme }) => ({
backgroundImage: 'none',
background: 'transparent',
border: 'none',
padding: 0,
}));
export const StyledChunkAccordionSummary = styled(AccordionSummary)(
({ theme }) => ({
justifyContent: 'flex-start',
gap: theme.spacing(2),
'.MuiAccordionSummary-content': {
flexGrow: 0,
},
}),
);
export const StyledChunkAccordionDetails = styled(AccordionDetails)(
({ theme }) => ({
paddingTop: 0,
paddingLeft: theme.spacing(2),
borderTop: 'none',
borderLeft: '1px solid',
borderColor: theme.palette.divider,
}),
);
export const StyledChunkItem = styled(Box)(({ theme }) => ({
cursor: 'pointer',
'&:hover': {
'.hover-primary': {
color: theme.palette.primary.main,
},
},
}));
// 思考过程相关组件
export const StyledThinkingAccordion = styled(Accordion)(({ theme }) => ({
backgroundColor: 'transparent',
border: 'none',
padding: 0,
paddingBottom: theme.spacing(2),
'&:before': {
content: '""',
height: 0,
},
}));
export const StyledThinkingAccordionSummary = styled(AccordionSummary)(
({ theme }) => ({
justifyContent: 'flex-start',
gap: theme.spacing(2),
'.MuiAccordionSummary-content': {
flexGrow: 0,
},
}),
);
export const StyledThinkingAccordionDetails = styled(AccordionDetails)(
({ theme }) => ({
paddingTop: 0,
paddingLeft: theme.spacing(2),
borderTop: 'none',
borderLeft: '1px solid',
borderColor: theme.palette.divider,
'.markdown-body': {
opacity: 0.75,
fontSize: 12,
},
}),
);
const Detail = ({
id,
open,
@ -55,7 +214,11 @@ const Detail = ({
};
} else if (message.role === 'assistant') {
if (currentPair.user) {
currentPair.assistant = message.content;
const { thinkingContent, answerContent } = handleThinkingContent(
message.content || '',
);
currentPair.assistant = answerContent;
currentPair.thinking_content = thinkingContent;
currentPair.created_at = message.created_at;
// @ts-expect-error 类型不兼容
currentPair.info = message.info;
@ -167,26 +330,43 @@ const Detail = ({
<Stack gap={2}>
{conversations &&
conversations.map((item, index) => (
<Box key={index}>
<Accordion defaultExpanded={true}>
<AccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
sx={{
userSelect: 'text',
backgroundColor: 'background.paper3',
fontSize: '18px',
fontWeight: 'bold',
}}
>
{item.user}
</AccordionSummary>
<AccordionDetails>
<MarkDown
content={item.assistant || '未查询到回答内容'}
/>
</AccordionDetails>
</Accordion>
</Box>
<StyledConversationItem key={index}>
{/* 用户问题气泡 - 右对齐 */}
<StyledUserBubble>{item.user}</StyledUserBubble>
{/* AI回答气泡 - 左对齐 */}
<StyledAiBubble>
{/* 思考过程 */}
{!!item.thinking_content && (
<StyledThinkingAccordion defaultExpanded>
<StyledThinkingAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
<Stack direction='row' alignItems='center' gap={1}>
<Typography
variant='body2'
sx={theme => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.5),
})}
>
</Typography>
</Stack>
</StyledThinkingAccordionSummary>
<StyledThinkingAccordionDetails>
<MarkDown content={item.thinking_content || ''} />
</StyledThinkingAccordionDetails>
</StyledThinkingAccordion>
)}
{/* AI回答内容 */}
<StyledAiBubbleContent>
<MarkDown content={item.assistant} />
</StyledAiBubbleContent>
</StyledAiBubble>
</StyledConversationItem>
))}
</Stack>
</Box>

View File

@ -31,6 +31,8 @@ import {
import dayjs from 'dayjs';
import { useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
interface DocPropertiesModalProps {
open: boolean;
@ -40,8 +42,6 @@ interface DocPropertiesModalProps {
data: DomainNodeListItemResp[];
}
const tips = '(企业版可用)';
const StyledText = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
fontSize: 16,
@ -53,7 +53,12 @@ const PER_OPTIONS = [
value: ConstsNodeAccessPerm.NodeAccessPermOpen,
},
{
label: '部分开放',
label: (
<Stack direction={'row'} alignItems={'center'}>
<span></span>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</Stack>
),
value: ConstsNodeAccessPerm.NodeAccessPermPartial,
},
{
@ -128,13 +133,13 @@ const DocPropertiesModal = ({
visitable: values.visitable as ConstsNodeAccessPerm,
visible: values.visible as ConstsNodeAccessPerm,
},
answerable_groups: isEnterprise
answerable_groups: isBusiness
? values.answerable_groups.map(item => item.id!)
: undefined,
visitable_groups: isEnterprise
visitable_groups: isBusiness
? values.visitable_groups.map(item => item.id!)
: undefined,
visible_groups: isEnterprise
visible_groups: isBusiness
? values.visible_groups.map(item => item.id!)
: undefined,
}),
@ -153,15 +158,15 @@ const DocPropertiesModal = ({
});
});
const isEnterprise = useMemo(() => {
return license.edition === 2;
const isBusiness = useMemo(() => {
return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
const tree = filterEmptyFolders(convertToTree(data));
useEffect(() => {
if (open && data) {
if (isEnterprise) {
if (isBusiness) {
getApiProV1AuthGroupList({
kb_id: kb_id!,
page: 1,
@ -206,7 +211,7 @@ const DocPropertiesModal = ({
);
});
}
}, [open, data, isEnterprise]);
}, [open, data, isBusiness]);
useEffect(() => {
if (!open) {
@ -302,22 +307,15 @@ const DocPropertiesModal = ({
name='answerable'
control={control}
render={({ field }) => (
<RadioGroup row {...field}>
<RadioGroup row {...field} sx={{ gap: 2 }}>
{PER_OPTIONS.map(option => (
<FormControlLabel
key={option.value}
value={option.value}
control={<Radio size='small' />}
label={
option.label +
(!isEnterprise &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
? tips
: '')
}
label={option.label}
disabled={
!isEnterprise &&
!isBusiness &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
}
@ -359,22 +357,15 @@ const DocPropertiesModal = ({
name='visitable'
control={control}
render={({ field }) => (
<RadioGroup row {...field}>
<RadioGroup row {...field} sx={{ gap: 2 }}>
{PER_OPTIONS.map(option => (
<FormControlLabel
key={option.value}
value={option.value}
control={<Radio size='small' />}
label={
option.label +
(!isEnterprise &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
? tips
: '')
}
label={option.label}
disabled={
!isEnterprise &&
!isBusiness &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
}
@ -416,22 +407,15 @@ const DocPropertiesModal = ({
name='visible'
control={control}
render={({ field }) => (
<RadioGroup row {...field}>
<RadioGroup row {...field} sx={{ gap: 2 }}>
{PER_OPTIONS.map(option => (
<FormControlLabel
key={option.value}
value={option.value}
control={<Radio size='small' />}
label={
option.label +
(!isEnterprise &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
? tips
: '')
}
label={option.label}
disabled={
!isEnterprise &&
!isBusiness &&
option.value ===
ConstsNodeAccessPerm.NodeAccessPermPartial
}

View File

@ -22,6 +22,8 @@ import { useNavigate, useOutletContext } from 'react-router-dom';
import { WrapContext } from '..';
import DocAddByCustomText from '../../component/DocAddByCustomText';
import DocDelete from '../../component/DocDelete';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
interface HeaderProps {
edit: boolean;
@ -52,8 +54,8 @@ const Header = ({
const [showSaveTip, setShowSaveTip] = useState(false);
const isEnterprise = useMemo(() => {
return license.edition === 2;
const isBusiness = useMemo(() => {
return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
const handlePublish = useCallback(() => {
@ -309,6 +311,7 @@ const Header = ({
// },
{
key: 'copy',
textSx: { flex: 1 },
label: <StyledMenuSelect></StyledMenuSelect>,
onClick: () => {
if (kb_id) {
@ -328,26 +331,22 @@ const Header = ({
},
{
key: 'version',
textSx: { flex: 1 },
label: (
<StyledMenuSelect disabled={!isEnterprise}>
{' '}
{!isEnterprise && (
<Tooltip title='企业版可用' placement='top' arrow>
<InfoIcon
sx={{ color: 'text.secondary', fontSize: 14 }}
/>
</Tooltip>
)}
<StyledMenuSelect disabled={!isBusiness}>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</StyledMenuSelect>
),
onClick: () => {
if (isEnterprise) {
if (isBusiness) {
navigate(`/doc/editor/history/${detail.id}`);
}
},
},
{
key: 'rename',
textSx: { flex: 1 },
label: <StyledMenuSelect></StyledMenuSelect>,
onClick: () => {
setRenameOpen(true);
@ -355,6 +354,7 @@ const Header = ({
},
{
key: 'delete',
textSx: { flex: 1 },
label: <StyledMenuSelect></StyledMenuSelect>,
onClick: () => {
setDelOpen(true);
@ -566,7 +566,7 @@ const StyledMenuSelect = styled('div')<{ disabled?: boolean }>(
padding: theme.spacing(0, 2),
lineHeight: '40px',
height: 40,
width: 106,
minWidth: 106,
borderRadius: '5px',
color: disabled ? theme.palette.text.secondary : theme.palette.text.primary,
cursor: disabled ? 'not-allowed' : 'pointer',

View File

@ -29,6 +29,7 @@ import Header from './Header';
import Summary from './Summary';
import Toc from './Toc';
import Toolbar from './Toolbar';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
interface WrapProps {
detail: V1NodeDetailResp;
@ -72,8 +73,8 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
emoji: defaultDetail.meta?.emoji || '',
});
const isEnterprise = useMemo(() => {
return license.edition === 2;
const isBusiness = useMemo(() => {
return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
const debouncedUpdateSummary = useCallback(
@ -383,7 +384,7 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
</Stack>
</Tooltip>
)}
<Tooltip arrow title={isEnterprise ? '查看历史版本' : ''}>
<Tooltip arrow title={isBusiness ? '查看历史版本' : ''}>
<Stack
direction={'row'}
alignItems={'center'}
@ -391,13 +392,13 @@ const Wrap = ({ detail: defaultDetail }: WrapProps) => {
sx={{
fontSize: 12,
color: 'text.tertiary',
cursor: isEnterprise ? 'pointer' : 'text',
cursor: isBusiness ? 'pointer' : 'text',
':hover': {
color: isEnterprise ? 'primary.main' : 'text.tertiary',
color: isBusiness ? 'primary.main' : 'text.tertiary',
},
}}
onClick={() => {
if (isEnterprise) {
if (isBusiness) {
navigate(`/doc/editor/history/${defaultDetail.id}`);
}
}}

View File

@ -25,6 +25,7 @@ import {
IconButton,
Stack,
useTheme,
ButtonBase,
} from '@mui/material';
import { useCallback, useEffect, useRef, useState } from 'react';
import VersionPublish from '../release/components/VersionPublish';
@ -418,15 +419,19 @@ const Content = () => {
>
{publish.unpublished} /
</Box>
<Button
size='small'
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
<ButtonBase
disableRipple
sx={{
fontSize: 12,
fontWeight: 400,
color: 'primary.main',
}}
onClick={() => {
setPublishOpen(true);
}}
>
</Button>
</ButtonBase>
</>
)}
{ragReStartCount > 0 && (
@ -441,15 +446,19 @@ const Content = () => {
>
{ragReStartCount}
</Box>
<Button
size='small'
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
<ButtonBase
disableRipple
sx={{
fontSize: 12,
fontWeight: 400,
color: 'primary.main',
}}
onClick={() => {
setRagOpen(true);
}}
>
</Button>
</ButtonBase>
</>
)}
</Stack>

View File

@ -28,6 +28,7 @@ import {
ButtonBase,
} from '@mui/material';
import { Ellipsis, Table, Modal, Icon, message } from '@ctzhian/ui';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import dayjs from 'dayjs';
import { useEffect, useState, useMemo } from 'react';
@ -162,8 +163,8 @@ const Comments = ({
useState<DomainWebAppCommentSettings | null>(null);
const isEnableReview = useMemo(() => {
return !!(license.edition === 1 || license.edition === 2);
}, [license]);
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
useEffect(() => {
setShowCommentsFilter(isEnableReview);

View File

@ -3,14 +3,18 @@ import { getApiV1ConversationMessageDetail } from '@/request';
import MarkDown from '@/components/MarkDown';
import { useAppSelector } from '@/store';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Accordion,
AccordionDetails,
AccordionSummary,
Box,
} from '@mui/material';
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
import { Box, Stack, Typography, alpha } from '@mui/material';
import { Ellipsis, Modal } from '@ctzhian/ui';
import { useEffect, useState } from 'react';
import {
StyledConversationItem,
StyledUserBubble,
StyledAiBubble,
StyledThinkingAccordion,
StyledThinkingAccordionSummary,
StyledThinkingAccordionDetails,
StyledAiBubbleContent,
} from '../conversation/Detail';
const Detail = ({
id,
@ -36,6 +40,7 @@ const Detail = ({
user: data.question,
assistant: res.content!,
created_at: res.created_at!,
thinking_content: '',
});
});
}
@ -62,24 +67,43 @@ const Detail = ({
>
<Box sx={{ fontSize: 14 }}>
<Box>
<Accordion defaultExpanded={true}>
<AccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
sx={{
userSelect: 'text',
backgroundColor: 'background.paper3',
fontSize: '18px',
fontWeight: 'bold',
}}
>
{conversations?.user}
</AccordionSummary>
<AccordionDetails>
<MarkDown
content={conversations?.assistant || '未查询到回答内容'}
/>
</AccordionDetails>
</Accordion>
<StyledConversationItem>
{/* 用户问题气泡 - 右对齐 */}
<StyledUserBubble>{conversations?.user}</StyledUserBubble>
{/* AI回答气泡 - 左对齐 */}
<StyledAiBubble>
{/* 思考过程 */}
{!!conversations?.thinking_content && (
<StyledThinkingAccordion defaultExpanded>
<StyledThinkingAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
<Stack direction='row' alignItems='center' gap={1}>
<Typography
variant='body2'
sx={theme => ({
fontSize: 12,
color: alpha(theme.palette.text.primary, 0.5),
})}
>
</Typography>
</Stack>
</StyledThinkingAccordionSummary>
<StyledThinkingAccordionDetails>
<MarkDown content={conversations?.thinking_content || ''} />
</StyledThinkingAccordionDetails>
</StyledThinkingAccordion>
)}
{/* AI回答内容 */}
<StyledAiBubbleContent>
<MarkDown content={conversations?.assistant || ''} />
</StyledAiBubbleContent>
</StyledAiBubble>
</StyledConversationItem>
</Box>
</Box>
</Modal>

View File

@ -14,6 +14,8 @@ import dayjs from 'dayjs';
import { ColumnType } from '@ctzhian/ui/dist/Table';
import { useEffect, useMemo, useState } from 'react';
import { useAppSelector } from '@/store';
import { VersionCanUse } from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
interface AddRoleProps {
open: boolean;
@ -23,7 +25,8 @@ interface AddRoleProps {
}
const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
const { kb_id, license } = useAppSelector(state => state.config);
const { kb_id } = useAppSelector(state => state.config);
const { license } = useAppSelector(state => state.config);
const [list, setList] = useState<V1UserListItemResp[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRowKeys, setSelectedRowKeys] = useState<string>('');
@ -31,10 +34,6 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
ConstsUserKBPermission.UserKBPermissionFullControl,
);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const columns: ColumnType<V1UserListItemResp>[] = [
{
title: '',
@ -119,6 +118,10 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
}
}, [open]);
const isPro = useMemo(() => {
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
return (
<Modal
title='添加 Wiki 站管理员'
@ -209,22 +212,33 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
fullWidth
sx={{ height: 52 }}
value={perm}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: '1 !important',
color: 'text.disabled',
},
},
}}
onChange={e => setPerm(e.target.value as V1KBUserInviteReq['perm'])}
>
<MenuItem value={ConstsUserKBPermission.UserKBPermissionFullControl}>
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage}
disabled={!isPro}
>
{isEnterprise ? '' : '(企业版可用)'}
{' '}
<VersionCanUse permission={PROFESSION_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate}
disabled={!isPro}
>
{isEnterprise ? '' : '(企业版可用)'}
{' '}
<VersionCanUse permission={PROFESSION_VERSION_PERMISSION} />
</MenuItem>
</Select>
</FormItem>

View File

@ -1,5 +1,6 @@
import { getApiProV1Prompt, postApiProV1Prompt } from '@/request/pro/Prompt';
import { DomainKnowledgeBaseDetail } from '@/request/types';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import { useAppSelector } from '@/store';
import { message } from '@ctzhian/ui';
import { Box, Slider, TextField } from '@mui/material';
@ -33,11 +34,12 @@ const CardAI = ({ kb }: CardAIProps) => {
});
const isPro = useMemo(() => {
return license.edition === 1 || license.edition === 2;
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
useEffect(() => {
if (!kb.id || !isPro) return;
if (!kb.id || !PROFESSION_VERSION_PERMISSION.includes(license.edition!))
return;
getApiProV1Prompt({ kb_id: kb.id! }).then(res => {
setValue('content', res.content || '');
});
@ -54,7 +56,7 @@ const CardAI = ({ kb }: CardAIProps) => {
<SettingCardItem title='智能问答' isEdit={isEdit} onSubmit={onSubmit}>
<FormItem
vertical
tooltip={!isPro && '联创版和企业版可用'}
permission={PROFESSION_VERSION_PERMISSION}
extra={
<Box
sx={{

View File

@ -29,6 +29,8 @@ import { ColumnType } from '@ctzhian/ui/dist/Table';
import { useEffect, useMemo, useState, useRef } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useAppSelector } from '@/store';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { VersionCanUse } from '@/components/VersionMask';
import { SettingCardItem, FormItem } from './Common';
interface CardAuthProps {
@ -114,7 +116,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
}),
value.enabled === '2' &&
source_type !== EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword
? isPro
? isBusiness
? postApiProV1AuthSet({
kb_id,
source_type: value.source_type as ConstsSourceType,
@ -157,25 +159,18 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
});
});
const isPro = useMemo(() => {
return license.edition === 1 || license.edition === 2;
}, [license]);
const isEnterprise = useMemo(() => {
return license.edition === 2;
const isBusiness = useMemo(() => {
return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
useEffect(() => {
const source_type = isPro
const source_type = isBusiness
? kb.access_settings?.source_type ||
EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword
: kb.access_settings?.source_type ===
EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub
? EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub
: EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword;
: EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword;
setValue('source_type', source_type);
sourceTypeRef.current = source_type;
}, [kb, isPro]);
}, [kb, isBusiness]);
useEffect(() => {
if (kb.access_settings?.simple_auth) {
@ -191,7 +186,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
}, [kb]);
const getAuth = () => {
if (isPro) {
if (isBusiness) {
getApiProV1AuthGet({
kb_id,
source_type: source_type as ConstsSourceType,
@ -236,7 +231,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
useEffect(() => {
if (!kb_id || enabled !== '2') return;
getAuth();
}, [kb_id, isPro, source_type, enabled]);
}, [kb_id, isBusiness, source_type, enabled]);
const columns: ColumnType<GithubComChaitinPandaWikiProApiAuthV1AuthItem>[] = [
{
@ -875,8 +870,18 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
field.onChange(e.target.value);
setIsEdit(true);
}}
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: '1 !important',
color: 'text.disabled',
},
},
}}
fullWidth
sx={{ height: 52 }}
sx={{
height: 52,
}}
>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypePassword}
@ -885,44 +890,52 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeDingTalk}
disabled={!isPro}
disabled={!isBusiness}
>
{isPro ? '' : tips}
{' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeFeishu}
disabled={!isPro}
disabled={!isBusiness}
>
{isPro ? '' : tips}
{' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeWeCom}
disabled={!isPro}
disabled={!isBusiness}
>
{isPro ? '' : tips}
{' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeOAuth}
disabled={!isPro}
disabled={!isBusiness}
>
OAuth {isPro ? '' : tips}
OAuth {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeCAS}
disabled={!isPro}
disabled={!isBusiness}
>
CAS {isPro ? '' : tips}
CAS {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeLDAP}
disabled={!isPro}
disabled={!isBusiness}
>
LDAP {isPro ? '' : tips}
LDAP {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
<MenuItem
value={EXTEND_CONSTS_SOURCE_TYPE.SourceTypeGitHub}
disabled={!isBusiness}
>
GitHub
GitHub {' '}
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</MenuItem>
</Select>
)}

View File

@ -3,7 +3,7 @@ import {
DomainKnowledgeBaseDetail,
} from '@/request/types';
import { useAppSelector } from '@/store';
import InfoIcon from '@mui/icons-material/Info';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
import {
Box,
Chip,
@ -12,7 +12,6 @@ import {
RadioGroup,
styled,
TextField,
Tooltip,
} from '@mui/material';
import { getApiV1AppDetail, putApiV1App } from '@/request/App';
@ -37,7 +36,7 @@ const DocumentComments = ({
data: DomainAppDetailResp;
refresh: () => void;
}) => {
const { license, kb_id } = useAppSelector(state => state.config);
const { kb_id } = useAppSelector(state => state.config);
const [isEdit, setIsEdit] = useState(false);
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
@ -57,8 +56,6 @@ const DocumentComments = ({
);
}, [data]);
const isPro = license.edition === 1 || license.edition === 2;
const onSubmit = handleSubmit(formData => {
putApiV1App(
{ id: data.id! },
@ -108,7 +105,7 @@ const DocumentComments = ({
)}
/>
</FormItem>
<FormItem label='评论审核' tooltip={!isPro && '联创版和企业版可用'}>
<FormItem label='评论审核' permission={PROFESSION_VERSION_PERMISSION}>
<Controller
control={control}
name='moderation_enable'
@ -116,7 +113,6 @@ const DocumentComments = ({
<RadioGroup
row
{...field}
value={isPro ? field.value : undefined}
onChange={e => {
setIsEdit(true);
field.onChange(+e.target.value as 1 | 0);
@ -124,12 +120,12 @@ const DocumentComments = ({
>
<FormControlLabel
value={1}
control={<Radio size='small' disabled={!isPro} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={0}
control={<Radio size='small' disabled={!isPro} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
</RadioGroup>
@ -150,7 +146,7 @@ const AIQuestion = ({
refresh: () => void;
}) => {
const [isEdit, setIsEdit] = useState(false);
const { kb_id, license } = useAppSelector(state => state.config);
const { kb_id } = useAppSelector(state => state.config);
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
is_enabled: true,
@ -159,7 +155,6 @@ const AIQuestion = ({
},
});
const [inputValue, setInputValue] = useState('');
const isEnterprise = license.edition === 2;
const onSubmit = handleSubmit(formData => {
putApiV1App(
@ -273,7 +268,7 @@ const AIQuestion = ({
)}
/>{' '}
</FormItem>
<FormItem label='免责声明' tooltip={!isEnterprise && '企业版可用'}>
<FormItem label='免责声明' permission={PROFESSION_VERSION_PERMISSION}>
<Controller
control={control}
name='disclaimer'
@ -282,7 +277,6 @@ const AIQuestion = ({
{...field}
fullWidth
value={field.value || ''}
disabled={!isEnterprise}
placeholder='请输入免责声明'
onChange={e => {
setIsEdit(true);
@ -304,7 +298,7 @@ const DocumentContribution = ({
refresh: () => void;
}) => {
const [isEdit, setIsEdit] = useState(false);
const { license, kb_id } = useAppSelector(state => state.config);
const { kb_id } = useAppSelector(state => state.config);
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
is_enable: false,
@ -330,7 +324,6 @@ const DocumentContribution = ({
});
});
const isPro = license.edition === 1 || license.edition === 2;
useEffect(() => {
setValue(
'is_enable',
@ -340,21 +333,8 @@ const DocumentContribution = ({
}, [data]);
return (
<SettingCardItem
title={
<>
{!isPro && (
<Tooltip title='联创版和企业版可用' placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14 }} />
</Tooltip>
)}
</>
}
isEdit={isEdit}
onSubmit={onSubmit}
>
<FormItem label='文档贡献'>
<SettingCardItem title='文档贡献' isEdit={isEdit} onSubmit={onSubmit}>
<FormItem label='文档贡献' permission={PROFESSION_VERSION_PERMISSION}>
<Controller
control={control}
name='is_enable'
@ -362,7 +342,7 @@ const DocumentContribution = ({
<RadioGroup
row
{...field}
value={isPro ? field.value : undefined}
value={field.value}
onChange={e => {
setIsEdit(true);
field.onChange(e.target.value === 'true');
@ -370,12 +350,12 @@ const DocumentContribution = ({
>
<FormControlLabel
value={true}
control={<Radio size='small' disabled={!isPro} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={false}
control={<Radio size='small' disabled={!isPro} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
</RadioGroup>

View File

@ -38,6 +38,10 @@ import { Controller, useForm } from 'react-hook-form';
import { useDispatch } from 'react-redux';
import AddRole from './AddRole';
import { Form, FormItem, SettingCardItem } from './Common';
import {
PROFESSION_VERSION_PERMISSION,
BUSINESS_VERSION_PERMISSION,
} from '@/constant/version';
type ApiTokenPermission =
GithubComChaitinPandaWikiProApiTokenV1CreateAPITokenReq['permission'];
@ -69,8 +73,8 @@ const ApiToken = () => {
perm: ConstsUserKBPermission.UserKBPermissionFullControl,
},
});
const isEnterprise = useMemo(() => {
return license.edition === 2;
const isBusiness = useMemo(() => {
return BUSINESS_VERSION_PERMISSION.includes(license.edition!);
}, [license]);
const onDeleteApiToken = (id: string, name: string) => {
@ -131,9 +135,9 @@ const ApiToken = () => {
};
useEffect(() => {
if (!kb_id) return;
if (!kb_id || !isBusiness) return;
getApiTokenList();
}, [kb_id]);
}, [kb_id, isBusiness]);
useEffect(() => {
if (!addOpen) reset();
@ -142,27 +146,17 @@ const ApiToken = () => {
return (
<SettingCardItem
title='API Token'
permission={BUSINESS_VERSION_PERMISSION}
extra={
<Stack direction={'row'} alignItems={'center'}>
<Button
color='primary'
size='small'
disabled={!isEnterprise}
onClick={() => setAddOpen(true)}
sx={{ textTransform: 'none' }}
>
API Token
</Button>
<Tooltip title={'企业版可用'} placement='top' arrow>
<InfoIcon
sx={{
color: 'text.secondary',
fontSize: 14,
display: !isEnterprise ? 'block' : 'none',
}}
/>
</Tooltip>
</Stack>
}
>
@ -232,7 +226,7 @@ const ApiToken = () => {
size='small'
sx={{ width: 120 }}
value={it.permission}
disabled={!isEnterprise || user.role !== 'admin'}
disabled={!isBusiness || user.role !== 'admin'}
onChange={e =>
onUpdateApiToken(it.id!, e.target.value as ApiTokenPermission)
}
@ -259,7 +253,7 @@ const ApiToken = () => {
kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl
? '权限不足'
: '业版可用'
: '业版可用'
}
placement='top'
arrow
@ -270,7 +264,7 @@ const ApiToken = () => {
fontSize: 14,
ml: 1,
visibility:
!isEnterprise ||
!isBusiness ||
kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl
? 'visible'
@ -285,13 +279,13 @@ const ApiToken = () => {
type='icon-icon_tool_close'
sx={{
cursor:
!isEnterprise ||
!isBusiness ||
kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl
? 'not-allowed'
: 'pointer',
color:
!isEnterprise ||
!isBusiness ||
kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl
? 'text.disabled'
@ -299,7 +293,7 @@ const ApiToken = () => {
}}
onClick={() => {
if (
!isEnterprise ||
!isBusiness ||
kbDetail?.perm !==
ConstsUserKBPermission.UserKBPermissionFullControl
)
@ -367,17 +361,16 @@ const ApiToken = () => {
>
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDocManage}
>
{isEnterprise ? '' : '(企业版可用)'}
</MenuItem>
<MenuItem
disabled={!isEnterprise}
value={ConstsUserKBPermission.UserKBPermissionDataOperate}
>
{isEnterprise ? '' : '(企业版可用)'}
</MenuItem>
</Select>
);
@ -405,9 +398,9 @@ const CardKB = () => {
});
};
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const isPro = useMemo(() => {
return PROFESSION_VERSION_PERMISSION.includes(license.edition!);
}, [license.edition]);
useEffect(() => {
if (!kb_id) return;
@ -513,7 +506,7 @@ const CardKB = () => {
size='small'
sx={{ width: 180 }}
value={it.perms}
disabled={!isEnterprise || it.role === 'admin'}
disabled={!isPro || it.role === 'admin'}
onChange={e =>
onUpdateUserPermission(
it.id!,
@ -542,7 +535,7 @@ const CardKB = () => {
title={
it.role === 'admin'
? '超级管理员不可被修改权限'
: '业版可用'
: '业版可用'
}
placement='top'
arrow
@ -553,9 +546,7 @@ const CardKB = () => {
fontSize: 14,
ml: 1,
visibility:
!isEnterprise || it.role === 'admin'
? 'visible'
: 'hidden',
!isPro || it.role === 'admin' ? 'visible' : 'hidden',
}}
/>
</Tooltip>

View File

@ -9,8 +9,11 @@ import {
} from '@/request/types';
import { useAppSelector } from '@/store';
import { Icon, message } from '@ctzhian/ui';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import {
Box,
Button,
Collapse,
FormControlLabel,
Link,
Radio,
@ -31,6 +34,8 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
const [isEdit, setIsEdit] = useState(false);
const [isEnabled, setIsEnabled] = useState(false);
const [detail, setDetail] = useState<DomainAppDetailResp | null>(null);
const [widgetConfigOpen, setWidgetConfigOpen] = useState(false);
const [modalConfigOpen, setModalConfigOpen] = useState(false);
const { kb_id } = useAppSelector(state => state.config);
const {
control,
@ -43,8 +48,15 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
defaultValues: {
is_open: 0,
theme_mode: 'light',
btn_style: 'hover_ball',
btn_id: '',
btn_position: 'bottom_right',
disclaimer: '',
btn_text: '',
btn_logo: '',
modal_position: 'follow',
search_mode: 'all',
placeholder: '',
recommend_questions: [] as string[],
recommend_node_ids: [] as string[],
},
@ -54,6 +66,8 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
const recommend_questions = watch('recommend_questions') || [];
const recommend_node_ids = watch('recommend_node_ids') || [];
const btn_style = watch('btn_style') || 'hover_ball';
const isCustomButton = btn_style === 'btn_trigger';
const recommendQuestionsField = useCommitPendingInput<string>({
value: recommend_questions,
@ -87,8 +101,17 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
reset({
is_open: res.settings?.widget_bot_settings?.is_open ? 1 : 0,
theme_mode: res.settings?.widget_bot_settings?.theme_mode || 'light',
btn_style: res.settings?.widget_bot_settings?.btn_style || 'hover_ball',
btn_id: res.settings?.widget_bot_settings?.btn_id || '',
btn_position:
res.settings?.widget_bot_settings?.btn_position || 'bottom_right',
btn_text: res.settings?.widget_bot_settings?.btn_text || '在线客服',
btn_logo: res.settings?.widget_bot_settings?.btn_logo,
btn_logo: res.settings?.widget_bot_settings?.btn_logo || '',
modal_position:
res.settings?.widget_bot_settings?.modal_position || 'follow',
search_mode: res.settings?.widget_bot_settings?.search_mode || 'all',
placeholder: res.settings?.widget_bot_settings?.placeholder || '',
disclaimer: res.settings?.widget_bot_settings?.disclaimer || '',
recommend_questions:
res.settings?.widget_bot_settings?.recommend_questions || [],
recommend_node_ids:
@ -108,8 +131,15 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
widget_bot_settings: {
is_open: data.is_open === 1 ? true : false,
theme_mode: data.theme_mode as 'light' | 'dark',
btn_style: data.btn_style,
btn_id: data.btn_id,
btn_position: data.btn_position,
btn_text: data.btn_text,
btn_logo: data.btn_logo,
modal_position: data.modal_position,
search_mode: data.search_mode,
placeholder: data.placeholder,
disclaimer: data.disclaimer,
recommend_questions: data.recommend_questions || [],
recommend_node_ids: data.recommend_node_ids || [],
},
@ -151,146 +181,469 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
</Link>
}
>
<FormItem label='网页挂件机器人'>
<Controller
control={control}
name='is_open'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(+e.target.value as 1 | 0);
setIsEnabled((+e.target.value as 1 | 0) === 1);
setIsEdit(true);
}}
<Stack spacing={3}>
<FormItem label='网页挂件机器人'>
<Controller
control={control}
name='is_open'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(+e.target.value as 1 | 0);
setIsEnabled((+e.target.value as 1 | 0) === 1);
setIsEdit(true);
}}
>
<FormControlLabel
value={1}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value={0}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
{isEnabled && (
<>
<FormItem
label='嵌入代码'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<FormControlLabel
value={1}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value={0}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
{isEnabled && (
<>
<FormItem label='配色方案'>
<Controller
control={control}
name='theme_mode'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
{url ? (
<ShowText
noEllipsis
text={[
`<!--// Head 标签引入样式 -->`,
`<link rel="stylesheet" href="${url}/widget-bot.css">`,
`<!--// Body 标签引入挂件 -->`,
`<script src="${url}/widget-bot.js"></script>`,
]}
/>
) : (
<Stack
direction='row'
alignItems={'center'}
gap={0.5}
sx={{
color: 'warning.main',
fontSize: 14,
p: 1.5,
borderRadius: 1,
bgcolor: 'warning.light',
}}
>
<FormControlLabel
value={'light'}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value={'dark'}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
<Icon type='icon-jinggao' />
<Box component={'span'} sx={{ fontWeight: 500 }}>
</Box>{' '}
</Stack>
)}
/>
</FormItem>
<FormItem label='侧边按钮文字'>
<Controller
control={control}
name='btn_text'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='输入侧边按钮文字'
error={!!errors.btn_text}
helperText={errors.btn_text?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
<FormItem label='侧边按钮 Logo'>
<Controller
control={control}
name='btn_logo'
render={({ field }) => (
<UploadFile
{...field}
id='btn_logo'
type='url'
accept='image/*'
width={80}
onChange={url => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</FormItem>
<FormItem label='推荐问题'>
<FreeSoloAutocomplete
{...recommendQuestionsField}
placeholder='回车确认,填写下一个推荐问题'
/>
</FormItem>
<FormItem label='推荐文档'>
<RecommendDocDragList
ids={recommend_node_ids}
onChange={(value: string[]) => {
setIsEdit(true);
setValue('recommend_node_ids', value);
}}
/>
</FormItem>
<FormItem label='嵌入代码'>
{url ? (
<ShowText
noEllipsis
text={[
`<!--// Head 标签引入样式 -->`,
`<link rel="stylesheet" href="${url}/widget-bot.css">`,
'',
`<!--// Body 标签引入挂件 -->`,
`<script src="${url}/widget-bot.js"></script>`,
]}
/>
) : (
<Stack
direction='row'
alignItems={'center'}
gap={0.5}
sx={{ color: 'warning.main', fontSize: 14 }}
>
<Icon type='icon-jinggao' />
<Box component={'span'} sx={{ fontWeight: 500 }}>
</Box>{' '}
</Stack>
)}
</FormItem>
</>
)}
</FormItem>
<FormItem
label='挂件配置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Box>
{!widgetConfigOpen && (
<Button
size='small'
variant='outlined'
onClick={() => setWidgetConfigOpen(true)}
endIcon={<ExpandMoreIcon />}
>
</Button>
)}
<Collapse in={widgetConfigOpen}>
<Stack spacing={2.5}>
<FormItem
label='按钮样式'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_style'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
const value = e.target.value;
field.onChange(value);
if (value === 'btn_trigger') {
setValue('modal_position', 'fixed');
}
setIsEdit(true);
}}
>
<FormControlLabel
value='hover_ball'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='side_sticky'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='btn_trigger'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
{isCustomButton ? (
<FormItem
label='自定义按钮 ID'
required
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_id'
rules={{
required: '自定义按钮 ID 不能为空',
}}
render={({ field }) => (
<TextField
{...field}
fullWidth
placeholder='嵌入网站中自定义按钮的 #id 点击触发,如: pandawiki-widget-bot-btn'
error={!!errors.btn_id}
helperText={errors.btn_id?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
) : (
<>
<FormItem
label='按钮位置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_position'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
>
<FormControlLabel
value='top_left'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='top_right'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='bottom_left'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='bottom_right'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
)}
/>
</FormItem>
{btn_style !== 'hover_ball' && (
<FormItem
label='按钮文字'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_text'
render={({ field }) => (
<TextField
{...field}
fullWidth
placeholder='输入按钮文字'
error={!!errors.btn_text}
helperText={errors.btn_text?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
)}
<FormItem
label='按钮图标'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='btn_logo'
render={({ field }) => (
<UploadFile
{...field}
id='btn_logo'
type='url'
accept='image/*'
width={80}
onChange={url => {
field.onChange(url);
setIsEdit(true);
}}
/>
)}
/>
</FormItem>
</>
)}
</Stack>
</Collapse>
</Box>
</FormItem>
<FormItem
label='弹框配置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Box>
{!modalConfigOpen && (
<Button
size='small'
variant='outlined'
onClick={() => setModalConfigOpen(true)}
endIcon={<ExpandMoreIcon />}
>
</Button>
)}
<Collapse in={modalConfigOpen}>
<Stack spacing={2.5}>
{/* <FormItem label='' sx={{ alignItems: 'flex-start' }} labelSx={{ mt: 1 }}>
<Controller
control={control}
name='theme_mode'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
>
<FormControlLabel
value='light'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='dark'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='system'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='wiki'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}> WIKI </Box>}
/>
</RadioGroup>
)}
/>
</FormItem> */}
<FormItem
label='弹窗位置'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='modal_position'
render={({ field }) => {
const isDisabled = btn_style === 'btn_trigger';
return (
<RadioGroup
row
{...field}
value={isDisabled ? 'fixed' : field.value}
onChange={e => {
if (!isDisabled) {
field.onChange(e.target.value);
setIsEdit(true);
}
}}
>
<FormControlLabel
value='follow'
control={
<Radio size='small' disabled={isDisabled} />
}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='fixed'
control={
<Radio size='small' disabled={isDisabled} />
}
label={<Box sx={{ width: 100 }}></Box>}
/>
</RadioGroup>
);
}}
/>
</FormItem>
<FormItem
label='搜索模式'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='search_mode'
render={({ field }) => (
<RadioGroup
row
{...field}
onChange={e => {
field.onChange(e.target.value);
setIsEdit(true);
}}
>
<FormControlLabel
value='all'
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value='qa'
control={<Radio size='small' />}
label={
<Box sx={{ width: 100 }}></Box>
}
/>
<FormControlLabel
value='doc'
control={<Radio size='small' />}
label={
<Box sx={{ width: 100 }}></Box>
}
/>
</RadioGroup>
)}
/>
</FormItem>
<FormItem
label='搜索提示语'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='placeholder'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='输入搜索提示语'
error={!!errors.placeholder}
helperText={errors.placeholder?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
<FormItem
label='推荐问题'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<FreeSoloAutocomplete
{...recommendQuestionsField}
placeholder='回车确认,填写下一个推荐问题'
/>
</FormItem>
<FormItem
label='推荐文档'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<RecommendDocDragList
ids={recommend_node_ids}
onChange={(value: string[]) => {
setIsEdit(true);
setValue('recommend_node_ids', value);
}}
/>
</FormItem>
<FormItem
label='免责声明'
sx={{ alignItems: 'flex-start' }}
labelSx={{ mt: 1 }}
>
<Controller
control={control}
name='disclaimer'
render={({ field }) => (
<TextField
fullWidth
{...field}
placeholder='输入免责声明'
error={!!errors.disclaimer}
helperText={errors.disclaimer?.message}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
</Stack>
</Collapse>
</Box>
</FormItem>
</>
)}
</Stack>
</SettingCardItem>
);
};

View File

@ -1,7 +1,6 @@
import { DomainKnowledgeBaseDetail } from '@/request/types';
import {
Box,
Button,
FormControl,
FormControlLabel,
Link,
@ -13,10 +12,11 @@ import {
import ShowText from '@/components/ShowText';
import { getApiV1AppDetail, putApiV1App } from '@/request/App';
import { Controller, useForm } from 'react-hook-form';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { FormItem, SettingCardItem } from './Common';
import { DomainAppDetailResp } from '@/request/types';
import { message } from '@ctzhian/ui';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import { useAppSelector } from '@/store';
const CardRobotApi = ({
@ -29,11 +29,6 @@ const CardRobotApi = ({
const [isEdit, setIsEdit] = useState(false);
const [detail, setDetail] = useState<DomainAppDetailResp | null>(null);
const { license } = useAppSelector(state => state.config);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const {
control,
handleSubmit,
@ -114,10 +109,7 @@ const CardRobotApi = ({
}
onSubmit={onSubmit}
>
<FormItem
label='问答机器人 API'
tooltip={!isEnterprise ? '企业版可用' : undefined}
>
<FormItem label='问答机器人 API' permission={BUSINESS_VERSION_PERMISSION}>
<FormControl>
<Controller
control={control}
@ -133,13 +125,11 @@ const CardRobotApi = ({
<Stack direction={'row'}>
<FormControlLabel
value={true}
disabled={!isEnterprise}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
<FormControlLabel
value={false}
disabled={!isEnterprise}
control={<Radio size='small' />}
label={<Box sx={{ width: 100 }}></Box>}
/>
@ -150,7 +140,7 @@ const CardRobotApi = ({
</FormControl>
</FormItem>
{isEnabled && (
{isEnabled && BUSINESS_VERSION_PERMISSION.includes(license.edition!) && (
<>
<FormItem label='API Token' required>
<Controller

View File

@ -19,6 +19,8 @@ import {
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { FormItem, SettingCardItem } from './Common';
import VersionMask from '@/components/VersionMask';
import { PROFESSION_VERSION_PERMISSION } from '@/constant/version';
const CardRobotWecomService = ({
kb,
@ -262,38 +264,40 @@ const CardRobotWecomService = ({
<Icon type='icon-jinggao' sx={{ fontSize: 18 }} />
</Stack>
<FormItem
label={
<Box>
<Box component={'span'} sx={{ fontWeight: 600 }}>
<VersionMask permission={PROFESSION_VERSION_PERMISSION}>
<FormItem
label={
<Box>
<Box component={'span'} sx={{ fontWeight: 600 }}>
</Box>
</Box>
</Box>
}
>
<FreeSoloAutocomplete
placeholder='回车确认,填写下一个'
{...containKeywordsField}
/>
</FormItem>
<FormItem
label={
<Box>
<Box component={'span'} sx={{ fontWeight: 600 }}>
}
>
<FreeSoloAutocomplete
placeholder='回车确认,填写下一个'
{...containKeywordsField}
/>
</FormItem>
<FormItem
label={
<Box>
<Box component={'span'} sx={{ fontWeight: 600 }}>
</Box>
</Box>
</Box>
}
>
<FreeSoloAutocomplete
placeholder='回车确认,填写下一个'
{...equalKeywordsField}
/>
</FormItem>
}
>
<FreeSoloAutocomplete
placeholder='回车确认,填写下一个'
{...equalKeywordsField}
/>
</FormItem>
</VersionMask>
</>
)}
</SettingCardItem>

View File

@ -8,6 +8,7 @@ import {
} from '@/request/types';
import { useAppSelector } from '@/store';
import { message } from '@ctzhian/ui';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
import {
Autocomplete,
Box,
@ -17,7 +18,7 @@ import {
TextField,
styled,
} from '@mui/material';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { FormItem, SettingCardItem } from './Common';
@ -32,15 +33,9 @@ const WatermarkForm = ({
data?: DomainAppDetailResp;
refresh: () => void;
}) => {
const { license, kb_id } = useAppSelector(state => state.config);
const { kb_id } = useAppSelector(state => state.config);
const [watermarkIsEdit, setWatermarkIsEdit] = useState(false);
const {
control,
handleSubmit,
setValue,
watch,
formState: { errors },
} = useForm({
const { control, handleSubmit, setValue, watch } = useForm({
defaultValues: {
watermark_setting: data?.settings?.watermark_setting ?? null,
watermark_content: data?.settings?.watermark_content ?? '',
@ -48,9 +43,6 @@ const WatermarkForm = ({
});
const watermarkSetting = watch('watermark_setting');
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const handleSaveWatermark = handleSubmit(values => {
if (!data?.id || values.watermark_setting === null) return;
@ -82,8 +74,9 @@ const WatermarkForm = ({
title='水印'
isEdit={watermarkIsEdit}
onSubmit={handleSaveWatermark}
permission={BUSINESS_VERSION_PERMISSION}
>
<FormItem label='水印开关' tooltip={!isEnterprise && '企业版可用'}>
<FormItem label='水印开关'>
<Controller
control={control}
name='watermark_setting'
@ -98,18 +91,18 @@ const WatermarkForm = ({
>
<FormControlLabel
value={ConstsWatermarkSetting.WatermarkVisible}
control={<Radio size='small' disabled={!isEnterprise} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={ConstsWatermarkSetting.WatermarkHidden}
control={<Radio size='small' disabled={!isEnterprise} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={ConstsWatermarkSetting.WatermarkDisabled}
control={<Radio size='small' disabled={!isEnterprise} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
</RadioGroup>
@ -128,7 +121,6 @@ const WatermarkForm = ({
placeholder='请输入水印内容, 支持多行输入'
multiline
minRows={2}
disabled={!isEnterprise}
onChange={e => {
setWatermarkIsEdit(true);
field.onChange(e.target.value);
@ -146,9 +138,6 @@ const KeywordsForm = ({ kb }: { kb: DomainKnowledgeBaseDetail }) => {
const { license } = useAppSelector(state => state.config);
const [questionInputValue, setQuestionInputValue] = useState('');
const [isEdit, setIsEdit] = useState(false);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
@ -169,17 +158,18 @@ const KeywordsForm = ({ kb }: { kb: DomainKnowledgeBaseDetail }) => {
});
useEffect(() => {
if (!kb.id || !isEnterprise) return;
if (!kb.id || !BUSINESS_VERSION_PERMISSION.includes(license.edition!))
return;
getApiProV1Block({ kb_id: kb.id! }).then(res => {
setValue('block_words', res.words || []);
});
}, [kb, isEnterprise]);
}, [kb, license.edition]);
return (
<SettingCardItem title='内容合规' isEdit={isEdit} onSubmit={onSubmit}>
<FormItem
vertical
tooltip={!isEnterprise && '企业版可用'}
permission={BUSINESS_VERSION_PERMISSION}
label='屏蔽 AI 问答中的关键字'
>
<Controller
@ -193,7 +183,6 @@ const KeywordsForm = ({ kb }: { kb: DomainKnowledgeBaseDetail }) => {
inputValue={questionInputValue}
options={[]}
fullWidth
disabled={!isEnterprise}
onInputChange={(_, value) => {
setQuestionInputValue(value);
}}
@ -234,23 +223,14 @@ const CopyForm = ({
data?: DomainAppDetailResp;
refresh: () => void;
}) => {
const { license, kb_id } = useAppSelector(state => state.config);
const { kb_id } = useAppSelector(state => state.config);
const [isEdit, setIsEdit] = useState(false);
const {
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm({
const { control, handleSubmit, setValue } = useForm({
defaultValues: {
copy_setting: data?.settings?.copy_setting ?? null,
},
});
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const handleSaveWatermark = handleSubmit(values => {
if (!data?.id || values.copy_setting === null) return;
putApiV1App(
@ -280,7 +260,7 @@ const CopyForm = ({
isEdit={isEdit}
onSubmit={handleSaveWatermark}
>
<FormItem label='限制复制' tooltip={!isEnterprise && '企业版可用'}>
<FormItem label='限制复制' permission={BUSINESS_VERSION_PERMISSION}>
<Controller
control={control}
name='copy_setting'
@ -295,18 +275,18 @@ const CopyForm = ({
>
<FormControlLabel
value={ConstsCopySetting.CopySettingNone}
control={<Radio size='small' disabled={!isEnterprise} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={ConstsCopySetting.CopySettingAppend}
control={<Radio size='small' disabled={!isEnterprise} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
<FormControlLabel
value={ConstsCopySetting.CopySettingDisabled}
control={<Radio size='small' disabled={!isEnterprise} />}
control={<Radio size='small' />}
label={<StyledRadioLabel></StyledRadioLabel>}
/>
</RadioGroup>

View File

@ -26,7 +26,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
defaultValues: {
desc: '',
keyword: '',
auto_sitemap: false,
},
});
@ -44,7 +43,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
useEffect(() => {
setValue('desc', data.settings?.desc || '');
setValue('keyword', data.settings?.keyword || '');
setValue('auto_sitemap', data.settings?.auto_sitemap ?? false);
}, [data]);
return (
@ -88,25 +86,6 @@ const CardWebSEO = ({ data, id, refresh }: CardWebSEOProps) => {
)}
/>
</FormItem>
<FormItem label='自动生成 Sitemap'>
<Controller
control={control}
name='auto_sitemap'
render={({ field }) => (
<Checkbox
{...field}
checked={field.value}
size='small'
sx={{ p: 0, m: 0 }}
onChange={event => {
setIsEdit(true);
field.onChange(event);
}}
/>
)}
/>
</FormItem>
</SettingCardItem>
);
};

View File

@ -1,7 +1,9 @@
import Card from '@/components/Card';
import { ConstsLicenseEdition } from '@/request/types';
import InfoIcon from '@mui/icons-material/Info';
import { Button, Stack, styled, SxProps, Tooltip } from '@mui/material';
import { createContext, useContext } from 'react';
import VersionMask from '@/components/VersionMask';
const StyledForm = styled('form')<{ gap?: number | string }>(
({ theme, gap = 2 }) => ({
@ -40,6 +42,7 @@ const StyledFormLabel = styled('span')<{ required?: boolean }>(
export const StyledFormItem = styled('div')<{ vertical?: boolean }>(
({ theme, vertical }) => ({
position: 'relative',
display: 'flex',
alignItems: vertical ? 'flex-start' : 'center',
flexDirection: vertical ? 'column' : 'row',
@ -82,6 +85,7 @@ export const FormItem = ({
extra,
sx,
labelSx,
permission,
}: {
label?: string | React.ReactNode;
children?: React.ReactNode;
@ -92,31 +96,37 @@ export const FormItem = ({
extra?: React.ReactNode;
sx?: SxProps;
labelSx?: SxProps;
permission?: number[];
}) => {
const { vertical: verticalContext, labelWidth: labelWidthContext } =
useContext(FormContext);
return (
<StyledFormItem vertical={vertical || verticalContext} sx={sx}>
<StyledFormLabelWrapper
vertical={vertical || verticalContext}
labelWidth={labelWidth || labelWidthContext}
sx={labelSx}
>
<Stack direction='row' alignItems='center' flex={1}>
<StyledFormLabel required={required}>{label}</StyledFormLabel>
{tooltip && typeof tooltip === 'string' ? (
<Tooltip title={tooltip} placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14, ml: 1 }} />
</Tooltip>
) : (
tooltip
)}
</Stack>
{extra}
</StyledFormLabelWrapper>
{children}
</StyledFormItem>
return (
<VersionMask permission={permission}>
<StyledFormItem vertical={vertical || verticalContext} sx={sx}>
<StyledFormLabelWrapper
vertical={vertical || verticalContext}
labelWidth={labelWidth || labelWidthContext}
sx={labelSx}
>
<Stack direction='row' alignItems='center' flex={1}>
<StyledFormLabel required={required}>{label}</StyledFormLabel>
{tooltip && typeof tooltip === 'string' ? (
<Tooltip title={tooltip} placement='top' arrow>
<InfoIcon
sx={{ color: 'text.secondary', fontSize: 14, ml: 1 }}
/>
</Tooltip>
) : (
tooltip
)}
</Stack>
{extra}
</StyledFormLabelWrapper>
{children}
</StyledFormItem>
</VersionMask>
);
};
@ -142,6 +152,7 @@ export const SettingCard = ({
};
const StyledSettingCardItem = styled('div')(({ theme }) => ({
position: 'relative',
'&:not(:last-child)': {
borderBottom: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(4),
@ -204,6 +215,12 @@ export const SettingCardItem = ({
extra,
more,
sx,
permission = [
ConstsLicenseEdition.LicenseEditionFree,
ConstsLicenseEdition.LicenseEditionProfession,
ConstsLicenseEdition.LicenseEditionBusiness,
ConstsLicenseEdition.LicenseEditionEnterprise,
],
}: {
children?: React.ReactNode;
title?: React.ReactNode;
@ -212,6 +229,7 @@ export const SettingCardItem = ({
extra?: React.ReactNode;
more?: SettingCardItemMore;
sx?: SxProps;
permission?: number[];
}) => {
const renderMore = (more: SettingCardItemMore) => {
if (more && typeof more === 'object' && 'type' in more) {
@ -237,20 +255,23 @@ export const SettingCardItem = ({
return more;
}
};
return (
<StyledSettingCardItem sx={sx}>
<StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemTitle>
{title} {renderMore(more)}
</StyledSettingCardItemTitle>
{isEdit && (
<Button variant='contained' size='small' onClick={onSubmit}>
</Button>
)}
{extra}
</StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemContent>{children}</StyledSettingCardItemContent>
</StyledSettingCardItem>
<VersionMask permission={permission}>
<StyledSettingCardItem sx={sx}>
<StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemTitle>
{title} {renderMore(more)}
</StyledSettingCardItemTitle>
{isEdit && (
<Button variant='contained' size='small' onClick={onSubmit}>
</Button>
)}
{extra}
</StyledSettingCardItemTitleWrapper>
<StyledSettingCardItemContent>{children}</StyledSettingCardItemContent>
</StyledSettingCardItem>
</VersionMask>
);
};

View File

@ -1,9 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { SettingCardItem } from '../Common';
import { Tooltip } from '@mui/material';
import InfoIcon from '@mui/icons-material/Info';
import { Modal, message } from '@ctzhian/ui';
import { Stack, Button } from '@mui/material';
import { Box } from '@mui/material';
import { postApiProV1AuthGroupSync } from '@/request/pro/AuthOrg';
import {
@ -19,6 +16,7 @@ import {
deleteApiProV1AuthGroupDelete,
} from '@/request/pro/AuthGroup';
import GroupTree from './GroupTree';
import { BUSINESS_VERSION_PERMISSION } from '@/constant/version';
interface UserGroupProps {
enabled: string;
@ -45,10 +43,6 @@ const UserGroup = ({
GithubComChaitinPandaWikiProApiAuthV1AuthGroupTreeItem[]
>([]);
const isEnterprise = useMemo(() => {
return license.edition === 2;
}, [license]);
const onDeleteUserGroup = (id: number) => {
Modal.confirm({
title: '删除用户组',
@ -74,10 +68,15 @@ const UserGroup = ({
});
};
useEffect(() => {
if (!kb_id || enabled !== '2' || !isEnterprise) return;
if (
!kb_id ||
enabled !== '2' ||
!BUSINESS_VERSION_PERMISSION.includes(license.edition!)
)
return;
getUserGroup();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [kb_id, enabled, isEnterprise]);
}, [kb_id, enabled, license.edition!]);
const handleMove = async ({
id,
@ -123,32 +122,7 @@ const UserGroup = ({
};
return (
<SettingCardItem
title='用户组'
more={
!isEnterprise && (
<Tooltip title='企业版可用' placement='top' arrow>
<InfoIcon sx={{ color: 'text.secondary', fontSize: 14, ml: 1 }} />
</Tooltip>
)
}
// extra={
// isEnterprise &&
// [
// ConstsSourceType.SourceTypeWeCom,
// ConstsSourceType.SourceTypeDingTalk,
// ].includes(sourceType as ConstsSourceType) && (
// <Button
// color='primary'
// size='small'
// onClick={handleSync}
// loading={syncLoading}
// >
// 同步组织架构和成员
// </Button>
// )
// }
>
<SettingCardItem title='用户组' permission={BUSINESS_VERSION_PERMISSION}>
<Box
sx={{
border: '1px dashed',

View File

@ -9,6 +9,11 @@ import QAReferer from './QAReferer';
import RTVisitor from './RTVisitor';
import TypeCount from './TypeCount';
import { useAppSelector } from '@/store';
import { VersionCanUse } from '@/components/VersionMask';
import {
BUSINESS_VERSION_PERMISSION,
PROFESSION_VERSION_PERMISSION,
} from '@/constant/version';
export const TimeList = [
{ label: '近 24 小时', value: 1 },
@ -25,13 +30,40 @@ const Statistic = () => {
const isWideScreen = useMediaQuery('(min-width:1190px)');
const timeList = useMemo(() => {
const isPro = license.edition === 1 || license.edition === 2;
const isEnterprise = license.edition === 2;
const isPro = PROFESSION_VERSION_PERMISSION.includes(license.edition!);
const isBusiness = BUSINESS_VERSION_PERMISSION.includes(license.edition!);
return [
{ label: '近 24 小时', value: 1, disabled: false },
{ label: '近 7 天', value: 7, disabled: !isPro },
{ label: '近 30 天', value: 30, disabled: !isEnterprise },
{ label: '近 90 天', value: 90, disabled: !isEnterprise },
{
label: (
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<span> 7 </span>
<VersionCanUse permission={PROFESSION_VERSION_PERMISSION} />
</Stack>
),
value: 7,
disabled: !isPro,
},
{
label: (
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<span> 30 </span>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</Stack>
),
value: 30,
disabled: !isBusiness,
},
{
label: (
<Stack direction={'row'} alignItems={'center'} gap={0.5}>
<span> 90 </span>
<VersionCanUse permission={BUSINESS_VERSION_PERMISSION} />
</Stack>
),
value: 90,
disabled: !isBusiness,
},
];
}, [license]);

View File

@ -26,6 +26,8 @@ import {
GetApiV1NodeListParams,
GetApiV1NodeRecommendNodesParams,
V1NodeDetailResp,
V1NodeRestudyReq,
V1NodeRestudyResp,
} from "./types";
/**
@ -263,6 +265,38 @@ export const getApiV1NodeRecommendNodes = (
...params,
});
/**
* @description
*
* @tags Node
* @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,
});
/**
* @description Summary Node
*

View File

@ -1,46 +0,0 @@
/* 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,
});

View File

@ -10,7 +10,6 @@ export * from './Message'
export * from './Model'
export * from './Node'
export * from './NodePermission'
export * from './NodeRestudy'
export * from './Stat'
export * from './User'
export * from './types'

View File

@ -24,6 +24,7 @@ import {
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
@ -206,6 +207,32 @@ export const postShareProV1AuthLdap = (
...params,
});
/**
* @description
*
* @tags ShareAuth
* @name PostShareProV1AuthLogout
* @summary
* @request POST:/share/pro/v1/auth/logout
* @response `200` `(DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
})` OK
*/
export const postShareProV1AuthLogout = (params: RequestParams = {}) =>
httpRequest<
DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp;
}
>({
path: `/share/pro/v1/auth/logout`,
method: "POST",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description OAuth登录
*

View File

@ -52,10 +52,12 @@ export enum ConstsSourceType {
export enum ConstsLicenseEdition {
/** 开源版 */
LicenseEditionFree = 0,
/** 联创版 */
LicenseEditionContributor = 1,
/** 专业版 */
LicenseEditionProfession = 1,
/** 企业版 */
LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
}
export enum ConstsContributeType {
@ -455,6 +457,11 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
any
>;
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
string,
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
kb_id?: string;
redirect_url?: string;
@ -465,6 +472,7 @@ export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp {
}
export interface GithubComChaitinPandaWikiProApiShareV1AuthWecomReq {
is_app?: boolean;
kb_id?: string;
redirect_url?: string;
}
@ -668,8 +676,6 @@ export interface GetApiProV1TokenListParams {
}
export interface PostApiV1LicensePayload {
/** license edition */
license_edition: "contributor" | "enterprise";
/** license type */
license_type: "file" | "code";
/**

View File

@ -171,14 +171,21 @@ export enum ConstsNodeAccessPerm {
NodeAccessPermClosed = "closed",
}
export enum ConstsModelSettingMode {
ModelSettingModeManual = "manual",
ModelSettingModeAuto = "auto",
}
/** @format int32 */
export enum ConstsLicenseEdition {
/** 开源版 */
LicenseEditionFree = 0,
/** 联创版 */
LicenseEditionContributor = 1,
/** 专业版 */
LicenseEditionProfession = 1,
/** 企业版 */
LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
}
export enum ConstsHomePageSetting {
@ -927,8 +934,10 @@ export interface DomainModelModeSetting {
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
/** 手动模式下嵌入模型是否更新 */
is_manual_embedding_updated?: boolean;
/** 模式: manual 或 auto */
mode?: string;
mode?: ConstsModelSettingMode;
}
export interface DomainMoveNodeReq {
@ -1180,6 +1189,17 @@ export interface DomainShareConversationMessage {
role?: SchemaRoleType;
}
export interface DomainShareNodeListItemResp {
emoji?: string;
id?: string;
name?: string;
parent_id?: string;
permissions?: DomainNodePermissions;
position?: number;
type?: DomainNodeType;
updated_at?: string;
}
export interface DomainSimpleAuth {
enabled?: boolean;
password?: string;
@ -1357,11 +1377,18 @@ export interface DomainWecomAIBotSettings {
}
export interface DomainWidgetBotSettings {
btn_id?: string;
btn_logo?: string;
btn_position?: string;
btn_style?: string;
btn_text?: string;
disclaimer?: string;
is_open?: boolean;
modal_position?: string;
placeholder?: string;
recommend_node_ids?: string[];
recommend_questions?: string[];
search_mode?: string;
theme_mode?: string;
}
@ -1645,8 +1672,9 @@ export interface V1NodePermissionResp {
}
export interface V1NodeRestudyReq {
kb_id?: string;
node_ids?: string[];
kb_id: string;
/** @minItems 1 */
node_ids: string[];
}
export type V1NodeRestudyResp = Record<string, any>;
@ -1666,6 +1694,7 @@ export interface V1ShareNodeDetailResp {
editor_id?: string;
id?: string;
kb_id?: string;
list?: DomainShareNodeListItemResp[];
meta?: DomainNodeMeta;
name?: string;
parent_id?: string;

View File

@ -1,4 +1,4 @@
FROM node:20-alpine
FROM node:22-alpine
ENV NODE_ENV=production

View File

@ -72,7 +72,7 @@ export default isDevelopment
// Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
// This can increase your server load as well as your hosting bill.
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
// Note: Check that the configured route will not match with your Next.js proxy, otherwise reporting of client-
// side errors will fail.
tunnelRoute: '/monitoring',

View File

@ -3,7 +3,7 @@
"version": "2.9.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -p 3010",
"dev": "next dev -p 3010",
"build": "next build",
"start": "next start",
"lint": "next lint",
@ -16,7 +16,7 @@
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@emotion/cache": "^11.14.0",
"@mui/material-nextjs": "^7.1.0",
"@mui/material-nextjs": "^7.3.5",
"@sentry/nextjs": "^10.8.0",
"@types/markdown-it": "13.0.1",
"@vscode/markdown-it-katex": "^1.1.2",
@ -25,12 +25,13 @@
"highlight.js": "^11.11.1",
"html-react-parser": "^5.2.5",
"html-to-image": "^1.11.13",
"import-in-the-middle": "^1.4.0",
"js-cookie": "^3.0.5",
"katex": "^0.16.22",
"markdown-it": "13.0.1",
"markdown-it-highlightjs": "^4.2.0",
"mermaid": "^11.9.0",
"next": "15.4.6",
"next": "^16.0.0",
"react-device-detect": "^2.2.3",
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
@ -41,17 +42,23 @@
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
"require-in-the-middle": "^7.5.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@ctzhian/cx-swagger-api": "^1.0.0",
"@eslint/eslintrc": "^3",
"@next/eslint-plugin-next": "^15.4.5",
"@next/eslint-plugin-next": "^16.0.0",
"@types/js-cookie": "^3.0.6",
"@types/rangy": "^1.3.0",
"@types/react-syntax-highlighter": "^15.5.13",
"eslint-config-next": "15.3.2",
"eslint-config-next": "16.0.0",
"eslint-config-prettier": "^9.1.2"
},
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac",
"pnpm": {
"overrides": {
"require-in-the-middle": "^7.5.2"
}
}
}

View File

@ -1,35 +1,39 @@
/* 挂件按钮样式 - 基于MUI主题 */
/* 挂件按钮基础样式 */
.widget-bot-button {
position: fixed;
right: 0;
bottom: 190px;
z-index: 9999;
font-size: 14px;
cursor: pointer;
user-select: none;
transition: all 0.2s ease-in-out;
border-radius: 18px 0 0 18px;
color: #FFFFFF;
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17);
padding: 11px;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
border: none;
opacity: 0;
transform: translateY(20px);
/* 优化拖拽性能 */
will-change: transform;
backface-visibility: hidden;
-webkit-font-smoothing: antialiased;
}
.widget-bot-button:hover {
.widget-bot-button:hover:not(.dragging) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2);
}
.widget-bot-hover-ball:hover:not(.dragging) {
transform: scale(1.1) !important;
}
.widget-bot-button.dragging {
cursor: grabbing;
transform: rotate(2deg);
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25);
transition: none !important;
/* 拖拽时禁用过渡,提升性能 */
/* transform 由 JS 控制,包含 rotate 和 translate */
}
.widget-bot-button-content {
@ -39,14 +43,13 @@
color: inherit;
}
.widget-bot-logo {
width: 20px;
height: 20px;
margin-bottom: 8px;
/* 图标样式 */
.widget-bot-icon {
border-radius: 50%;
object-fit: cover;
}
/* 文字样式 */
.widget-bot-text {
font-size: 14px;
font-weight: 400;
@ -60,6 +63,47 @@
margin: 1px 0;
}
/* 侧边吸附按钮样式 */
.widget-bot-side-sticky {
width: 48px;
padding: 6px 6px 12px 6px;
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border-radius: 24px;
border: 1px solid #ECEEF1;
min-height: auto;
}
.widget-bot-side-sticky .widget-bot-icon {
width: 36px;
height: 36px;
margin-bottom: 4px;
}
.widget-bot-side-sticky .widget-bot-text {
font-size: 12px;
color: #646a73;
line-height: 16px;
}
/* 悬浮球按钮样式 */
.widget-bot-hover-ball {
width: 48px;
height: 48px;
border-radius: 24px;
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border: 1px solid #ECEEF1;
padding: 0;
min-height: auto;
}
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
width: 48px;
height: 48px;
margin-bottom: 0;
}
/* 模态框样式 - 基于MUI主题 */
.widget-bot-modal {
position: fixed;
@ -75,6 +119,11 @@
backdrop-filter: blur(4px);
}
.widget-bot-modal-fixed {
align-items: center;
justify-content: center;
}
.widget-bot-modal-content {
position: absolute;
width: 600px;
@ -88,6 +137,14 @@
animation: slideInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.widget-bot-modal-content-fixed {
position: relative;
width: 800px;
height: auto;
max-height: 90vh;
margin: auto;
}
@keyframes slideInUp {
from {
opacity: 0;
@ -100,34 +157,30 @@
}
}
/* 关闭按钮样式 - 基于MUI IconButton */
/* 关闭按钮样式 - 透明框 */
.widget-bot-close-btn {
position: absolute;
top: 12px;
right: 12px;
background: none;
width: 36px;
height: 36px;
top: 22.5px;
right: 16px;
background: transparent;
width: 36.26px;
height: 25px;
border: none;
border-radius: 50%;
border-radius: 0;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
opacity: 0.5;
z-index: 10001;
transition: all 0.1s ease-in-out;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
}
.widget-bot-close-btn:hover {
font-size: 0;
opacity: 1;
}
.widget-bot-close-btn:active {
transform: scale(0.95);
z-index: 10001;
transition: none;
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
padding: 0;
margin: 0;
pointer-events: none;
/* 允许鼠标穿透到下方 */
}
/* iframe样式 */
@ -140,6 +193,11 @@
background: #F8F9FA;
}
.widget-bot-modal-content-fixed .widget-bot-iframe {
min-height: 600px;
height: auto;
}
/* 防止页面滚动 */
body.widget-bot-modal-open {
overflow: hidden;
@ -147,19 +205,34 @@ body.widget-bot-modal-open {
/* 暗色主题支持 - 基于data-theme属性 */
.widget-bot-button[data-theme="dark"] {
background: #6E73FE;
box-shadow: 0 2px 4px rgba(110, 115, 254, 0.15);
}
.widget-bot-button[data-theme="dark"]:hover {
.widget-bot-side-sticky[data-theme="dark"] {
background: #6E73FE;
}
.widget-bot-side-sticky[data-theme="dark"]:hover {
background: #5d68fd;
box-shadow: 0 4px 8px rgba(110, 115, 254, 0.2);
}
.widget-bot-button[data-theme="dark"].dragging {
.widget-bot-side-sticky[data-theme="dark"].dragging {
box-shadow: 0 6px 12px rgba(110, 115, 254, 0.25);
}
.widget-bot-hover-ball[data-theme="dark"] {
background: #6E73FE;
}
.widget-bot-hover-ball[data-theme="dark"]:hover {
transform: scale(1.1) !important;
}
.widget-bot-hover-ball[data-theme="dark"].dragging {
box-shadow: 0 8px 20px rgba(110, 115, 254, 0.3);
}
.widget-bot-modal[data-theme="dark"] {
background: rgba(0, 0, 0, 0.7);
}
@ -169,61 +242,63 @@ body.widget-bot-modal-open {
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* 移动端适配 */
/* 移动端适配 - 统一处理 */
@media (max-width: 768px) {
.widget-bot-button {
bottom: 16px;
padding: 8px;
border-radius: 10px 0 0 10px;
.widget-bot-side-sticky {
width: 48px;
padding: 6px 6px 12px 6px;
border-radius: 24px;
}
.widget-bot-hover-ball {
width: 48px;
height: 48px;
border-radius: 24px;
padding: 0;
}
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
width: 48px;
height: 48px;
margin-bottom: 0;
}
.widget-bot-text {
font-size: 12px;
}
.widget-bot-logo {
.widget-bot-icon {
width: 16px;
height: 16px;
margin-bottom: 6px;
}
/* 移动端弹框统一居中显示宽度100%-32px高度90vh */
.widget-bot-modal-content {
width: calc(100% - 60.5px);
height: 90%;
max-width: none;
max-height: none;
position: relative !important;
width: calc(100% - 32px) !important;
height: 90vh !important;
max-width: none !important;
max-height: 90vh !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
margin: auto !important;
}
.widget-bot-modal-content-fixed {
width: calc(100% - 32px) !important;
height: 90vh !important;
max-height: 90vh !important;
}
.widget-bot-close-btn {
top: 8px;
right: 8px;
width: 32px;
height: 32px;
font-size: 16px;
}
}
/* 小屏幕适配 */
@media (max-width: 480px) {
.widget-bot-button {
bottom: 12px;
padding: 6px;
}
.widget-bot-text {
font-size: 11px;
}
.widget-bot-modal-content {
width: calc(100% - 55.5px);
height: 90%;
border-radius: 6px;
}
.widget-bot-close-btn {
width: 28px;
height: 28px;
font-size: 14px;
top: 22.5px;
right: 16px;
width: 36.26px;
height: 25px;
font-size: 0;
}
}
@ -274,19 +349,32 @@ body.widget-bot-modal-open {
}
/* 浅色主题样式 - 显式定义 */
.widget-bot-button[data-theme="light"] {
background: #3248F2;
color: #FFFFFF;
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17);
.widget-bot-side-sticky[data-theme="light"] {
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border: 1px solid #ECEEF1;
}
.widget-bot-button[data-theme="light"]:hover {
background: #2a3cdb;
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2);
.widget-bot-side-sticky[data-theme="light"]:hover {
background: #FFFFFF;
}
.widget-bot-button[data-theme="light"].dragging {
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25);
.widget-bot-side-sticky[data-theme="light"].dragging {
background: #FFFFFF;
}
.widget-bot-hover-ball[data-theme="light"] {
background: #FFFFFF;
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
border: 1px solid #ECEEF1;
}
.widget-bot-hover-ball[data-theme="light"]:hover {
transform: scale(1.1) !important;
}
.widget-bot-hover-ball[data-theme="light"].dragging {
box-shadow: 0 8px 20px rgba(50, 72, 242, 0.3);
}
.widget-bot-modal[data-theme="light"] {

View File

@ -1,6 +1,10 @@
(function () {
'use strict';
const defaultModalPosition = 'follow';
const defaultBtnPosition = 'bottom_left';
const defaultBtnStyle = 'side_sticky';
// 获取当前脚本的域名
const currentScript = document.currentScript || document.querySelector('script[src*="widget-bot.js"]');
const widgetDomain = currentScript ? new URL(currentScript.src).origin : window.location.origin;
@ -11,6 +15,13 @@
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
let currentTheme = 'light'; // 默认浅色主题
let customTriggerElement = null; // 自定义触发元素
let customTriggerHandler = null; // 自定义触发元素的事件处理函数
let dragAnimationFrame = null; // 拖拽动画帧ID
let buttonSize = { width: 0, height: 0 }; // 缓存按钮尺寸
let initialPosition = { left: 0, top: 0 }; // 拖拽开始时的初始位置
let hasDragged = false; // 标记是否发生了拖拽
let dragStartPos = { x: 0, y: 0 }; // 拖拽开始时的鼠标位置
// 应用主题
function applyTheme(theme_mode) {
@ -60,13 +71,22 @@
applyTheme(widgetInfo.theme_mode);
}
createWidget();
// 根据 btn_style 创建不同的挂件
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
if (btnStyle === 'btn_trigger') {
createCustomTrigger();
} else {
createWidget();
}
} catch (error) {
console.error('获取挂件信息失败:', error);
// 使用默认值
widgetInfo = {
btn_text: '在线客服',
btn_logo: '',
btn_logo: `''`,
btn_style: defaultBtnStyle,
btn_position: defaultBtnPosition,
modal_position: defaultModalPosition,
theme_mode: 'light'
};
applyTheme(widgetInfo.theme_mode);
@ -78,53 +98,92 @@
}
}
// 创建垂直文字
function createVerticalText(text) {
return text.split('').map((char, index) =>
`<span>${char}</span>`
).join('');
// 创建两行文字(每行两个字)
function createTwoLineText(text) {
const chars = text.split('').filter(it => !!it.trim());
const lines = [];
for (let i = 0; i < chars.length; i += 2) {
lines.push(chars.slice(i, i + 2).join(''));
}
return lines.map(line => `<span>${line}</span>`).join('');
}
// 创建挂件按钮
function createWidget() {
// 如果已存在,先删除
if (widgetButton) {
widgetButton.remove();
}
// 应用按钮位置
function applyButtonPosition(button, position) {
const pos = position || defaultBtnPosition;
button.style.top = 'auto';
button.style.right = 'auto';
button.style.bottom = 'auto';
button.style.left = 'auto';
// 创建按钮容器
// 两种模式使用相同的默认位置距离边缘16px垂直方向190px
switch (pos) {
case 'top_left':
button.style.top = '190px';
button.style.left = '16px';
break;
case 'top_right':
button.style.top = '190px';
button.style.right = '16px';
break;
case 'bottom_left':
button.style.bottom = '190px';
button.style.left = '16px';
break;
case 'bottom_right':
default:
button.style.bottom = '190px';
button.style.right = '16px';
break;
}
}
// 创建侧边吸附按钮
function createSideStickyButton() {
widgetButton = document.createElement('div');
widgetButton.className = 'widget-bot-button';
widgetButton.className = 'widget-bot-button widget-bot-side-sticky';
widgetButton.setAttribute('role', 'button');
widgetButton.setAttribute('tabindex', '0');
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text}窗口`);
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
widgetButton.setAttribute('data-theme', currentTheme);
const buttonContent = document.createElement('div');
buttonContent.className = 'widget-bot-button-content';
// 添加logo如果有
if (widgetInfo.btn_logo) {
const logo = document.createElement('img');
logo.src = widgetDomain + widgetInfo.btn_logo;
logo.alt = 'logo';
logo.className = 'widget-bot-logo';
logo.onerror = () => {
logo.style.display = 'none';
};
buttonContent.appendChild(logo);
}
// 侧边吸附显示图标和文字btn_logo 以及 btn_text
const icon = document.createElement('img');
const defaultIconSrc = widgetDomain + '/favicon.png';
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
icon.alt = 'icon';
icon.className = 'widget-bot-icon';
icon.onerror = () => {
// 如果当前不是 favicon.png尝试使用 favicon.png 作为备用
if (icon.src !== defaultIconSrc) {
icon.src = defaultIconSrc;
} else {
// 如果 favicon.png 也加载失败,隐藏图标
icon.style.display = 'none';
}
};
buttonContent.appendChild(icon);
// 添加文字
const textDiv = document.createElement('div');
textDiv.className = 'widget-bot-text';
textDiv.innerHTML = createVerticalText(widgetInfo.btn_text || '在线客服');
textDiv.innerHTML = createTwoLineText(widgetInfo.btn_text || '在线客服');
buttonContent.appendChild(textDiv);
widgetButton.appendChild(buttonContent);
// 应用位置 - 距离边缘16px垂直方向190px
const position = widgetInfo.btn_position || defaultBtnPosition;
applyButtonPosition(widgetButton, position);
// 设置 border-radius 为 24px统一圆角
widgetButton.style.borderRadius = '24px';
// 添加事件监听器
widgetButton.addEventListener('click', showModal);
widgetButton.addEventListener('click', handleButtonClick);
widgetButton.addEventListener('mousedown', startDrag);
widgetButton.addEventListener('keydown', handleKeyDown);
@ -134,6 +193,69 @@
widgetButton.addEventListener('touchend', handleTouchEnd);
document.body.appendChild(widgetButton);
}
// 创建悬浮球按钮
function createHoverBallButton() {
widgetButton = document.createElement('div');
widgetButton.className = 'widget-bot-button widget-bot-hover-ball';
widgetButton.setAttribute('role', 'button');
widgetButton.setAttribute('tabindex', '0');
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
widgetButton.setAttribute('data-theme', currentTheme);
const buttonContent = document.createElement('div');
buttonContent.className = 'widget-bot-button-content';
// 悬浮球只显示图标btn_logo
const icon = document.createElement('img');
const defaultIconSrc = widgetDomain + '/favicon.png';
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
icon.alt = 'icon';
icon.className = 'widget-bot-icon widget-bot-hover-ball-icon';
icon.onerror = () => {
// 如果当前不是 favicon.png尝试使用 favicon.png 作为备用
if (icon.src !== defaultIconSrc) {
icon.src = defaultIconSrc;
} else {
// 如果 favicon.png 也加载失败,隐藏图标
icon.style.display = 'none';
}
};
buttonContent.appendChild(icon);
widgetButton.appendChild(buttonContent);
// 应用位置 - 距离边缘16px垂直方向190px
applyButtonPosition(widgetButton, widgetInfo.btn_position || defaultBtnPosition);
// 添加事件监听器
widgetButton.addEventListener('click', handleButtonClick);
widgetButton.addEventListener('mousedown', startDrag);
widgetButton.addEventListener('keydown', handleKeyDown);
// 添加触摸事件支持
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
widgetButton.addEventListener('touchend', handleTouchEnd);
document.body.appendChild(widgetButton);
}
// 创建挂件按钮
function createWidget() {
// 如果已存在,先删除
if (widgetButton) {
widgetButton.remove();
}
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
if (btnStyle === 'hover_ball') {
createHoverBallButton();
} else {
createSideStickyButton();
}
// 创建模态框
createModal();
@ -145,6 +267,109 @@
}, 100);
}
// 创建自定义触发按钮
function createCustomTrigger() {
const btnId = widgetInfo.btn_id;
if (!btnId) {
console.error('btn_trigger 模式需要提供 btn_id');
return;
}
let retryCount = 0;
const maxRetries = 50; // 最多重试 50 次5秒
// 绑定事件到元素
function attachTrigger(element) {
if (!element) return;
// 避免重复绑定
if (element.hasAttribute('data-widget-trigger-attached')) {
return;
}
element.setAttribute('data-widget-trigger-attached', 'true');
customTriggerElement = element;
// 创建事件处理函数并保存引用
customTriggerHandler = function (e) {
e.preventDefault();
e.stopPropagation();
showModal();
};
// 绑定点击事件
element.addEventListener('click', customTriggerHandler);
}
// 尝试查找并绑定元素
function tryAttachTrigger() {
const element = document.getElementById(btnId);
if (element) {
attachTrigger(element);
createModal();
return true;
}
return false;
}
// 立即尝试一次
if (tryAttachTrigger()) {
return;
}
// 如果元素还没加载,使用多种方式监听
function retryAttach() {
if (tryAttachTrigger()) {
return;
}
retryCount++;
if (retryCount < maxRetries) {
setTimeout(retryAttach, 100);
} else {
console.warn('自定义触发按钮未找到,已停止重试:', btnId);
}
}
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver(function (mutations) {
if (tryAttachTrigger()) {
observer.disconnect();
}
});
// 开始观察 DOM 变化
observer.observe(document.body, {
childList: true,
subtree: true
});
// 如果 DOM 已加载完成,立即开始重试
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
setTimeout(retryAttach, 100);
});
} else {
setTimeout(retryAttach, 100);
}
// 延迟断开观察器(避免无限观察)
setTimeout(function () {
observer.disconnect();
}, 10000); // 10秒后断开
}
// 处理按钮点击事件(区分点击和拖拽)
function handleButtonClick(e) {
// 如果发生了拖拽,不打开弹框
if (hasDragged) {
e.preventDefault();
e.stopPropagation();
return;
}
showModal();
}
// 键盘事件处理
function handleKeyDown(e) {
if (e.key === 'Enter' || e.key === ' ') {
@ -176,7 +401,8 @@
Math.pow(touch.clientY - touchStartPos.y, 2)
);
if (distance < 10) {
// 只有在没有拖拽且移动距离很小的情况下才认为是点击
if (!hasDragged && distance < 10) {
// 判断为点击事件
setTimeout(() => showModal(), 100);
}
@ -198,22 +424,41 @@
widgetModal.setAttribute('aria-labelledby', 'widget-modal-title');
widgetModal.setAttribute('data-theme', currentTheme);
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
if (modalPosition === 'fixed') {
widgetModal.classList.add('widget-bot-modal-fixed');
}
const modalContent = document.createElement('div');
modalContent.className = 'widget-bot-modal-content';
if (modalPosition === 'fixed') {
modalContent.classList.add('widget-bot-modal-content-fixed');
}
// 创建关闭按钮
// 创建关闭按钮(透明框)
const closeBtn = document.createElement('button');
closeBtn.className = 'widget-bot-close-btn';
closeBtn.innerHTML = '<svg t="1752218667372" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4632" id="mx_n_1752218667373" width="32" height="32"><path d="M512 939.19762963a427.19762963 427.19762963 0 1 1 0-854.39525926 427.19762963 427.19762963 0 0 1 0 854.39525926z m0-482.08605274L396.47540505 341.53519999a19.41807408 19.41807408 0 0 0-27.44421216 0l-27.44421097 27.44421217a19.41807408 19.41807408 0 0 0 0 27.44421095L457.00801422 512l-115.47281423 115.52459495a19.41807408 19.41807408 0 0 0 0 27.44421216l27.44421217 27.44421097a19.41807408 19.41807408 0 0 0 27.44421095 0L512 566.99198578l115.52459495 115.47281423a19.41807408 19.41807408 0 0 0 27.44421216 0l27.44421097-27.44421217a19.41807408 19.41807408 0 0 0 0-27.44421095l-115.47281424-115.47281423 115.47281424-115.57637689a19.41807408 19.41807408 0 0 0 0-27.44421095l-27.44421097-27.44421096a19.41807408 19.41807408 0 0 0-27.44421216 0L512 457.00801422z" p-id="4633" fill="#ffffff"></path></svg>'
closeBtn.setAttribute('aria-label', '关闭窗口');
closeBtn.setAttribute('type', 'button');
closeBtn.addEventListener('click', hideModal);
// 创建一个内部元素来处理实际的点击事件(因为按钮设置了 pointer-events: none
const closeBtnArea = document.createElement('div');
closeBtnArea.style.width = '100%';
closeBtnArea.style.height = '100%';
closeBtnArea.style.pointerEvents = 'auto'; // 内部元素可以接收事件
closeBtnArea.style.cursor = 'pointer';
closeBtnArea.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
hideModal();
});
closeBtn.appendChild(closeBtnArea);
// 创建iframe
const iframe = document.createElement('iframe');
iframe.className = 'widget-bot-iframe';
iframe.src = `${widgetDomain}/widget`;
iframe.setAttribute('title', `${widgetInfo.btn_text}服务窗口`);
iframe.setAttribute('title', `${widgetInfo.btn_text || '在线客服'}服务窗口`);
iframe.setAttribute('allow', 'camera; microphone; geolocation');
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation');
@ -224,6 +469,156 @@
document.body.appendChild(widgetModal);
}
// 检测是否为移动端
function isMobile() {
return window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
// 智能定位弹框follow模式
function positionModalFollow(modalContent) {
if (!widgetButton || !modalContent) return;
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
return;
}
requestAnimationFrame(() => {
const buttonRect = widgetButton.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const margin = 16; // 距离屏幕边缘的最小距离
const buttonGap = 16; // 弹框和按钮之间的最小距离
// 先设置一个临时位置来获取弹框尺寸
const originalPosition = modalContent.style.position;
const originalTop = modalContent.style.top;
const originalLeft = modalContent.style.left;
const originalVisibility = modalContent.style.visibility;
const originalDisplay = modalContent.style.display;
modalContent.style.position = 'absolute';
modalContent.style.top = '0';
modalContent.style.left = '0';
modalContent.style.visibility = 'hidden';
modalContent.style.display = 'block';
const modalRect = modalContent.getBoundingClientRect();
const modalWidth = modalRect.width;
const modalHeight = modalRect.height;
modalContent.style.visibility = originalVisibility || 'visible';
modalContent.style.display = originalDisplay || 'block';
// 计算按钮中心点
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
// 判断按钮在屏幕的哪一侧
const isLeftSide = buttonCenterX < windowWidth / 2;
const isTopSide = buttonCenterY < windowHeight / 2;
// 智能选择弹框位置,确保完整显示
let finalTop, finalBottom, finalLeft, finalRight;
if (isLeftSide) {
// 按钮在左侧,弹框优先显示在右侧(按钮右侧)
finalLeft = buttonRect.right + buttonGap;
finalRight = 'auto';
// 如果右侧空间不够,显示在左侧(按钮左侧)
if (finalLeft + modalWidth > windowWidth - margin) {
finalLeft = 'auto';
finalRight = windowWidth - buttonRect.left + buttonGap;
// 如果左侧空间也不够,则贴左边(但保持与按钮的距离)
if (buttonRect.left - buttonGap - modalWidth < margin) {
finalLeft = margin;
finalRight = 'auto';
}
}
} else {
// 按钮在右侧,弹框优先显示在左侧(按钮左侧)
finalLeft = 'auto';
finalRight = windowWidth - buttonRect.left + buttonGap;
// 如果左侧空间不够,显示在右侧(按钮右侧)
if (buttonRect.left - buttonGap - modalWidth < margin) {
finalRight = 'auto';
finalLeft = buttonRect.right + buttonGap;
// 如果右侧空间也不够,则贴右边(但保持与按钮的距离)
if (finalLeft + modalWidth > windowWidth - margin) {
finalLeft = 'auto';
finalRight = margin;
}
}
}
// 垂直方向:优先与按钮顶部对齐
// 弹框顶部与按钮顶部对齐
finalTop = buttonRect.top;
finalBottom = 'auto';
// 如果弹框底部超出屏幕,则向上调整,确保弹框完整显示在屏幕内
if (finalTop + modalHeight > windowHeight - margin) {
// 计算向上调整后的位置
const adjustedTop = windowHeight - margin - modalHeight;
// 如果调整后的位置仍然在按钮上方,则使用调整后的位置
if (adjustedTop >= margin) {
finalTop = adjustedTop;
} else {
// 如果调整后仍然超出,则贴顶部
finalTop = margin;
}
} else if (finalTop < margin) {
// 如果弹框顶部超出屏幕,则贴顶部
finalTop = margin;
}
// 应用最终位置
modalContent.style.top = finalTop !== undefined ? (typeof finalTop === 'string' ? finalTop : finalTop + 'px') : 'auto';
modalContent.style.bottom = finalBottom !== undefined ? (typeof finalBottom === 'string' ? finalBottom : finalBottom + 'px') : 'auto';
modalContent.style.left = finalLeft !== undefined ? (typeof finalLeft === 'string' ? finalLeft : finalLeft + 'px') : 'auto';
modalContent.style.right = finalRight !== undefined ? (typeof finalRight === 'string' ? finalRight : finalRight + 'px') : 'auto';
// 最终检查并修正,确保弹框完全在屏幕内
requestAnimationFrame(() => {
const finalModalRect = modalContent.getBoundingClientRect();
// 修正左边界
if (finalModalRect.left < margin) {
modalContent.style.left = margin + 'px';
modalContent.style.right = 'auto';
}
// 修正右边界
if (finalModalRect.right > windowWidth - margin) {
modalContent.style.right = margin + 'px';
modalContent.style.left = 'auto';
}
// 修正上边界
if (finalModalRect.top < margin) {
modalContent.style.top = margin + 'px';
modalContent.style.bottom = 'auto';
}
// 修正下边界
if (finalModalRect.bottom > windowHeight - margin) {
modalContent.style.bottom = margin + 'px';
modalContent.style.top = 'auto';
}
});
});
}
// 显示模态框
function showModal() {
if (!widgetModal) return;
@ -231,27 +626,31 @@
widgetModal.style.display = 'flex';
document.body.classList.add('widget-bot-modal-open');
// 计算模态框位置
requestAnimationFrame(() => {
const buttonRect = widgetButton.getBoundingClientRect();
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
if (modalContent) {
// 设置模态框位置距离按钮16px距离底部24px
const modalBottom = 24;
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16);
modalContent.style.bottom = modalBottom + 'px';
modalContent.style.right = modalRight + 'px';
// 确保模态框不会超出屏幕
const modalRect = modalContent.getBoundingClientRect();
if (modalRect.left < 16) {
modalContent.style.right = '16px';
modalContent.style.left = '16px';
}
}
});
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
} else if (modalPosition === 'fixed') {
// 桌面端固定模式:居中展示
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
} else {
// 桌面端跟随模式:跟随按钮位置 - 智能定位,确保弹框完整显示在屏幕内
positionModalFollow(modalContent);
}
// 添加ESC键关闭功能
document.addEventListener('keydown', handleEscKey);
@ -287,42 +686,98 @@
};
isDragging = true;
hasDragged = false; // 重置拖拽标记
const rect = widgetButton.getBoundingClientRect();
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
dragOffset.x = clientX - rect.left;
dragOffset.y = clientY - rect.top;
// 记录拖拽开始位置
dragStartPos.x = clientX;
dragStartPos.y = clientY;
// 清除bottom定位使用top定位
widgetButton.style.bottom = 'auto';
widgetButton.style.top = rect.top + 'px';
// 缓存按钮尺寸,避免拖拽过程中频繁读取
buttonSize.width = rect.width;
buttonSize.height = rect.height;
// 先清除 transform确保获取真实的位置
widgetButton.style.transform = 'none';
// 重新获取位置(清除 transform 后的真实位置)
const realRect = widgetButton.getBoundingClientRect();
// 记录初始位置(基于清除 transform 后的真实位置)
initialPosition.left = realRect.left;
initialPosition.top = realRect.top;
dragOffset.x = clientX - realRect.left;
dragOffset.y = clientY - realRect.top;
// 确保使用 fixed 定位,使用真实位置
widgetButton.style.position = 'fixed';
widgetButton.style.top = realRect.top + 'px';
widgetButton.style.left = realRect.left + 'px';
widgetButton.style.right = 'auto';
widgetButton.style.bottom = 'auto';
document.addEventListener('mousemove', drag);
// 禁用过渡效果,提升拖拽性能
widgetButton.style.transition = 'none';
// 提示浏览器优化(使用 left/top 定位)
widgetButton.style.willChange = 'left, top';
document.addEventListener('mousemove', drag, { passive: false });
document.addEventListener('mouseup', stopDrag);
widgetButton.classList.add('dragging');
widgetButton.style.zIndex = '10001';
}
// 拖拽中
// 拖拽中 - 直接更新位置,实现丝滑跟随
function drag(e) {
if (!isDragging) return;
if (e.preventDefault) {
e.preventDefault()
};
e.preventDefault();
}
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
// 检测是否发生了实际移动超过5px才认为是拖拽
const moveDistance = Math.sqrt(
Math.pow(clientX - dragStartPos.x, 2) +
Math.pow(clientY - dragStartPos.y, 2)
);
if (moveDistance > 5) {
hasDragged = true;
}
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const buttonWidth = buttonSize.width;
const buttonHeight = buttonSize.height;
// 直接基于鼠标位置计算新位置
// 鼠标位置减去拖拽偏移量,得到按钮左上角应该的位置
const newLeft = clientX - dragOffset.x;
const newTop = clientY - dragOffset.y;
const maxTop = window.innerHeight - widgetButton.offsetHeight;
// 限制在屏幕范围内
const constrainedTop = Math.max(0, Math.min(newTop, maxTop));
// 垂直位置:限制在屏幕范围内,距离顶部和底部最小距离为 24px
const minTop = 24;
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
const constrainedTop = Math.max(minTop, Math.min(newTop, maxTop));
// 水平位置:限制在屏幕范围内
const maxLeft = windowWidth - buttonWidth;
const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft));
// 直接使用 left/top 定位,实现无延迟的丝滑跟随
// 使用 transform: none 确保不会有任何 transform 干扰
widgetButton.style.left = constrainedLeft + 'px';
widgetButton.style.top = constrainedTop + 'px';
widgetButton.style.right = 'auto';
widgetButton.style.bottom = 'auto';
widgetButton.style.transform = 'none';
}
// 停止拖拽
@ -330,26 +785,75 @@
if (!isDragging) return;
isDragging = false;
// 取消待执行的动画帧
if (dragAnimationFrame) {
cancelAnimationFrame(dragAnimationFrame);
dragAnimationFrame = null;
}
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', stopDrag);
widgetButton.classList.remove('dragging');
widgetButton.style.zIndex = '9999';
// 吸附到右侧恢复bottom定位
// 恢复过渡效果
widgetButton.style.transition = '';
widgetButton.style.willChange = '';
// 根据按钮类型和当前位置进行最终定位
requestAnimationFrame(() => {
const currentTop = parseInt(widgetButton.style.top);
const buttonRect = widgetButton.getBoundingClientRect();
const currentLeft = buttonRect.left;
const currentTop = buttonRect.top;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const buttonHeight = widgetButton.offsetHeight;
const buttonWidth = buttonSize.width;
const buttonHeight = buttonSize.height;
// 计算距离底部的位置
const bottomPosition = windowHeight - currentTop - buttonHeight;
// 两种模式使用相同的停止拖拽逻辑:只能左右侧边缘吸附
// 根据按钮实际位置判断左右,保持当前位置
const screenCenterX = windowWidth / 2;
const buttonCenterX = currentLeft + buttonWidth / 2;
const isLeftSide = buttonCenterX < screenCenterX;
const sideDistance = 16; // 距离边缘的距离
// 恢复right和bottom定位清除top
widgetButton.style.right = '0';
widgetButton.style.bottom = Math.max(20, bottomPosition) + 'px';
widgetButton.style.top = 'auto';
widgetButton.style.left = 'auto';
// 垂直位置:保持在当前位置,限制在屏幕范围内,距离顶部和底部最小距离为 24px
const minTop = 24;
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
const finalTop = Math.max(minTop, Math.min(currentTop, maxTop));
let finalLeft;
// 水平位置距离左右边16px
if (isLeftSide) {
finalLeft = sideDistance;
widgetButton.style.left = sideDistance + 'px';
widgetButton.style.right = 'auto';
} else {
finalLeft = windowWidth - sideDistance - buttonWidth;
widgetButton.style.right = sideDistance + 'px';
widgetButton.style.left = 'auto';
}
widgetButton.style.top = finalTop + 'px';
widgetButton.style.bottom = 'auto';
// 清除 transform使用 left/top 定位
widgetButton.style.transform = 'none';
// 更新 border-radius现在都是24px圆角
widgetButton.style.borderRadius = '24px';
// 更新初始位置,为下次拖拽做准备
if (finalLeft !== undefined && finalTop !== undefined) {
initialPosition.left = finalLeft;
initialPosition.top = finalTop;
} else {
// 如果未定义,使用当前实际位置
initialPosition.left = buttonRect.left;
initialPosition.top = buttonRect.top;
}
});
}
@ -390,19 +894,30 @@
// 窗口大小改变时重新定位
window.addEventListener('resize', function () {
if (widgetModal && widgetModal.style.display === 'flex') {
// 重新计算模态框位置
setTimeout(() => {
const buttonRect = widgetButton.getBoundingClientRect();
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
if (!modalContent) return;
if (modalContent) {
const modalBottom = 24;
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16);
// 移动端强制居中显示
if (isMobile()) {
modalContent.style.position = 'relative';
modalContent.style.top = 'auto';
modalContent.style.left = 'auto';
modalContent.style.right = 'auto';
modalContent.style.bottom = 'auto';
modalContent.style.margin = 'auto';
modalContent.style.width = 'calc(100% - 32px)';
modalContent.style.height = 'auto';
return;
}
modalContent.style.bottom = modalBottom + 'px';
modalContent.style.right = modalRight + 'px';
}
}, 100);
const modalPosition = widgetInfo?.modal_position || defaultModalPosition;
if (modalPosition === 'fixed') {
// 固定居中模式不需要重新定位
return;
}
// 重新计算模态框位置(使用智能定位)
positionModalFollow(modalContent);
}
});
@ -423,8 +938,13 @@
if (widgetModal) {
widgetModal.remove();
}
if (customTriggerElement && customTriggerHandler) {
customTriggerElement.removeEventListener('click', customTriggerHandler);
customTriggerElement.removeAttribute('data-widget-trigger-attached');
}
});
// 启动
init();
})();

View File

@ -4,7 +4,7 @@ import { ThemeStoreProvider } from '@/provider/themeStore';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth';
import { Box } from '@mui/material';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
import type { Metadata, Viewport } from 'next';
import localFont from 'next/font/local';
import { headers, cookies } from 'next/headers';
@ -92,7 +92,7 @@ const Layout = async ({
return (
<html lang='en'>
<body
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : ''}`}
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : 'light'}`}
>
<AppRouterCacheProvider>
<ThemeStoreProvider themeMode={themeMode}>

View File

@ -49,7 +49,7 @@
--color-primary-main: #6e73fe;
/* 代码块颜色 */
--code-bg: #ffffff;
--code-bg: rgba(0, 0, 0, 0.03);
--code-color: #21222d;
--inline-code-bg: #fff5f5;
--inline-code-color: #ff502c;

View File

@ -1,8 +1,5 @@
import StoreProvider from '@/provider';
import { darkThemeWidget, lightThemeWidget } from '@/theme';
import { getShareV1AppWidgetInfo } from '@/request/ShareApp';
import { ThemeProvider } from '@ctzhian/ui';
import React from 'react';
const Layout = async ({
@ -12,18 +9,7 @@ const Layout = async ({
}>) => {
const widgetDetail: any = await getShareV1AppWidgetInfo();
const themeMode =
widgetDetail?.settings?.widget_bot_settings?.theme_mode || 'light';
return (
<ThemeProvider
theme={themeMode === 'dark' ? darkThemeWidget : lightThemeWidget}
>
<StoreProvider widget={widgetDetail} themeMode={themeMode || 'light'}>
{children}
</StoreProvider>
</ThemeProvider>
);
return <StoreProvider widget={widgetDetail}>{children}</StoreProvider>;
};
export default Layout;

View File

@ -1,17 +1,3 @@
import Widget from '@/views/widget';
import { Box } from '@mui/material';
const Page = () => {
return (
<Box
sx={{
width: '100vw',
height: '100vh',
}}
>
<Widget />
</Box>
);
};
export default Page;
export default Widget;

View File

@ -109,10 +109,19 @@ export type WidgetInfo = {
search_placeholder: string;
recommend_questions: string[];
widget_bot_settings: {
btn_logo: string;
btn_text: string;
is_open: boolean;
theme_mode: 'light' | 'dark';
btn_logo?: string;
btn_text?: string;
btn_style?: string;
btn_id?: string;
btn_position?: string;
modal_position?: string;
is_open?: boolean;
recommend_node_ids?: string[];
recommend_questions?: string[];
theme_mode?: string;
search_mode?: string;
placeholder?: string;
disclaimer?: string;
};
};
};

View File

@ -82,6 +82,8 @@ export interface ConversationItem {
message_id: string;
source: 'history' | 'chat';
chunk_result: ChunkResultItem[];
result_expend: boolean;
thinking_expend: boolean;
thinking_content: string;
id: string;
}
@ -382,6 +384,8 @@ const AiQaContent: React.FC<{
const solution = await cap.solve();
token = solution.token;
} catch (error) {
setLoading(false);
setThinking(4);
message.error('验证失败');
console.log(error, 'error---------');
return;
@ -465,6 +469,8 @@ const AiQaContent: React.FC<{
if (lastConversation) {
lastConversation.a = answerContent;
lastConversation.thinking_content = thinkingContent;
lastConversation.result_expend = false;
lastConversation.thinking_expend = false;
}
return newConversation;
});
@ -513,6 +519,8 @@ const AiQaContent: React.FC<{
source: 'chat',
chunk_result: [],
thinking_content: '',
result_expend: true,
thinking_expend: true,
id: uuidv4(),
});
messageIdRef.current = '';
@ -631,6 +639,8 @@ const AiQaContent: React.FC<{
source: 'history',
chunk_result: [],
thinking_content: '',
result_expend: true,
thinking_expend: true,
id: uuidv4(),
});
}
@ -667,6 +677,8 @@ const AiQaContent: React.FC<{
chunk_result: [],
thinking_content: '',
id: uuidv4(),
result_expend: true,
thinking_expend: true,
});
}
}
@ -791,7 +803,16 @@ const AiQaContent: React.FC<{
<StyledAiBubble>
{/* 搜索结果 */}
{item.chunk_result.length > 0 && (
<StyledChunkAccordion defaultExpanded>
<StyledChunkAccordion
expanded={item.result_expend}
onChange={(event, expanded) => {
setConversation(prev => {
const newConversation = [...prev];
newConversation[index].result_expend = expanded;
return newConversation;
});
}}
>
<StyledChunkAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
@ -837,7 +858,16 @@ const AiQaContent: React.FC<{
{/* 思考过程 */}
{!!item.thinking_content && (
<StyledThinkingAccordion defaultExpanded>
<StyledThinkingAccordion
expanded={item.thinking_expend}
onChange={(event, expanded) => {
setConversation(prev => {
const newConversation = [...prev];
newConversation[index].thinking_expend = expanded;
return newConversation;
});
}}
>
<StyledThinkingAccordionSummary
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
>
@ -929,6 +959,9 @@ const AiQaContent: React.FC<{
</>
)}
</Stack>
<Box>
{kbDetail?.settings?.disclaimer_settings?.content}
</Box>
</StyledActionStack>
)}
</StyledAiBubble>

View File

@ -47,9 +47,9 @@ export const StyledUserBubble = styled(Box)(({ theme }) => ({
export const StyledAiBubble = styled(Box)(({ theme }) => ({
alignSelf: 'flex-start',
maxWidth: '85%',
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: theme.spacing(3),
}));

View File

@ -247,7 +247,9 @@ const QaModal: React.FC<QaModalProps> = () => {
<Box
sx={{
px: 3,
pt: kbDetail?.settings?.disclaimer_settings?.content ? 2 : 0,
pt: kbDetail?.settings?.web_app_custom_style?.show_brand_info
? 2
: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -263,7 +265,10 @@ const QaModal: React.FC<QaModalProps> = () => {
gap: 1,
}}
>
<Box>{kbDetail?.settings?.disclaimer_settings?.content}</Box>
<Box>
{kbDetail?.settings?.web_app_custom_style?.show_brand_info &&
'本网站由 PandaWiki 提供技术支持'}
</Box>
</Typography>
</Box>
</Box>

View File

@ -1,10 +1,14 @@
'use client';
import Logo from '@/assets/images/logo.png';
import { Box } from '@mui/material';
import { Stack, Box, IconButton, alpha, Tooltip } from '@mui/material';
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
import { IconDengchu } from '@panda-wiki/icons';
import { useStore } from '@/provider';
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import ErrorIcon from '@mui/icons-material/Error';
import { Modal } from '@ctzhian/ui';
import {
Header as CustomHeader,
WelcomeHeader as WelcomeHeaderComponent,
@ -16,8 +20,58 @@ interface HeaderProps {
isWelcomePage?: boolean;
}
const LogoutButton = () => {
const [open, setOpen] = useState(false);
const handleLogout = () => {
return postShareProV1AuthLogout().then(() => {
// 使用当前页面的协议http 或 https
const protocol = window.location.protocol;
const host = window.location.host;
window.location.href = `${protocol}//${host}/auth/login`;
});
};
return (
<>
<Modal
title={
<Stack direction='row' alignItems='center' gap={1}>
<ErrorIcon sx={{ fontSize: 24, color: 'warning.main' }} />
<Box sx={{ mt: '2px' }}></Box>
</Stack>
}
open={open}
okText='确定'
cancelText='取消'
onCancel={() => setOpen(false)}
onOk={handleLogout}
closable={false}
>
<Box sx={{ pl: 4 }}>退</Box>
</Modal>
<Tooltip title='退出登录' arrow>
<IconButton size='small' onClick={() => setOpen(true)}>
<IconDengchu
sx={theme => ({
cursor: 'pointer',
color: alpha(theme.palette.text.primary, 0.65),
fontSize: 24,
'&:hover': { color: theme.palette.primary.main },
})}
/>
</IconButton>
</Tooltip>
</>
);
};
const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const pathname = usePathname();
const docWidth = useMemo(() => {
if (isWelcomePage) return 'full';
@ -55,16 +109,23 @@ const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)}
>
<Box sx={{ ml: 2 }}>
<Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
<ThemeSwitch />
</Box>
{!!authInfo && <LogoutButton />}
</Stack>
<QaModal />
</CustomHeader>
);
};
export const WelcomeHeader = () => {
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
if (value?.trim()) {
if (type === 'chat') {
@ -91,6 +152,7 @@ export const WelcomeHeader = () => {
onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)}
>
<Box sx={{ ml: 2 }}>{!!authInfo && <LogoutButton />}</Box>
<QaModal />
</WelcomeHeaderComponent>
);

View File

@ -4,12 +4,12 @@ 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> => {
const fetchImageAsBlob = async (
src: string,
imageBlobCache: Map<string, string>,
): Promise<string> => {
// 检查缓存
if (imageBlobCache.has(src)) {
return imageBlobCache.get(src)!;
@ -39,12 +39,8 @@ const fetchImageAsBlob = async (src: string): Promise<string> => {
}
};
// 导出获取图片 blob URL 的函数
export const getImageBlobUrl = (src: string): string | null => {
return imageBlobCache.get(src) || null;
};
export const clearImageBlobCache = () => {
// 清理图片 blob 缓存
export const clearImageBlobCache = (imageBlobCache: Map<string, string>) => {
imageBlobCache.forEach(url => {
URL.revokeObjectURL(url);
});
@ -54,7 +50,7 @@ export const clearImageBlobCache = () => {
const StyledErrorContainer = styled('div')(({ theme }) => ({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2),
padding: theme.spacing(1, 6),
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
@ -71,7 +67,7 @@ const StyledErrorContainer = styled('div')(({ theme }) => ({
const StyledErrorText = styled('div')(() => ({
fontSize: '12px',
marginBottom: 16,
marginBottom: 10,
}));
export const ImageErrorIcon = (props: SvgIconProps) => {
@ -102,7 +98,7 @@ export const ImageErrorIcon = (props: SvgIconProps) => {
const ImageErrorDisplay: React.FC = () => (
<StyledErrorContainer>
<ImageErrorIcon
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 160 }}
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 140 }}
/>
<StyledErrorText></StyledErrorText>
</StyledErrorContainer>
@ -116,7 +112,7 @@ interface ImageComponentProps {
imageIndex: number;
onLoad: (index: number, html: string) => void;
onError: (index: number, html: string) => void;
onImageClick: (src: string) => void;
imageBlobCache: Map<string, string>;
}
// ==================== 图片组件 ====================
@ -127,7 +123,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
imageIndex,
onLoad,
onError,
onImageClick,
imageBlobCache,
}) => {
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
'loading',
@ -149,7 +145,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
// 获取图片 blob URL
useEffect(() => {
let mounted = true;
fetchImageAsBlob(src)
fetchImageAsBlob(src, imageBlobCache)
.then(url => {
if (mounted) {
setBlobUrl(url);
@ -166,7 +162,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
return () => {
mounted = false;
};
}, [src]);
}, [src, imageBlobCache]);
// 解析自定义样式
const parseStyleString = (styleStr: string) => {
@ -238,7 +234,8 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
referrerPolicy='no-referrer'
onLoad={handleLoad}
onError={handleError}
onClick={() => onImageClick(src)} // 传递原始 src 用于预览
data-original-src={src}
className='markdown-image'
{...getOtherProps()}
/>
) : (
@ -264,12 +261,13 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
export interface ImageRendererOptions {
onImageLoad: (index: number, html: string) => void;
onImageError: (index: number, html: string) => void;
onImageClick: (src: string) => void;
imageRenderCache: Map<number, string>;
imageBlobCache: Map<string, string>;
}
export const createImageRenderer = (options: ImageRendererOptions) => {
const { onImageLoad, onImageError, imageRenderCache, onImageClick } = options;
const { onImageLoad, onImageError, imageRenderCache, imageBlobCache } =
options;
return (
src: string,
alt: string,
@ -279,29 +277,6 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
// 检查缓存
const cached = imageRenderCache.get(imageIndex);
if (cached) {
// 下一帧对已缓存的DOM绑定原生点击事件避免事件丢失且不引起重渲染
requestAnimationFrame(() => {
const container = document.querySelector(
`.image-container-${imageIndex}`,
) as HTMLElement | null;
if (!container) return;
const img = container.querySelector('img') as HTMLImageElement | null;
if (!img) return;
const alreadyBound = (img as HTMLElement).getAttribute(
'data-click-bound',
);
if (!alreadyBound) {
(img as HTMLElement).setAttribute('data-click-bound', '1');
img.style.cursor = img.style.cursor || 'pointer';
img.addEventListener('click', () => {
try {
onImageClick(img.src);
} catch {
// noop
}
});
}
});
return cached;
}
@ -323,7 +298,7 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
imageIndex={imageIndex}
onLoad={onImageLoad}
onError={onImageError}
onImageClick={onImageClick}
imageBlobCache={imageBlobCache}
/>,
);
} else {

View File

@ -15,11 +15,7 @@ import React, {
useState,
} from 'react';
import { useSmartScroll } from '@/hooks';
import {
clearImageBlobCache,
createImageRenderer,
getImageBlobUrl,
} from './imageRenderer';
import { clearImageBlobCache, createImageRenderer } from './imageRenderer';
import { incrementalRender } from './incrementalRenderer';
import { createMermaidRenderer } from './mermaidRenderer';
import {
@ -88,7 +84,8 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const lastContentRef = useRef<string>('');
const mdRef = useRef<MarkdownIt | null>(null);
const mermaidSuccessIdRef = useRef<Map<number, string>>(new Map());
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存HTML
const imageBlobCacheRef = useRef<Map<string, string>>(new Map()); // 图片 blob URL 缓存
// 使用智能滚动 hook
const { scrollToBottom } = useSmartScroll({
@ -125,13 +122,8 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
createImageRenderer({
onImageLoad: handleImageLoad,
onImageError: handleImageError,
onImageClick: (src: string) => {
// 尝试获取缓存的 blob URL如果不存在则使用原始 src
const blobUrl = getImageBlobUrl(src);
setPreviewImgBlobUrl(blobUrl || src);
setPreviewOpen(true);
},
imageRenderCache: imageRenderCacheRef.current,
imageBlobCache: imageBlobCacheRef.current,
}),
[handleImageLoad, handleImageError],
);
@ -158,6 +150,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const originalFenceRender = md.renderer.rules.fence;
// 自定义图片渲染
let imageCount = 0;
let htmlImageCount = 0; // HTML 标签图片计数
let mermaidCount = 0;
md.renderer.rules.image = (tokens, idx) => {
imageCount++;
@ -240,6 +233,38 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
);
};
// 解析 HTML img 标签并提取属性
const parseImgTag = (
html: string,
): {
src: string;
alt: string;
attrs: [string, string][];
} | null => {
// 匹配 <img> 标签(支持自闭合和普通标签)
const imgMatch = html.match(/<img\s+([^>]*?)\/?>/i);
if (!imgMatch) return null;
const attrsString = imgMatch[1];
const attrs: [string, string][] = [];
let src = '';
let alt = '';
// 解析属性:匹配 name="value" 或 name='value' 或 name=value
const attrRegex =
/(\w+)(?:=["']([^"']*)["']|=(?:["'])?([^\s>]+)(?:["'])?)?/g;
let attrMatch;
while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
const name = attrMatch[1].toLowerCase();
const value = attrMatch[2] || attrMatch[3] || '';
attrs.push([name, value]);
if (name === 'src') src = value;
if (name === 'alt') alt = value;
}
return { src, alt, attrs };
};
md.renderer.rules.html_block = (
tokens,
idx,
@ -278,6 +303,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
if (content.includes('<error>')) return '<span class="chat-error">';
if (content.includes('</error>')) return '</span>';
// 处理 img 标签
if (content.includes('<img')) {
const imgData = parseImgTag(content);
if (imgData && imgData.src) {
const imageIndex = imageCount + htmlImageCount;
htmlImageCount++;
return renderImage(
imgData.src,
imgData.alt,
imgData.attrs,
imageIndex,
);
}
}
// 🔒 安全检查:不在白名单的标签,转义输出
if (!isAllowedTag(content)) {
return md.utils.escapeHtml(content);
@ -301,6 +341,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
if (content.includes('<error>')) return '<span class="chat-error">';
if (content.includes('</error>')) return '</span>';
// 处理 img 标签
if (content.includes('<img')) {
const imgData = parseImgTag(content);
if (imgData && imgData.src) {
const imageIndex = imageCount + htmlImageCount;
htmlImageCount++;
return renderImage(
imgData.src,
imgData.alt,
imgData.attrs,
imageIndex,
);
}
}
// 🔒 安全检查:不在白名单的标签,转义输出
if (!isAllowedTag(content)) {
return md.utils.escapeHtml(content);
@ -352,7 +407,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
}
}, [content, customizeRenderer, scrollToBottom]);
// 添加代码块点击复制功能
// 添加代码块点击复制和图片点击预览功能(事件代理)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
@ -360,6 +415,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement;
// 检查是否点击了图片
const imgElement = target.closest(
'img.markdown-image',
) as HTMLImageElement;
if (imgElement) {
const originalSrc = imgElement.getAttribute('data-original-src');
if (originalSrc) {
// 尝试获取缓存的 blob URL如果不存在则使用原始 src
const blobUrl = imageBlobCacheRef.current.get(originalSrc);
setPreviewImgBlobUrl(blobUrl || originalSrc);
setPreviewOpen(true);
}
return;
}
// 检查是否点击了代码块
const preElement = target.closest('pre.hljs');
if (preElement) {
@ -368,6 +438,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
const code = codeElement.textContent || '';
copyText(code.replace(/\n$/, ''));
}
return;
}
// 检查是否点击了行内代码
@ -380,7 +451,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
container.addEventListener('click', handleClick);
return () => {
clearImageBlobCache();
clearImageBlobCache(imageBlobCacheRef.current);
container.removeEventListener('click', handleClick);
};
}, []);
@ -406,6 +477,9 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
position: 'relative',
display: 'inline-block',
},
'.markdown-image': {
cursor: 'pointer',
},
'.image-error': {
display: 'flex',
alignItems: 'center',

View File

@ -1,95 +0,0 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { getShareV1AppWidgetInfo } from './request/ShareApp';
import { middleware as homeMiddleware } from './middleware/home';
const proxyShare = async (request: NextRequest) => {
// 转发到 process.env.TARGET
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
const targetOrigin = process.env.TARGET!;
const targetUrl = new URL(
request.nextUrl.pathname + request.nextUrl.search,
targetOrigin,
);
// 构造 fetch 选项
const fetchHeaders = new Headers(request.headers);
fetchHeaders.set('x-kb-id', kb_id);
const fetchOptions: RequestInit = {
method: request.method,
headers: fetchHeaders,
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
redirect: 'manual',
};
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
const nextRes = new NextResponse(proxyRes.body, {
status: proxyRes.status,
headers: proxyRes.headers,
statusText: proxyRes.statusText,
});
return nextRes;
};
export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const pathname = url.pathname;
if (pathname.startsWith('/widget')) {
const widgetInfo: any = await getShareV1AppWidgetInfo();
if (widgetInfo) {
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
return NextResponse.rewrite(new URL('/not-fount', request.url));
}
}
return;
}
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
headers[key] = value;
}
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
let needSetSessionId = false;
if (!sessionId) {
sessionId = uuidv4();
needSetSessionId = true;
}
let response: NextResponse;
if (pathname.startsWith('/share/')) {
response = await proxyShare(request);
} else {
response = await homeMiddleware(request, headers, sessionId);
}
if (needSetSessionId) {
response.cookies.set('x-pw-session-id', sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 年
});
}
if (!pathname.startsWith('/share')) {
response.headers.set('x-current-path', pathname);
response.headers.set('x-current-search', url.search);
}
return response;
}
export const config = {
matcher: [
'/',
'/home',
'/share/:path*',
'/chat/:path*',
'/widget',
'/welcome',
'/auth/login',
'/node/:path*',
'/node',
// '/client/:path*',
],
};

View File

@ -1,87 +0,0 @@
import { parsePathname } from '@/utils';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { postShareV1StatPage } from '@/request/ShareStat';
import { getShareV1NodeList } from '@/request/ShareNode';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { filterEmptyFolders, convertToTree } from '@/utils/drag';
import { deepSearchFirstNode } from '@/utils';
const StatPage = {
welcome: 1,
node: 2,
chat: 3,
auth: 4,
} as const;
const getFirstNode = async () => {
const nodeListResult: any = await getShareV1NodeList();
const tree = filterEmptyFolders(convertToTree(nodeListResult || []));
return deepSearchFirstNode(tree);
};
const getHomePath = async () => {
const info = await getShareV1AppWebInfo();
return info?.settings?.home_page_setting;
};
export async function middleware(
request: NextRequest,
headers: Record<string, string>,
session: string,
) {
const url = request.nextUrl.clone();
const { page, id } = parsePathname(url.pathname);
try {
// 获取节点列表
if (url.pathname === '/') {
const homePath = await getHomePath();
if (homePath === 'custom') {
return NextResponse.rewrite(new URL('/home', request.url));
} else {
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
if (firstNode) {
return NextResponse.rewrite(
new URL(`/node/${firstNode.id}`, request.url),
);
}
return NextResponse.rewrite(new URL('/node', request.url));
}
}
// 页面上报
const pages = Object.keys(StatPage);
if (pages.includes(page) || pages.includes(id)) {
postShareV1StatPage(
{
scene: StatPage[page as keyof typeof StatPage],
node_id: id || '',
},
{
headers: {
'x-pw-session-id': session,
...headers,
},
},
);
}
return NextResponse.next();
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
error.message === 'NEXT_REDIRECT'
) {
return NextResponse.redirect(
new URL(
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
request.url,
),
);
}
}
return NextResponse.next();
}

View File

@ -35,6 +35,10 @@ export const ThemeStoreProvider = ({
useEffect(() => {
Cookies.set('theme_mode', themeMode, { expires: 365 * 10 });
}, [themeMode]);
console.log('themeMode-------', themeMode);
console.log('themeMode-------', theme);
return (
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>

181
web/app/src/proxy.ts Normal file
View File

@ -0,0 +1,181 @@
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
import { getShareV1AppWidgetInfo } from './request/ShareApp';
import { parsePathname } from '@/utils';
import { postShareV1StatPage } from '@/request/ShareStat';
import { getShareV1NodeList } from '@/request/ShareNode';
import { getShareV1AppWebInfo } from '@/request/ShareApp';
import { filterEmptyFolders, convertToTree } from '@/utils/drag';
import { deepSearchFirstNode } from '@/utils';
const StatPage = {
welcome: 1,
node: 2,
chat: 3,
auth: 4,
} as const;
const getFirstNode = async () => {
const nodeListResult: any = await getShareV1NodeList();
const tree = filterEmptyFolders(convertToTree(nodeListResult || []));
return deepSearchFirstNode(tree);
};
const getHomePath = async () => {
const info = await getShareV1AppWebInfo();
return info?.settings?.home_page_setting;
};
const homeProxy = async (
request: NextRequest,
headers: Record<string, string>,
session: string,
) => {
const url = request.nextUrl.clone();
const { page, id } = parsePathname(url.pathname);
try {
// 获取节点列表
if (url.pathname === '/') {
const homePath = await getHomePath();
if (homePath === 'custom') {
return NextResponse.rewrite(new URL('/home', request.url));
} else {
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
if (firstNode) {
return NextResponse.rewrite(
new URL(`/node/${firstNode.id}`, request.url),
);
}
return NextResponse.rewrite(new URL('/node', request.url));
}
}
// 页面上报
const pages = Object.keys(StatPage);
if (pages.includes(page) || pages.includes(id)) {
postShareV1StatPage(
{
scene: StatPage[page as keyof typeof StatPage],
node_id: id || '',
},
{
headers: {
'x-pw-session-id': session,
...headers,
},
},
);
}
return NextResponse.next();
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
error.message === 'NEXT_REDIRECT'
) {
return NextResponse.redirect(
new URL(
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
request.url,
),
);
}
}
return NextResponse.next();
};
const proxyShare = async (request: NextRequest) => {
// 转发到 process.env.TARGET
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
const targetOrigin = process.env.TARGET!;
const targetUrl = new URL(
request.nextUrl.pathname + request.nextUrl.search,
targetOrigin,
);
// 构造 fetch 选项
const fetchHeaders = new Headers(request.headers);
fetchHeaders.set('x-kb-id', kb_id);
const hasBody = !['GET', 'HEAD'].includes(request.method);
const fetchOptions: RequestInit = {
method: request.method,
headers: fetchHeaders,
body: hasBody ? request.body : undefined,
redirect: 'manual',
...(hasBody && { duplex: 'half' as const }),
};
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
const nextRes = new NextResponse(proxyRes.body, {
status: proxyRes.status,
headers: proxyRes.headers,
statusText: proxyRes.statusText,
});
return nextRes;
};
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
const pathname = url.pathname;
if (pathname.startsWith('/widget')) {
const widgetInfo: any = await getShareV1AppWidgetInfo();
if (widgetInfo) {
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
return NextResponse.rewrite(new URL('/not-found', request.url));
}
}
return;
}
const headers: Record<string, string> = {};
for (const [key, value] of request.headers.entries()) {
headers[key] = value;
}
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
let needSetSessionId = false;
if (!sessionId) {
sessionId = uuidv4();
needSetSessionId = true;
}
let response: NextResponse;
if (pathname.startsWith('/share/')) {
response = await proxyShare(request);
} else {
response = await homeProxy(request, headers, sessionId);
}
if (needSetSessionId) {
response.cookies.set('x-pw-session-id', sessionId, {
httpOnly: true,
maxAge: 60 * 60 * 24 * 365, // 1 年
});
}
if (!pathname.startsWith('/share')) {
response.headers.set('x-current-path', pathname);
response.headers.set('x-current-search', url.search);
}
return response;
}
export const config = {
matcher: [
'/',
'/home',
'/share/:path*',
'/chat/:path*',
'/widget',
'/welcome',
'/auth/login',
'/node/:path*',
'/node',
],
};

View File

@ -18,7 +18,6 @@ import {
DomainOpenAICompletionsResponse,
DomainResponse,
PostShareV1ChatMessageParams,
PostShareV1ChatWidgetParams,
} from "./types";
/**
@ -92,28 +91,3 @@ export const postShareV1ChatMessage = (
format: "json",
...params,
});
/**
* @description ChatWidget
*
* @tags share_chat
* @name PostShareV1ChatWidget
* @summary ChatWidget
* @request POST:/share/v1/chat/widget
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatWidget = (
query: PostShareV1ChatWidgetParams,
request: DomainChatRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/widget`,
method: "POST",
query: query,
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -0,0 +1,75 @@
/* 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 {
DomainChatRequest,
DomainChatSearchReq,
DomainChatSearchResp,
DomainResponse,
PostShareV1ChatWidgetParams,
} from "./types";
/**
* @description ChatWidget
*
* @tags Widget
* @name PostShareV1ChatWidget
* @summary ChatWidget
* @request POST:/share/v1/chat/widget
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatWidget = (
query: PostShareV1ChatWidgetParams,
request: DomainChatRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/widget`,
method: "POST",
query: query,
body: request,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description WidgetSearch
*
* @tags Widget
* @name PostShareV1ChatWidgetSearch
* @summary WidgetSearch
* @request POST:/share/v1/chat/widget/search
* @response `200` `(DomainResponse & {
data?: DomainChatSearchResp,
})` OK
*/
export const postShareV1ChatWidgetSearch = (
request: DomainChatSearchReq,
params: RequestParams = {},
) =>
httpRequest<
DomainResponse & {
data?: DomainChatSearchResp;
}
>({
path: `/share/v1/chat/widget/search`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
});

View File

@ -10,5 +10,6 @@ export * from './ShareNode'
export * from './ShareOpenapi'
export * from './ShareStat'
export * from './Wechat'
export * from './Widget'
export * from './types'

View File

@ -24,6 +24,7 @@ import {
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
@ -206,6 +207,32 @@ export const postShareProV1AuthLdap = (
...params,
});
/**
* @description
*
* @tags ShareAuth
* @name PostShareProV1AuthLogout
* @summary
* @request POST:/share/pro/v1/auth/logout
* @response `200` `(DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
})` OK
*/
export const postShareProV1AuthLogout = (params: RequestParams = {}) =>
httpRequest<
DomainPWResponse & {
data?: GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp;
}
>({
path: `/share/pro/v1/auth/logout`,
method: "POST",
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description OAuth登录
*

View File

@ -52,10 +52,12 @@ export enum ConstsSourceType {
export enum ConstsLicenseEdition {
/** 开源版 */
LicenseEditionFree = 0,
/** 联创版 */
LicenseEditionContributor = 1,
/** 专业版 */
LicenseEditionProfession = 1,
/** 企业版 */
LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
}
export enum ConstsContributeType {
@ -455,6 +457,11 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
any
>;
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
string,
any
>;
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
kb_id?: string;
redirect_url?: string;
@ -669,8 +676,6 @@ export interface GetApiProV1TokenListParams {
}
export interface PostApiV1LicensePayload {
/** license edition */
license_edition: "contributor" | "enterprise";
/** license type */
license_type: "file" | "code";
/**

View File

@ -171,14 +171,21 @@ export enum ConstsNodeAccessPerm {
NodeAccessPermClosed = "closed",
}
export enum ConstsModelSettingMode {
ModelSettingModeManual = "manual",
ModelSettingModeAuto = "auto",
}
/** @format int32 */
export enum ConstsLicenseEdition {
/** 开源版 */
LicenseEditionFree = 0,
/** 联创版 */
LicenseEditionContributor = 1,
/** 专业版 */
LicenseEditionProfession = 1,
/** 企业版 */
LicenseEditionEnterprise = 2,
/** 商业版 */
LicenseEditionBusiness = 3,
}
export enum ConstsHomePageSetting {
@ -922,6 +929,17 @@ export interface DomainMetricsConfig {
type?: string;
}
export interface DomainModelModeSetting {
/** 百智云 API Key */
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
/** 手动模式下嵌入模型是否更新 */
is_manual_embedding_updated?: boolean;
/** 模式: manual 或 auto */
mode?: ConstsModelSettingMode;
}
export interface DomainMoveNodeReq {
id: string;
kb_id: string;
@ -1171,6 +1189,17 @@ export interface DomainShareConversationMessage {
role?: SchemaRoleType;
}
export interface DomainShareNodeListItemResp {
emoji?: string;
id?: string;
name?: string;
parent_id?: string;
permissions?: DomainNodePermissions;
position?: number;
type?: DomainNodeType;
updated_at?: string;
}
export interface DomainSimpleAuth {
enabled?: boolean;
password?: string;
@ -1195,6 +1224,18 @@ export interface DomainStatPageReq {
scene: 1 | 2 | 3 | 4;
}
export interface DomainSwitchModeReq {
/** 百智云 API Key */
auto_mode_api_key?: string;
/** 自定义对话模型名称 */
chat_model?: string;
mode: "manual" | "auto";
}
export interface DomainSwitchModeResp {
message?: string;
}
export interface DomainTextConfig {
title?: string;
type?: string;
@ -1336,11 +1377,18 @@ export interface DomainWecomAIBotSettings {
}
export interface DomainWidgetBotSettings {
btn_id?: string;
btn_logo?: string;
btn_position?: string;
btn_style?: string;
btn_text?: string;
disclaimer?: string;
is_open?: boolean;
modal_position?: string;
placeholder?: string;
recommend_node_ids?: string[];
recommend_questions?: string[];
search_mode?: string;
theme_mode?: string;
}
@ -1646,6 +1694,7 @@ export interface V1ShareNodeDetailResp {
editor_id?: string;
id?: string;
kb_id?: string;
list?: DomainShareNodeListItemResp[];
meta?: DomainNodeMeta;
name?: string;
parent_id?: string;

View File

@ -117,7 +117,7 @@ const DocContent = ({
setCommentImages([]);
message.success(
appDetail?.web_app_comment_settings?.moderation_enable
? '正在审核中...'
? '评论已提交,请耐心等待审核'
: '评论成功',
);
} catch (error: any) {

File diff suppressed because it is too large Load Diff

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