mirror of https://github.com/chaitin/PandaWiki.git
Compare commits
No commits in common. "e9d30eb3d4e2d76fbbcdd5aa9286c652ca01b71b" and "63a4a964b14fcb3f4856be3c2b84150c5620d581" have entirely different histories.
e9d30eb3d4
...
63a4a964b1
|
|
@ -24,5 +24,4 @@ type ShareNodeDetailResp struct {
|
|||
CreatorAccount string `json:"creator_account"`
|
||||
EditorAccount string `json:"editor_account"`
|
||||
PublisherAccount string `json:"publisher_account"`
|
||||
List []*domain.ShareNodeListItemResp `json:"list" gorm:"-"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
//go:build wireinject
|
||||
// +build wireinject
|
||||
|
||||
package main
|
||||
|
||||
|
|
|
|||
|
|
@ -3478,7 +3478,7 @@ const docTemplate = `{
|
|||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Widget"
|
||||
"share_chat"
|
||||
],
|
||||
"summary": "ChatWidget",
|
||||
"parameters": [
|
||||
|
|
@ -3509,52 +3509,6 @@ 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",
|
||||
|
|
@ -6361,9 +6315,6 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.MessageContent": {
|
||||
"type": "object"
|
||||
},
|
||||
"domain.MessageFrom": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
|
|
@ -6765,9 +6716,6 @@ const docTemplate = `{
|
|||
"stream": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stream_options": {
|
||||
"$ref": "#/definitions/domain.OpenAIStreamOptions"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number"
|
||||
},
|
||||
|
|
@ -6890,7 +6838,7 @@ const docTemplate = `{
|
|||
],
|
||||
"properties": {
|
||||
"content": {
|
||||
"$ref": "#/definitions/domain.MessageContent"
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
|
|
@ -6920,14 +6868,6 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.OpenAIStreamOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_usage": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.OpenAITool": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -7194,35 +7134,6 @@ 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": {
|
||||
|
|
@ -7740,33 +7651,15 @@ 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": {
|
||||
|
|
@ -7779,9 +7672,6 @@ const docTemplate = `{
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"search_mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"theme_mode": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -8650,12 +8540,6 @@ const docTemplate = `{
|
|||
"kb_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.ShareNodeListItemResp"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/domain.NodeMeta"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3471,7 +3471,7 @@
|
|||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Widget"
|
||||
"share_chat"
|
||||
],
|
||||
"summary": "ChatWidget",
|
||||
"parameters": [
|
||||
|
|
@ -3502,52 +3502,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/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",
|
||||
|
|
@ -6354,9 +6308,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.MessageContent": {
|
||||
"type": "object"
|
||||
},
|
||||
"domain.MessageFrom": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
|
|
@ -6758,9 +6709,6 @@
|
|||
"stream": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"stream_options": {
|
||||
"$ref": "#/definitions/domain.OpenAIStreamOptions"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number"
|
||||
},
|
||||
|
|
@ -6883,7 +6831,7 @@
|
|||
],
|
||||
"properties": {
|
||||
"content": {
|
||||
"$ref": "#/definitions/domain.MessageContent"
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
|
|
@ -6913,14 +6861,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"domain.OpenAIStreamOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_usage": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"domain.OpenAITool": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
|
@ -7187,35 +7127,6 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
|
|
@ -7733,33 +7644,15 @@
|
|||
"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": {
|
||||
|
|
@ -7772,9 +7665,6 @@
|
|||
"type": "string"
|
||||
}
|
||||
},
|
||||
"search_mode": {
|
||||
"type": "string"
|
||||
},
|
||||
"theme_mode": {
|
||||
"type": "string"
|
||||
}
|
||||
|
|
@ -8643,12 +8533,6 @@
|
|||
"kb_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/domain.ShareNodeListItemResp"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/definitions/domain.NodeMeta"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1614,8 +1614,6 @@ definitions:
|
|||
url:
|
||||
type: string
|
||||
type: object
|
||||
domain.MessageContent:
|
||||
type: object
|
||||
domain.MessageFrom:
|
||||
enum:
|
||||
- 1
|
||||
|
|
@ -1877,8 +1875,6 @@ definitions:
|
|||
type: array
|
||||
stream:
|
||||
type: boolean
|
||||
stream_options:
|
||||
$ref: '#/definitions/domain.OpenAIStreamOptions'
|
||||
temperature:
|
||||
type: number
|
||||
tool_choice:
|
||||
|
|
@ -1960,7 +1956,7 @@ definitions:
|
|||
domain.OpenAIMessage:
|
||||
properties:
|
||||
content:
|
||||
$ref: '#/definitions/domain.MessageContent'
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
role:
|
||||
|
|
@ -1981,11 +1977,6 @@ definitions:
|
|||
required:
|
||||
- type
|
||||
type: object
|
||||
domain.OpenAIStreamOptions:
|
||||
properties:
|
||||
include_usage:
|
||||
type: boolean
|
||||
type: object
|
||||
domain.OpenAITool:
|
||||
properties:
|
||||
function:
|
||||
|
|
@ -2159,25 +2150,6 @@ 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:
|
||||
|
|
@ -2520,24 +2492,12 @@ 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
|
||||
|
|
@ -2546,8 +2506,6 @@ definitions:
|
|||
items:
|
||||
type: string
|
||||
type: array
|
||||
search_mode:
|
||||
type: string
|
||||
theme_mode:
|
||||
type: string
|
||||
type: object
|
||||
|
|
@ -3121,10 +3079,6 @@ definitions:
|
|||
type: string
|
||||
kb_id:
|
||||
type: string
|
||||
list:
|
||||
items:
|
||||
$ref: '#/definitions/domain.ShareNodeListItemResp'
|
||||
type: array
|
||||
meta:
|
||||
$ref: '#/definitions/domain.NodeMeta'
|
||||
name:
|
||||
|
|
@ -5346,34 +5300,7 @@ paths:
|
|||
$ref: '#/definitions/domain.Response'
|
||||
summary: ChatWidget
|
||||
tags:
|
||||
- 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_chat
|
||||
/share/v1/comment:
|
||||
post:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -405,13 +405,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenAI API 请求结构体
|
||||
type OpenAICompletionsRequest struct {
|
||||
Model string `json:"model" validate:"required"`
|
||||
Messages []OpenAIMessage `json:"messages" validate:"required"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
StreamOptions *OpenAIStreamOptions `json:"stream_options,omitempty"`
|
||||
Temperature *float64 `json:"temperature,omitempty"`
|
||||
MaxTokens *int `json:"max_tokens,omitempty"`
|
||||
TopP *float64 `json:"top_p,omitempty"`
|
||||
|
|
@ -24,95 +17,9 @@ type OpenAICompletionsRequest struct {
|
|||
ResponseFormat *OpenAIResponseFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIStreamOptions struct {
|
||||
IncludeUsage bool `json:"include_usage,omitempty"`
|
||||
}
|
||||
|
||||
// MessageContent 支持字符串或内容数组
|
||||
type MessageContent struct {
|
||||
isString bool
|
||||
strValue string
|
||||
arrValue []OpenAIContentPart
|
||||
}
|
||||
|
||||
// OpenAIContentPart 表示内容数组中的单个元素
|
||||
type OpenAIContentPart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ImageURL *OpenAIContentPartURL `json:"image_url,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIContentPartURL represents the image_url field in content parts
|
||||
type OpenAIContentPartURL struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON 自定义解析,支持 string 或 array 格式
|
||||
func (mc *MessageContent) UnmarshalJSON(data []byte) error {
|
||||
// 尝试解析为字符串
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err == nil {
|
||||
mc.isString = true
|
||||
mc.strValue = str
|
||||
return nil
|
||||
}
|
||||
|
||||
// 尝试解析为数组
|
||||
var arr []OpenAIContentPart
|
||||
if err := json.Unmarshal(data, &arr); err == nil {
|
||||
mc.isString = false
|
||||
mc.arrValue = arr
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("content must be string or array")
|
||||
}
|
||||
|
||||
// MarshalJSON 自定义序列化
|
||||
func (mc *MessageContent) MarshalJSON() ([]byte, error) {
|
||||
if mc.isString {
|
||||
return json.Marshal(mc.strValue)
|
||||
}
|
||||
return json.Marshal(mc.arrValue)
|
||||
}
|
||||
|
||||
// NewStringContent 创建字符串类型的 MessageContent
|
||||
func NewStringContent(s string) *MessageContent {
|
||||
return &MessageContent{
|
||||
isString: true,
|
||||
strValue: s,
|
||||
}
|
||||
}
|
||||
|
||||
// NewArrayContent 创建数组类型的 MessageContent
|
||||
func NewArrayContent(parts []OpenAIContentPart) *MessageContent {
|
||||
return &MessageContent{
|
||||
isString: false,
|
||||
arrValue: parts,
|
||||
}
|
||||
}
|
||||
|
||||
// String 获取文本内容
|
||||
func (mc *MessageContent) String() string {
|
||||
if mc.isString {
|
||||
return mc.strValue
|
||||
}
|
||||
// 从数组中提取文本
|
||||
var builder strings.Builder
|
||||
for _, part := range mc.arrValue {
|
||||
if part.Type == "text" {
|
||||
if builder.Len() > 0 && part.Text != "" {
|
||||
builder.WriteString(" ")
|
||||
}
|
||||
builder.WriteString(part.Text)
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
type OpenAIMessage struct {
|
||||
Role string `json:"role" validate:"required"`
|
||||
Content *MessageContent `json:"content,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ToolCalls []OpenAIToolCall `json:"tool_calls,omitempty"`
|
||||
ToolCallID string `json:"tool_call_id,omitempty"`
|
||||
|
|
@ -183,7 +90,6 @@ type OpenAIStreamResponse struct {
|
|||
Created int64 `json:"created"`
|
||||
Model string `json:"model"`
|
||||
Choices []OpenAIStreamChoice `json:"choices"`
|
||||
Usage *OpenAIUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type OpenAIStreamChoice struct {
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMessageContent_UnmarshalJSON_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
expected string
|
||||
}{
|
||||
{"simple string", `"hello"`, "hello"},
|
||||
{"with quotes", `"say \"hello\""`, `say "hello"`},
|
||||
{"with newline", `"line1\nline2"`, "line1\nline2"},
|
||||
{"empty string", `""`, ""},
|
||||
{"unicode", `"你好 🌍"`, "你好 🌍"},
|
||||
{"special chars", `"Hello \"World\"\nNew Line\tTab"`, "Hello \"World\"\nNew Line\tTab"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var mc MessageContent
|
||||
err := json.Unmarshal([]byte(tt.json), &mc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, mc.String())
|
||||
assert.True(t, mc.isString)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContent_UnmarshalJSON_Array(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
"single text part",
|
||||
`[{"type":"text","text":"Hello"}]`,
|
||||
"Hello",
|
||||
},
|
||||
{
|
||||
"multiple text parts",
|
||||
`[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]`,
|
||||
"Hello World",
|
||||
},
|
||||
{
|
||||
"mixed types with image",
|
||||
`[{"type":"text","text":"Look at this"},{"type":"image_url","image_url":{"url":"https://example.com/img.png"}},{"type":"text","text":"image"}]`,
|
||||
"Look at this image",
|
||||
},
|
||||
{
|
||||
"empty array",
|
||||
`[]`,
|
||||
"",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var mc MessageContent
|
||||
err := json.Unmarshal([]byte(tt.json), &mc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, mc.String())
|
||||
assert.False(t, mc.isString)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContent_UnmarshalJSON_Invalid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"number", `123`},
|
||||
{"boolean", `true`},
|
||||
{"object", `{"key":"value"}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var mc MessageContent
|
||||
err := json.Unmarshal([]byte(tt.json), &mc)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "content must be string or array")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageContent_UnmarshalJSON_Null(t *testing.T) {
|
||||
var mc *MessageContent
|
||||
err := json.Unmarshal([]byte(`null`), &mc)
|
||||
assert.NoError(t, err)
|
||||
assert.Nil(t, mc)
|
||||
}
|
||||
|
||||
func TestMessageContent_MarshalJSON_String(t *testing.T) {
|
||||
mc := NewStringContent("Hello World")
|
||||
data, err := json.Marshal(mc)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"Hello World"`, string(data))
|
||||
}
|
||||
|
||||
func TestMessageContent_MarshalJSON_Array(t *testing.T) {
|
||||
mc := NewArrayContent([]OpenAIContentPart{
|
||||
{Type: "text", Text: "Hello"},
|
||||
{Type: "text", Text: "World"},
|
||||
})
|
||||
data, err := json.Marshal(mc)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `[{"type":"text","text":"Hello"},{"type":"text","text":"World"}]`, string(data))
|
||||
}
|
||||
|
||||
func TestMessageContent_Roundtrip_String(t *testing.T) {
|
||||
original := NewStringContent("Test message with \"quotes\" and \nnewlines")
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var decoded MessageContent
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, original.String(), decoded.String())
|
||||
assert.Equal(t, original.isString, decoded.isString)
|
||||
}
|
||||
|
||||
func TestMessageContent_Roundtrip_Array(t *testing.T) {
|
||||
parts := []OpenAIContentPart{
|
||||
{Type: "text", Text: "Part 1"},
|
||||
{Type: "text", Text: "Part 2"},
|
||||
}
|
||||
original := NewArrayContent(parts)
|
||||
|
||||
// Marshal
|
||||
data, err := json.Marshal(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unmarshal
|
||||
var decoded MessageContent
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify
|
||||
assert.Equal(t, original.String(), decoded.String())
|
||||
assert.Equal(t, original.isString, decoded.isString)
|
||||
}
|
||||
|
||||
func TestNewStringContent(t *testing.T) {
|
||||
mc := NewStringContent("test")
|
||||
assert.NotNil(t, mc)
|
||||
assert.True(t, mc.isString)
|
||||
assert.Equal(t, "test", mc.strValue)
|
||||
assert.Equal(t, "test", mc.String())
|
||||
}
|
||||
|
||||
func TestNewArrayContent(t *testing.T) {
|
||||
parts := []OpenAIContentPart{
|
||||
{Type: "text", Text: "Hello"},
|
||||
}
|
||||
mc := NewArrayContent(parts)
|
||||
assert.NotNil(t, mc)
|
||||
assert.False(t, mc.isString)
|
||||
assert.Equal(t, parts, mc.arrValue)
|
||||
assert.Equal(t, "Hello", mc.String())
|
||||
}
|
||||
|
||||
func TestMessageContent_String_EmptyArray(t *testing.T) {
|
||||
mc := NewArrayContent([]OpenAIContentPart{})
|
||||
assert.Equal(t, "", mc.String())
|
||||
}
|
||||
|
||||
func TestMessageContent_String_NoTextParts(t *testing.T) {
|
||||
mc := NewArrayContent([]OpenAIContentPart{
|
||||
{Type: "image_url", Text: ""},
|
||||
})
|
||||
assert.Equal(t, "", mc.String())
|
||||
}
|
||||
|
|
@ -49,7 +49,6 @@ require (
|
|||
github.com/sbzhu/weworkapi_golang v0.0.0-20210525081115-1799804a7c8d
|
||||
github.com/silenceper/wechat/v2 v2.1.9
|
||||
github.com/spf13/viper v1.20.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/swaggo/echo-swagger v1.4.1
|
||||
github.com/swaggo/swag v1.16.5
|
||||
github.com/tidwall/gjson v1.14.1
|
||||
|
|
@ -99,7 +98,6 @@ require (
|
|||
github.com/cloudwego/eino-ext/components/model/openai v0.0.0-20250710065240-482d48888f25 // indirect
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.0.0-20250626133421-3c142631c961 // indirect
|
||||
github.com/cohesion-org/deepseek-go v1.2.8 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dlclark/regexp2 v1.11.4 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
|
|
@ -167,7 +165,6 @@ require (
|
|||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ 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
|
||||
}
|
||||
|
|
@ -132,7 +131,7 @@ func (h *ShareChatHandler) ChatMessage(c echo.Context) error {
|
|||
//
|
||||
// @Summary ChatWidget
|
||||
// @Description ChatWidget
|
||||
// @Tags Widget
|
||||
// @Tags share_chat
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param app_type query string true "app type"
|
||||
|
|
@ -269,9 +268,7 @@ func (h *ShareChatHandler) ChatCompletions(c echo.Context) error {
|
|||
var lastUserMessage string
|
||||
for i := len(req.Messages) - 1; i >= 0; i-- {
|
||||
if req.Messages[i].Role == "user" {
|
||||
if req.Messages[i].Content != nil {
|
||||
lastUserMessage = req.Messages[i].Content.String()
|
||||
}
|
||||
lastUserMessage = req.Messages[i].Content
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
@ -348,12 +345,11 @@ func (h *ShareChatHandler) handleOpenAIStreamResponse(c echo.Context, eventCh <-
|
|||
Index: 0,
|
||||
Delta: domain.OpenAIMessage{
|
||||
Role: "assistant",
|
||||
Content: domain.NewStringContent(event.Content),
|
||||
Content: event.Content,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if err := h.writeOpenAIStreamEvent(c, streamResp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -401,7 +397,7 @@ func (h *ShareChatHandler) handleOpenAINonStreamResponse(c echo.Context, eventCh
|
|||
Index: 0,
|
||||
Message: domain.OpenAIMessage{
|
||||
Role: "assistant",
|
||||
Content: domain.NewStringContent(content),
|
||||
Content: content,
|
||||
},
|
||||
FinishReason: "stop",
|
||||
},
|
||||
|
|
@ -445,7 +441,7 @@ func stringPtr(s string) *string {
|
|||
return &s
|
||||
}
|
||||
|
||||
// ChatSearch searches chat messages in shared knowledge base
|
||||
// ChatMessage chat search
|
||||
//
|
||||
// @Summary ChatSearch
|
||||
// @Description ChatSearch
|
||||
|
|
@ -488,43 +484,3 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,15 +91,5 @@ 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit bb1b17dd5c7d72d40f6a1198b1604f4d3c44116e
|
||||
Subproject commit bcf7e0f0bedb18f43cf36463ddb45ace6c1dbab9
|
||||
|
|
@ -350,56 +350,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -588,7 +588,6 @@ export type ChatConversationItem = {
|
|||
export type ChatConversationPair = {
|
||||
user: string;
|
||||
assistant: string;
|
||||
thinking_content: string;
|
||||
created_at: string;
|
||||
info: {
|
||||
feedback_content: string;
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -262,7 +262,7 @@ const MemberAdd = ({
|
|||
MenuProps={{
|
||||
sx: {
|
||||
'.Mui-disabled': {
|
||||
opacity: '1 !important',
|
||||
opacity: 1,
|
||||
color: 'text.disabled',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,169 +13,10 @@ 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,
|
||||
|
|
@ -214,11 +55,7 @@ const Detail = ({
|
|||
};
|
||||
} else if (message.role === 'assistant') {
|
||||
if (currentPair.user) {
|
||||
const { thinkingContent, answerContent } = handleThinkingContent(
|
||||
message.content || '',
|
||||
);
|
||||
currentPair.assistant = answerContent;
|
||||
currentPair.thinking_content = thinkingContent;
|
||||
currentPair.assistant = message.content;
|
||||
currentPair.created_at = message.created_at;
|
||||
// @ts-expect-error 类型不兼容
|
||||
currentPair.info = message.info;
|
||||
|
|
@ -330,43 +167,26 @@ const Detail = ({
|
|||
<Stack gap={2}>
|
||||
{conversations &&
|
||||
conversations.map((item, index) => (
|
||||
<StyledConversationItem key={index}>
|
||||
{/* 用户问题气泡 - 右对齐 */}
|
||||
<StyledUserBubble>{item.user}</StyledUserBubble>
|
||||
|
||||
{/* AI回答气泡 - 左对齐 */}
|
||||
<StyledAiBubble>
|
||||
{/* 思考过程 */}
|
||||
{!!item.thinking_content && (
|
||||
<StyledThinkingAccordion defaultExpanded>
|
||||
<StyledThinkingAccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
<Box key={index}>
|
||||
<Accordion defaultExpanded={true}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
|
||||
sx={{
|
||||
userSelect: 'text',
|
||||
backgroundColor: 'background.paper3',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{item.user}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<MarkDown
|
||||
content={item.assistant || '未查询到回答内容'}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -446,19 +446,15 @@ const Content = () => {
|
|||
>
|
||||
{ragReStartCount} 个文档未学习,
|
||||
</Box>
|
||||
<ButtonBase
|
||||
disableRipple
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
color: 'primary.main',
|
||||
}}
|
||||
<Button
|
||||
size='small'
|
||||
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
|
||||
onClick={() => {
|
||||
setRagOpen(true);
|
||||
}}
|
||||
>
|
||||
去学习
|
||||
</ButtonBase>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -3,18 +3,14 @@ import { getApiV1ConversationMessageDetail } from '@/request';
|
|||
import MarkDown from '@/components/MarkDown';
|
||||
import { useAppSelector } from '@/store';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
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';
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
} from '@mui/material';
|
||||
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const Detail = ({
|
||||
id,
|
||||
|
|
@ -40,7 +36,6 @@ const Detail = ({
|
|||
user: data.question,
|
||||
assistant: res.content!,
|
||||
created_at: res.created_at!,
|
||||
thinking_content: '',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -67,43 +62,24 @@ const Detail = ({
|
|||
>
|
||||
<Box sx={{ fontSize: 14 }}>
|
||||
<Box>
|
||||
<StyledConversationItem>
|
||||
{/* 用户问题气泡 - 右对齐 */}
|
||||
<StyledUserBubble>{conversations?.user}</StyledUserBubble>
|
||||
|
||||
{/* AI回答气泡 - 左对齐 */}
|
||||
<StyledAiBubble>
|
||||
{/* 思考过程 */}
|
||||
{!!conversations?.thinking_content && (
|
||||
<StyledThinkingAccordion defaultExpanded>
|
||||
<StyledThinkingAccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||
<Accordion defaultExpanded={true}>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
|
||||
sx={{
|
||||
userSelect: 'text',
|
||||
backgroundColor: 'background.paper3',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{conversations?.user}
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<MarkDown
|
||||
content={conversations?.assistant || '未查询到回答内容'}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
</Box>
|
||||
</Box>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
|
|||
MenuProps={{
|
||||
sx: {
|
||||
'.Mui-disabled': {
|
||||
opacity: '1 !important',
|
||||
opacity: 1,
|
||||
color: 'text.disabled',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -873,7 +873,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
|
|||
MenuProps={{
|
||||
sx: {
|
||||
'.Mui-disabled': {
|
||||
opacity: '1 !important',
|
||||
opacity: 1,
|
||||
color: 'text.disabled',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -9,11 +9,8 @@ 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,
|
||||
|
|
@ -34,8 +31,6 @@ 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,
|
||||
|
|
@ -48,15 +43,8 @@ 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[],
|
||||
},
|
||||
|
|
@ -66,8 +54,6 @@ 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,
|
||||
|
|
@ -101,17 +87,8 @@ 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 || '',
|
||||
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 || '',
|
||||
btn_logo: res.settings?.widget_bot_settings?.btn_logo,
|
||||
recommend_questions:
|
||||
res.settings?.widget_bot_settings?.recommend_questions || [],
|
||||
recommend_node_ids:
|
||||
|
|
@ -131,15 +108,8 @@ 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 || [],
|
||||
},
|
||||
|
|
@ -181,7 +151,6 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
|||
</Link>
|
||||
}
|
||||
>
|
||||
<Stack spacing={3}>
|
||||
<FormItem label='网页挂件机器人'>
|
||||
<Controller
|
||||
control={control}
|
||||
|
|
@ -212,139 +181,10 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
|||
</FormItem>
|
||||
{isEnabled && (
|
||||
<>
|
||||
<FormItem
|
||||
label='嵌入代码'
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
labelSx={{ mt: 1 }}
|
||||
>
|
||||
{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',
|
||||
}}
|
||||
>
|
||||
<Icon type='icon-jinggao' />
|
||||
未配置域名,可在右侧
|
||||
<Box component={'span'} sx={{ fontWeight: 500 }}>
|
||||
服务监听方式
|
||||
</Box>{' '}
|
||||
中配置
|
||||
</Stack>
|
||||
)}
|
||||
</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 }}
|
||||
>
|
||||
<FormItem label='配色方案'>
|
||||
<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'
|
||||
name='theme_mode'
|
||||
render={({ field }) => (
|
||||
<RadioGroup
|
||||
row
|
||||
|
|
@ -355,43 +195,28 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
|||
}}
|
||||
>
|
||||
<FormControlLabel
|
||||
value='top_left'
|
||||
value={'light'}
|
||||
control={<Radio size='small' />}
|
||||
label={<Box sx={{ width: 100 }}>左上</Box>}
|
||||
label={<Box sx={{ width: 100 }}>浅色模式</Box>}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value='top_right'
|
||||
value={'dark'}
|
||||
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>}
|
||||
label={<Box sx={{ width: 100 }}>深色模式</Box>}
|
||||
/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
{btn_style !== 'hover_ball' && (
|
||||
<FormItem
|
||||
label='按钮文字'
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
labelSx={{ mt: 1 }}
|
||||
>
|
||||
<FormItem label='侧边按钮文字'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='btn_text'
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
fullWidth
|
||||
placeholder='输入按钮文字'
|
||||
{...field}
|
||||
placeholder='输入侧边按钮文字'
|
||||
error={!!errors.btn_text}
|
||||
helperText={errors.btn_text?.message}
|
||||
onChange={event => {
|
||||
|
|
@ -402,12 +227,7 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
|||
)}
|
||||
/>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormItem
|
||||
label='按钮图标'
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
labelSx={{ mt: 1 }}
|
||||
>
|
||||
<FormItem label='侧边按钮 Logo'>
|
||||
<Controller
|
||||
control={control}
|
||||
name='btn_logo'
|
||||
|
|
@ -426,186 +246,13 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
|||
)}
|
||||
/>
|
||||
</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 }}
|
||||
>
|
||||
<FormItem label='推荐问题'>
|
||||
<FreeSoloAutocomplete
|
||||
{...recommendQuestionsField}
|
||||
placeholder='回车确认,填写下一个推荐问题'
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label='推荐文档'
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
labelSx={{ mt: 1 }}
|
||||
>
|
||||
<FormItem label='推荐文档'>
|
||||
<RecommendDocDragList
|
||||
ids={recommend_node_ids}
|
||||
onChange={(value: string[]) => {
|
||||
|
|
@ -614,36 +261,36 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
|||
}}
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
label='免责声明'
|
||||
sx={{ alignItems: 'flex-start' }}
|
||||
labelSx={{ mt: 1 }}
|
||||
<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 }}
|
||||
>
|
||||
<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>
|
||||
<Icon type='icon-jinggao' />
|
||||
未配置域名,可在右侧
|
||||
<Box component={'span'} sx={{ fontWeight: 500 }}>
|
||||
服务监听方式
|
||||
</Box>{' '}
|
||||
中配置
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</SettingCardItem>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
|
||||
|
|
@ -207,32 +206,6 @@ 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登录
|
||||
*
|
||||
|
|
|
|||
|
|
@ -457,11 +457,6 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
|
|||
any
|
||||
>;
|
||||
|
||||
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
|
||||
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
|
||||
kb_id?: string;
|
||||
redirect_url?: string;
|
||||
|
|
|
|||
|
|
@ -171,11 +171,6 @@ export enum ConstsNodeAccessPerm {
|
|||
NodeAccessPermClosed = "closed",
|
||||
}
|
||||
|
||||
export enum ConstsModelSettingMode {
|
||||
ModelSettingModeManual = "manual",
|
||||
ModelSettingModeAuto = "auto",
|
||||
}
|
||||
|
||||
/** @format int32 */
|
||||
export enum ConstsLicenseEdition {
|
||||
/** 开源版 */
|
||||
|
|
@ -934,10 +929,8 @@ export interface DomainModelModeSetting {
|
|||
auto_mode_api_key?: string;
|
||||
/** 自定义对话模型名称 */
|
||||
chat_model?: string;
|
||||
/** 手动模式下嵌入模型是否更新 */
|
||||
is_manual_embedding_updated?: boolean;
|
||||
/** 模式: manual 或 auto */
|
||||
mode?: ConstsModelSettingMode;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export interface DomainMoveNodeReq {
|
||||
|
|
@ -1189,17 +1182,6 @@ 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;
|
||||
|
|
@ -1377,18 +1359,11 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -1694,7 +1669,6 @@ export interface V1ShareNodeDetailResp {
|
|||
editor_id?: string;
|
||||
id?: string;
|
||||
kb_id?: string;
|
||||
list?: DomainShareNodeListItemResp[];
|
||||
meta?: DomainNodeMeta;
|
||||
name?: string;
|
||||
parent_id?: string;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:22-alpine
|
||||
FROM node:20-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
|
|
|
|||
|
|
@ -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 proxy, otherwise reporting of client-
|
||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
||||
// side errors will fail.
|
||||
tunnelRoute: '/monitoring',
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3010",
|
||||
"dev": "next dev --turbopack -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.3.5",
|
||||
"@mui/material-nextjs": "^7.1.0",
|
||||
"@sentry/nextjs": "^10.8.0",
|
||||
"@types/markdown-it": "13.0.1",
|
||||
"@vscode/markdown-it-katex": "^1.1.2",
|
||||
|
|
@ -25,13 +25,12 @@
|
|||
"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": "^16.0.0",
|
||||
"next": "15.4.6",
|
||||
"react-device-detect": "^2.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
|
|
@ -42,23 +41,17 @@
|
|||
"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": "^16.0.0",
|
||||
"@next/eslint-plugin-next": "^15.4.5",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/rangy": "^1.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"eslint-config-next": "16.0.0",
|
||||
"eslint-config-next": "15.3.2",
|
||||
"eslint-config-prettier": "^9.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"require-in-the-middle": "^7.5.2"
|
||||
}
|
||||
}
|
||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,35 @@
|
|||
/* 挂件按钮基础样式 */
|
||||
/* 挂件按钮样式 - 基于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:not(.dragging) {
|
||||
.widget-bot-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.widget-bot-hover-ball:hover:not(.dragging) {
|
||||
transform: scale(1.1) !important;
|
||||
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2);
|
||||
}
|
||||
|
||||
.widget-bot-button.dragging {
|
||||
cursor: grabbing;
|
||||
transition: none !important;
|
||||
/* 拖拽时禁用过渡,提升性能 */
|
||||
/* transform 由 JS 控制,包含 rotate 和 translate */
|
||||
transform: rotate(2deg);
|
||||
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25);
|
||||
}
|
||||
|
||||
.widget-bot-button-content {
|
||||
|
|
@ -43,13 +39,14 @@
|
|||
color: inherit;
|
||||
}
|
||||
|
||||
/* 图标样式 */
|
||||
.widget-bot-icon {
|
||||
.widget-bot-logo {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* 文字样式 */
|
||||
.widget-bot-text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
|
@ -63,47 +60,6 @@
|
|||
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;
|
||||
|
|
@ -119,11 +75,6 @@
|
|||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.widget-bot-modal-fixed {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.widget-bot-modal-content {
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
|
|
@ -137,14 +88,6 @@
|
|||
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;
|
||||
|
|
@ -157,30 +100,34 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* 关闭按钮样式 - 透明框 */
|
||||
/* 关闭按钮样式 - 基于MUI IconButton */
|
||||
.widget-bot-close-btn {
|
||||
position: absolute;
|
||||
top: 22.5px;
|
||||
right: 16px;
|
||||
background: transparent;
|
||||
width: 36.26px;
|
||||
height: 25px;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: none;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-radius: 50%;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0;
|
||||
opacity: 1;
|
||||
font-size: 18px;
|
||||
opacity: 0.5;
|
||||
z-index: 10001;
|
||||
transition: none;
|
||||
transition: all 0.1s ease-in-out;
|
||||
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
pointer-events: none;
|
||||
/* 允许鼠标穿透到下方 */
|
||||
}
|
||||
|
||||
.widget-bot-close-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.widget-bot-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* iframe样式 */
|
||||
|
|
@ -193,11 +140,6 @@
|
|||
background: #F8F9FA;
|
||||
}
|
||||
|
||||
.widget-bot-modal-content-fixed .widget-bot-iframe {
|
||||
min-height: 600px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 防止页面滚动 */
|
||||
body.widget-bot-modal-open {
|
||||
overflow: hidden;
|
||||
|
|
@ -205,34 +147,19 @@ 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-side-sticky[data-theme="dark"] {
|
||||
background: #6E73FE;
|
||||
}
|
||||
|
||||
.widget-bot-side-sticky[data-theme="dark"]:hover {
|
||||
.widget-bot-button[data-theme="dark"]:hover {
|
||||
background: #5d68fd;
|
||||
box-shadow: 0 4px 8px rgba(110, 115, 254, 0.2);
|
||||
}
|
||||
|
||||
.widget-bot-side-sticky[data-theme="dark"].dragging {
|
||||
.widget-bot-button[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);
|
||||
}
|
||||
|
|
@ -242,63 +169,61 @@ body.widget-bot-modal-open {
|
|||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 移动端适配 - 统一处理 */
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.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-button {
|
||||
bottom: 16px;
|
||||
padding: 8px;
|
||||
border-radius: 10px 0 0 10px;
|
||||
}
|
||||
|
||||
.widget-bot-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.widget-bot-icon {
|
||||
.widget-bot-logo {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 移动端弹框统一居中显示,宽度100%-32px,高度90vh */
|
||||
.widget-bot-modal-content {
|
||||
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;
|
||||
width: calc(100% - 60.5px);
|
||||
height: 90%;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.widget-bot-close-btn {
|
||||
top: 22.5px;
|
||||
right: 16px;
|
||||
width: 36.26px;
|
||||
height: 25px;
|
||||
font-size: 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -349,32 +274,19 @@ body.widget-bot-modal-open {
|
|||
}
|
||||
|
||||
/* 浅色主题样式 - 显式定义 */
|
||||
.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"] {
|
||||
background: #3248F2;
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17);
|
||||
}
|
||||
|
||||
.widget-bot-side-sticky[data-theme="light"]:hover {
|
||||
background: #FFFFFF;
|
||||
.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"].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-button[data-theme="light"].dragging {
|
||||
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25);
|
||||
}
|
||||
|
||||
.widget-bot-modal[data-theme="light"] {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
(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;
|
||||
|
|
@ -15,13 +11,6 @@
|
|||
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) {
|
||||
|
|
@ -71,22 +60,13 @@
|
|||
applyTheme(widgetInfo.theme_mode);
|
||||
}
|
||||
|
||||
// 根据 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_style: defaultBtnStyle,
|
||||
btn_position: defaultBtnPosition,
|
||||
modal_position: defaultModalPosition,
|
||||
btn_logo: '',
|
||||
theme_mode: 'light'
|
||||
};
|
||||
applyTheme(widgetInfo.theme_mode);
|
||||
|
|
@ -98,148 +78,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
// 创建两行文字(每行两个字)
|
||||
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 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 widget-bot-side-sticky';
|
||||
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 以及 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 = 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', 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 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 createVerticalText(text) {
|
||||
return text.split('').map((char, index) =>
|
||||
`<span>${char}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// 创建挂件按钮
|
||||
|
|
@ -249,14 +92,49 @@
|
|||
widgetButton.remove();
|
||||
}
|
||||
|
||||
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
|
||||
// 创建按钮容器
|
||||
widgetButton = document.createElement('div');
|
||||
widgetButton.className = 'widget-bot-button';
|
||||
widgetButton.setAttribute('role', 'button');
|
||||
widgetButton.setAttribute('tabindex', '0');
|
||||
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text}窗口`);
|
||||
widgetButton.setAttribute('data-theme', currentTheme);
|
||||
|
||||
if (btnStyle === 'hover_ball') {
|
||||
createHoverBallButton();
|
||||
} else {
|
||||
createSideStickyButton();
|
||||
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);
|
||||
}
|
||||
|
||||
// 添加文字
|
||||
const textDiv = document.createElement('div');
|
||||
textDiv.className = 'widget-bot-text';
|
||||
textDiv.innerHTML = createVerticalText(widgetInfo.btn_text || '在线客服');
|
||||
buttonContent.appendChild(textDiv);
|
||||
|
||||
widgetButton.appendChild(buttonContent);
|
||||
|
||||
// 添加事件监听器
|
||||
widgetButton.addEventListener('click', showModal);
|
||||
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);
|
||||
|
||||
// 创建模态框
|
||||
createModal();
|
||||
|
||||
|
|
@ -267,109 +145,6 @@
|
|||
}, 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 === ' ') {
|
||||
|
|
@ -401,8 +176,7 @@
|
|||
Math.pow(touch.clientY - touchStartPos.y, 2)
|
||||
);
|
||||
|
||||
// 只有在没有拖拽且移动距离很小的情况下才认为是点击
|
||||
if (!hasDragged && distance < 10) {
|
||||
if (distance < 10) {
|
||||
// 判断为点击事件
|
||||
setTimeout(() => showModal(), 100);
|
||||
}
|
||||
|
|
@ -424,41 +198,22 @@
|
|||
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');
|
||||
|
||||
// 创建一个内部元素来处理实际的点击事件(因为按钮设置了 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);
|
||||
closeBtn.addEventListener('click', hideModal);
|
||||
|
||||
// 创建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');
|
||||
|
||||
|
|
@ -469,156 +224,6 @@
|
|||
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;
|
||||
|
|
@ -626,31 +231,27 @@
|
|||
widgetModal.style.display = 'flex';
|
||||
document.body.classList.add('widget-bot-modal-open');
|
||||
|
||||
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
|
||||
// 计算模态框位置
|
||||
requestAnimationFrame(() => {
|
||||
const buttonRect = widgetButton.getBoundingClientRect();
|
||||
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
||||
|
||||
// 移动端强制居中显示
|
||||
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);
|
||||
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';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 添加ESC键关闭功能
|
||||
document.addEventListener('keydown', handleEscKey);
|
||||
|
|
@ -686,98 +287,42 @@
|
|||
};
|
||||
|
||||
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);
|
||||
|
||||
// 记录拖拽开始位置
|
||||
dragStartPos.x = clientX;
|
||||
dragStartPos.y = clientY;
|
||||
dragOffset.x = clientX - rect.left;
|
||||
dragOffset.y = clientY - rect.top;
|
||||
|
||||
// 缓存按钮尺寸,避免拖拽过程中频繁读取
|
||||
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';
|
||||
// 清除bottom定位,使用top定位
|
||||
widgetButton.style.bottom = 'auto';
|
||||
widgetButton.style.top = rect.top + 'px';
|
||||
widgetButton.style.position = 'fixed';
|
||||
|
||||
// 禁用过渡效果,提升拖拽性能
|
||||
widgetButton.style.transition = 'none';
|
||||
|
||||
// 提示浏览器优化(使用 left/top 定位)
|
||||
widgetButton.style.willChange = 'left, top';
|
||||
|
||||
document.addEventListener('mousemove', drag, { passive: false });
|
||||
document.addEventListener('mousemove', drag);
|
||||
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;
|
||||
|
||||
// 垂直位置:限制在屏幕范围内,距离顶部和底部最小距离为 24px
|
||||
const minTop = 24;
|
||||
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
|
||||
const constrainedTop = Math.max(minTop, Math.min(newTop, maxTop));
|
||||
// 限制在屏幕范围内
|
||||
const constrainedTop = Math.max(0, 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';
|
||||
}
|
||||
|
||||
// 停止拖拽
|
||||
|
|
@ -785,75 +330,26 @@
|
|||
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';
|
||||
|
||||
// 恢复过渡效果
|
||||
widgetButton.style.transition = '';
|
||||
widgetButton.style.willChange = '';
|
||||
|
||||
// 根据按钮类型和当前位置进行最终定位
|
||||
// 吸附到右侧,恢复bottom定位
|
||||
requestAnimationFrame(() => {
|
||||
const buttonRect = widgetButton.getBoundingClientRect();
|
||||
const currentLeft = buttonRect.left;
|
||||
const currentTop = buttonRect.top;
|
||||
const windowWidth = window.innerWidth;
|
||||
const currentTop = parseInt(widgetButton.style.top);
|
||||
const windowHeight = window.innerHeight;
|
||||
const buttonWidth = buttonSize.width;
|
||||
const buttonHeight = buttonSize.height;
|
||||
const buttonHeight = widgetButton.offsetHeight;
|
||||
|
||||
// 两种模式使用相同的停止拖拽逻辑:只能左右侧边缘吸附
|
||||
// 根据按钮实际位置判断左右,保持当前位置
|
||||
const screenCenterX = windowWidth / 2;
|
||||
const buttonCenterX = currentLeft + buttonWidth / 2;
|
||||
const isLeftSide = buttonCenterX < screenCenterX;
|
||||
const sideDistance = 16; // 距离边缘的距离
|
||||
// 计算距离底部的位置
|
||||
const bottomPosition = windowHeight - currentTop - buttonHeight;
|
||||
|
||||
// 垂直位置:保持在当前位置,限制在屏幕范围内,距离顶部和底部最小距离为 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';
|
||||
// 恢复right和bottom定位,清除top
|
||||
widgetButton.style.right = '0';
|
||||
widgetButton.style.bottom = Math.max(20, bottomPosition) + 'px';
|
||||
widgetButton.style.top = 'auto';
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -894,30 +390,19 @@
|
|||
// 窗口大小改变时重新定位
|
||||
window.addEventListener('resize', function () {
|
||||
if (widgetModal && widgetModal.style.display === 'flex') {
|
||||
// 重新计算模态框位置
|
||||
setTimeout(() => {
|
||||
const buttonRect = widgetButton.getBoundingClientRect();
|
||||
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
||||
if (!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;
|
||||
if (modalContent) {
|
||||
const modalBottom = 24;
|
||||
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16);
|
||||
|
||||
modalContent.style.bottom = modalBottom + 'px';
|
||||
modalContent.style.right = modalRight + 'px';
|
||||
}
|
||||
|
||||
const modalPosition = widgetInfo?.modal_position || defaultModalPosition;
|
||||
if (modalPosition === 'fixed') {
|
||||
// 固定居中模式不需要重新定位
|
||||
return;
|
||||
}
|
||||
|
||||
// 重新计算模态框位置(使用智能定位)
|
||||
positionModalFollow(modalContent);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -938,13 +423,8 @@
|
|||
if (widgetModal) {
|
||||
widgetModal.remove();
|
||||
}
|
||||
if (customTriggerElement && customTriggerHandler) {
|
||||
customTriggerElement.removeEventListener('click', customTriggerHandler);
|
||||
customTriggerElement.removeAttribute('data-widget-trigger-attached');
|
||||
}
|
||||
});
|
||||
|
||||
// 启动
|
||||
init();
|
||||
})();
|
||||
|
||||
|
|
|
|||
|
|
@ -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/v16-appRouter';
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-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' : 'light'}`}
|
||||
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
>
|
||||
<AppRouterCacheProvider>
|
||||
<ThemeStoreProvider themeMode={themeMode}>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
--color-primary-main: #6e73fe;
|
||||
|
||||
/* 代码块颜色 */
|
||||
--code-bg: rgba(0, 0, 0, 0.03);
|
||||
--code-bg: #ffffff;
|
||||
--code-color: #21222d;
|
||||
--inline-code-bg: #fff5f5;
|
||||
--inline-code-color: #ff502c;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
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 ({
|
||||
|
|
@ -9,7 +12,18 @@ const Layout = async ({
|
|||
}>) => {
|
||||
const widgetDetail: any = await getShareV1AppWidgetInfo();
|
||||
|
||||
return <StoreProvider widget={widgetDetail}>{children}</StoreProvider>;
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,17 @@
|
|||
import Widget from '@/views/widget';
|
||||
import { Box } from '@mui/material';
|
||||
|
||||
export default Widget;
|
||||
const Page = () => {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
<Widget />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
|
|
|||
|
|
@ -109,19 +109,10 @@ export type WidgetInfo = {
|
|||
search_placeholder: string;
|
||||
recommend_questions: string[];
|
||||
widget_bot_settings: {
|
||||
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;
|
||||
btn_logo: string;
|
||||
btn_text: string;
|
||||
is_open: boolean;
|
||||
theme_mode: 'light' | 'dark';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import Logo from '@/assets/images/logo.png';
|
||||
import { Stack, Box, IconButton, alpha, Tooltip } from '@mui/material';
|
||||
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
|
||||
import { IconDengchu } from '@panda-wiki/icons';
|
||||
import { Box } from '@mui/material';
|
||||
import { useStore } from '@/provider';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
import ErrorIcon from '@mui/icons-material/Error';
|
||||
import { Modal } from '@ctzhian/ui';
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
Header as CustomHeader,
|
||||
WelcomeHeader as WelcomeHeaderComponent,
|
||||
|
|
@ -20,58 +16,8 @@ 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,
|
||||
authInfo,
|
||||
} = useStore();
|
||||
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
|
||||
const pathname = usePathname();
|
||||
const docWidth = useMemo(() => {
|
||||
if (isWelcomePage) return 'full';
|
||||
|
|
@ -109,23 +55,16 @@ const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
|
|||
onSearch={handleSearch}
|
||||
onQaClick={() => setQaModalOpen?.(true)}
|
||||
>
|
||||
<Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
|
||||
<Box sx={{ ml: 2 }}>
|
||||
<ThemeSwitch />
|
||||
{!!authInfo && <LogoutButton />}
|
||||
</Stack>
|
||||
</Box>
|
||||
<QaModal />
|
||||
</CustomHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export const WelcomeHeader = () => {
|
||||
const {
|
||||
mobile = false,
|
||||
kbDetail,
|
||||
catalogWidth,
|
||||
setQaModalOpen,
|
||||
authInfo,
|
||||
} = useStore();
|
||||
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
|
||||
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
|
||||
if (value?.trim()) {
|
||||
if (type === 'chat') {
|
||||
|
|
@ -152,7 +91,6 @@ export const WelcomeHeader = () => {
|
|||
onSearch={handleSearch}
|
||||
onQaClick={() => setQaModalOpen?.(true)}
|
||||
>
|
||||
<Box sx={{ ml: 2 }}>{!!authInfo && <LogoutButton />}</Box>
|
||||
<QaModal />
|
||||
</WelcomeHeaderComponent>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
imageBlobCache: Map<string, string>,
|
||||
): Promise<string> => {
|
||||
const fetchImageAsBlob = async (src: string): Promise<string> => {
|
||||
// 检查缓存
|
||||
if (imageBlobCache.has(src)) {
|
||||
return imageBlobCache.get(src)!;
|
||||
|
|
@ -39,8 +39,12 @@ const fetchImageAsBlob = async (
|
|||
}
|
||||
};
|
||||
|
||||
// 清理图片 blob 缓存
|
||||
export const clearImageBlobCache = (imageBlobCache: Map<string, string>) => {
|
||||
// 导出获取图片 blob URL 的函数
|
||||
export const getImageBlobUrl = (src: string): string | null => {
|
||||
return imageBlobCache.get(src) || null;
|
||||
};
|
||||
|
||||
export const clearImageBlobCache = () => {
|
||||
imageBlobCache.forEach(url => {
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
|
@ -50,7 +54,7 @@ export const clearImageBlobCache = (imageBlobCache: Map<string, string>) => {
|
|||
const StyledErrorContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
padding: theme.spacing(1, 6),
|
||||
padding: theme.spacing(2),
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
|
|
@ -67,7 +71,7 @@ const StyledErrorContainer = styled('div')(({ theme }) => ({
|
|||
|
||||
const StyledErrorText = styled('div')(() => ({
|
||||
fontSize: '12px',
|
||||
marginBottom: 10,
|
||||
marginBottom: 16,
|
||||
}));
|
||||
|
||||
export const ImageErrorIcon = (props: SvgIconProps) => {
|
||||
|
|
@ -98,7 +102,7 @@ export const ImageErrorIcon = (props: SvgIconProps) => {
|
|||
const ImageErrorDisplay: React.FC = () => (
|
||||
<StyledErrorContainer>
|
||||
<ImageErrorIcon
|
||||
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 140 }}
|
||||
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 160 }}
|
||||
/>
|
||||
<StyledErrorText>图片加载失败</StyledErrorText>
|
||||
</StyledErrorContainer>
|
||||
|
|
@ -112,7 +116,7 @@ interface ImageComponentProps {
|
|||
imageIndex: number;
|
||||
onLoad: (index: number, html: string) => void;
|
||||
onError: (index: number, html: string) => void;
|
||||
imageBlobCache: Map<string, string>;
|
||||
onImageClick: (src: string) => void;
|
||||
}
|
||||
|
||||
// ==================== 图片组件 ====================
|
||||
|
|
@ -123,7 +127,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
|||
imageIndex,
|
||||
onLoad,
|
||||
onError,
|
||||
imageBlobCache,
|
||||
onImageClick,
|
||||
}) => {
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
|
||||
'loading',
|
||||
|
|
@ -145,7 +149,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
|||
// 获取图片 blob URL
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
fetchImageAsBlob(src, imageBlobCache)
|
||||
fetchImageAsBlob(src)
|
||||
.then(url => {
|
||||
if (mounted) {
|
||||
setBlobUrl(url);
|
||||
|
|
@ -162,7 +166,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
|||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [src, imageBlobCache]);
|
||||
}, [src]);
|
||||
|
||||
// 解析自定义样式
|
||||
const parseStyleString = (styleStr: string) => {
|
||||
|
|
@ -234,8 +238,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
|||
referrerPolicy='no-referrer'
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
data-original-src={src}
|
||||
className='markdown-image'
|
||||
onClick={() => onImageClick(src)} // 传递原始 src 用于预览
|
||||
{...getOtherProps()}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -261,13 +264,12 @@ 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, imageBlobCache } =
|
||||
options;
|
||||
const { onImageLoad, onImageError, imageRenderCache, onImageClick } = options;
|
||||
return (
|
||||
src: string,
|
||||
alt: string,
|
||||
|
|
@ -277,6 +279,29 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -298,7 +323,7 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
|
|||
imageIndex={imageIndex}
|
||||
onLoad={onImageLoad}
|
||||
onError={onImageError}
|
||||
imageBlobCache={imageBlobCache}
|
||||
onImageClick={onImageClick}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import React, {
|
|||
useState,
|
||||
} from 'react';
|
||||
import { useSmartScroll } from '@/hooks';
|
||||
import { clearImageBlobCache, createImageRenderer } from './imageRenderer';
|
||||
import {
|
||||
clearImageBlobCache,
|
||||
createImageRenderer,
|
||||
getImageBlobUrl,
|
||||
} from './imageRenderer';
|
||||
import { incrementalRender } from './incrementalRenderer';
|
||||
import { createMermaidRenderer } from './mermaidRenderer';
|
||||
import {
|
||||
|
|
@ -84,8 +88,7 @@ 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()); // 图片渲染缓存(HTML)
|
||||
const imageBlobCacheRef = useRef<Map<string, string>>(new Map()); // 图片 blob URL 缓存
|
||||
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存
|
||||
|
||||
// 使用智能滚动 hook
|
||||
const { scrollToBottom } = useSmartScroll({
|
||||
|
|
@ -122,8 +125,13 @@ 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],
|
||||
);
|
||||
|
|
@ -150,7 +158,6 @@ 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++;
|
||||
|
|
@ -233,38 +240,6 @@ 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,
|
||||
|
|
@ -303,21 +278,6 @@ 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);
|
||||
|
|
@ -341,21 +301,6 @@ 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);
|
||||
|
|
@ -407,7 +352,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
|||
}
|
||||
}, [content, customizeRenderer, scrollToBottom]);
|
||||
|
||||
// 添加代码块点击复制和图片点击预览功能(事件代理)
|
||||
// 添加代码块点击复制功能
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
|
@ -415,21 +360,6 @@ 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) {
|
||||
|
|
@ -438,7 +368,6 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
|||
const code = codeElement.textContent || '';
|
||||
copyText(code.replace(/\n$/, ''));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否点击了行内代码
|
||||
|
|
@ -451,7 +380,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
|||
container.addEventListener('click', handleClick);
|
||||
|
||||
return () => {
|
||||
clearImageBlobCache(imageBlobCacheRef.current);
|
||||
clearImageBlobCache();
|
||||
container.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -477,9 +406,6 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
|||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
},
|
||||
'.markdown-image': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'.image-error': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
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*',
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
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();
|
||||
}
|
||||
|
|
@ -35,10 +35,6 @@ 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>
|
||||
|
|
|
|||
|
|
@ -1,181 +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 { 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',
|
||||
],
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ import {
|
|||
DomainOpenAICompletionsResponse,
|
||||
DomainResponse,
|
||||
PostShareV1ChatMessageParams,
|
||||
PostShareV1ChatWidgetParams,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
|
|
@ -91,3 +92,28 @@ 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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,75 +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 {
|
||||
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,
|
||||
});
|
||||
|
|
@ -10,6 +10,5 @@ export * from './ShareNode'
|
|||
export * from './ShareOpenapi'
|
||||
export * from './ShareStat'
|
||||
export * from './Wechat'
|
||||
export * from './Widget'
|
||||
export * from './types'
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import {
|
|||
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
|
||||
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
|
||||
|
|
@ -207,32 +206,6 @@ 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登录
|
||||
*
|
||||
|
|
|
|||
|
|
@ -52,12 +52,10 @@ export enum ConstsSourceType {
|
|||
export enum ConstsLicenseEdition {
|
||||
/** 开源版 */
|
||||
LicenseEditionFree = 0,
|
||||
/** 专业版 */
|
||||
LicenseEditionProfession = 1,
|
||||
/** 联创版 */
|
||||
LicenseEditionContributor = 1,
|
||||
/** 企业版 */
|
||||
LicenseEditionEnterprise = 2,
|
||||
/** 商业版 */
|
||||
LicenseEditionBusiness = 3,
|
||||
}
|
||||
|
||||
export enum ConstsContributeType {
|
||||
|
|
@ -457,11 +455,6 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
|
|||
any
|
||||
>;
|
||||
|
||||
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
|
||||
string,
|
||||
any
|
||||
>;
|
||||
|
||||
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
|
||||
kb_id?: string;
|
||||
redirect_url?: string;
|
||||
|
|
@ -676,6 +669,8 @@ export interface GetApiProV1TokenListParams {
|
|||
}
|
||||
|
||||
export interface PostApiV1LicensePayload {
|
||||
/** license edition */
|
||||
license_edition: "contributor" | "enterprise";
|
||||
/** license type */
|
||||
license_type: "file" | "code";
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -171,21 +171,14 @@ export enum ConstsNodeAccessPerm {
|
|||
NodeAccessPermClosed = "closed",
|
||||
}
|
||||
|
||||
export enum ConstsModelSettingMode {
|
||||
ModelSettingModeManual = "manual",
|
||||
ModelSettingModeAuto = "auto",
|
||||
}
|
||||
|
||||
/** @format int32 */
|
||||
export enum ConstsLicenseEdition {
|
||||
/** 开源版 */
|
||||
LicenseEditionFree = 0,
|
||||
/** 专业版 */
|
||||
LicenseEditionProfession = 1,
|
||||
/** 联创版 */
|
||||
LicenseEditionContributor = 1,
|
||||
/** 企业版 */
|
||||
LicenseEditionEnterprise = 2,
|
||||
/** 商业版 */
|
||||
LicenseEditionBusiness = 3,
|
||||
}
|
||||
|
||||
export enum ConstsHomePageSetting {
|
||||
|
|
@ -929,17 +922,6 @@ 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;
|
||||
|
|
@ -1189,17 +1171,6 @@ 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;
|
||||
|
|
@ -1224,18 +1195,6 @@ 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;
|
||||
|
|
@ -1377,18 +1336,11 @@ 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;
|
||||
}
|
||||
|
||||
|
|
@ -1694,7 +1646,6 @@ export interface V1ShareNodeDetailResp {
|
|||
editor_id?: string;
|
||||
id?: string;
|
||||
kb_id?: string;
|
||||
list?: DomainShareNodeListItemResp[];
|
||||
meta?: DomainNodeMeta;
|
||||
name?: string;
|
||||
parent_id?: string;
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -0,0 +1,142 @@
|
|||
import { IconArrowUp } from '@/components/icons';
|
||||
import { useStore } from '@/provider';
|
||||
import { Box, IconButton, TextField } from '@mui/material';
|
||||
import { useState } from 'react';
|
||||
import ChatLoading from '../chat/ChatLoading';
|
||||
import { AnswerStatus } from '../chat/constant';
|
||||
|
||||
interface ChatInputProps {
|
||||
loading: boolean;
|
||||
thinking: keyof typeof AnswerStatus;
|
||||
onSearch: (input: string) => void;
|
||||
handleSearchAbort: () => void;
|
||||
setThinking: (thinking: keyof typeof AnswerStatus) => void;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const ChatInput = ({
|
||||
loading,
|
||||
onSearch,
|
||||
thinking,
|
||||
setThinking,
|
||||
handleSearchAbort,
|
||||
placeholder,
|
||||
}: ChatInputProps) => {
|
||||
const { themeMode } = useStore();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const handleSearch = () => {
|
||||
if (input.length > 0) {
|
||||
onSearch(input);
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor:
|
||||
themeMode === 'dark' ? 'background.paper' : 'background.default',
|
||||
p: 2,
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
'.MuiInputBase-root': {
|
||||
p: 0,
|
||||
overflow: 'hidden',
|
||||
height: '52px !important',
|
||||
transition: 'all 0.5s ease-in-out',
|
||||
bgcolor:
|
||||
themeMode === 'dark' ? 'background.paper' : 'background.default',
|
||||
},
|
||||
textarea: {
|
||||
lineHeight: '26px',
|
||||
height: '52px !important',
|
||||
borderRadius: 0,
|
||||
transition: 'all 0.5s ease-in-out',
|
||||
'&::placeholder': {
|
||||
fontSize: 14,
|
||||
},
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
},
|
||||
fieldset: {
|
||||
border: 'none',
|
||||
},
|
||||
}}
|
||||
size='small'
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
const isComposing =
|
||||
e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229;
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
!e.shiftKey &&
|
||||
input.length > 0 &&
|
||||
!isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
autoComplete='off'
|
||||
slotProps={{
|
||||
input: {
|
||||
sx: {
|
||||
gap: 2,
|
||||
alignItems: loading ? 'flex-start' : 'flex-end',
|
||||
mr: loading ? 10 : 4,
|
||||
},
|
||||
endAdornment: (
|
||||
<Box
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<ChatLoading
|
||||
thinking={thinking}
|
||||
onClick={() => {
|
||||
setThinking(4);
|
||||
handleSearchAbort();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={() => {
|
||||
if (input.length > 0) {
|
||||
handleSearchAbort();
|
||||
setThinking(1);
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<IconArrowUp sx={{ fontSize: 12 }} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
'use client';
|
||||
|
||||
import { ConversationItem } from '@/assets/type';
|
||||
import Feedback from '@/components/feedback';
|
||||
import { IconCai, IconCaied, IconZan, IconZaned } from '@/components/icons';
|
||||
import MarkDown from '@/components/markdown';
|
||||
import { useStore } from '@/provider';
|
||||
import { postShareV1ChatFeedback } from '@/request/ShareChat';
|
||||
import { AnswerStatus } from '@/views/chat/constant';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
Skeleton,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import { message } from '@ctzhian/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import { useEffect, useState } from 'react';
|
||||
import ChatInput from './ChatInput';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
dayjs.locale('zh-cn');
|
||||
|
||||
interface ChatWindowProps {
|
||||
placeholder: string;
|
||||
conversation: ConversationItem[];
|
||||
conversation_id: string;
|
||||
setConversation: (conversation: ConversationItem[]) => void;
|
||||
answer: string;
|
||||
loading: boolean;
|
||||
thinking: keyof typeof AnswerStatus;
|
||||
onSearch: (input: string) => void;
|
||||
handleSearchAbort: () => void;
|
||||
setThinking: (thinking: keyof typeof AnswerStatus) => void;
|
||||
}
|
||||
|
||||
const ChatWindow = ({
|
||||
conversation,
|
||||
conversation_id,
|
||||
setConversation,
|
||||
answer,
|
||||
loading,
|
||||
thinking,
|
||||
onSearch,
|
||||
handleSearchAbort,
|
||||
setThinking,
|
||||
placeholder,
|
||||
}: ChatWindowProps) => {
|
||||
const [conversationItem, setConversationItem] =
|
||||
useState<ConversationItem | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { themeMode = 'light', widget, kbDetail } = useStore();
|
||||
|
||||
const handleScore = async (
|
||||
message_id: string,
|
||||
score: number,
|
||||
type?: string,
|
||||
content?: string,
|
||||
) => {
|
||||
const data: any = {
|
||||
conversation_id,
|
||||
message_id,
|
||||
score,
|
||||
};
|
||||
if (type) data.type = type;
|
||||
if (content) data.feedback_content = content;
|
||||
await postShareV1ChatFeedback(data);
|
||||
message.success('反馈成功');
|
||||
setConversation(
|
||||
conversation.map(item => {
|
||||
return item.message_id === message_id ? { ...item, score } : item;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const isFeedbackEnabled =
|
||||
// @ts-ignore
|
||||
kbDetail?.settings?.ai_feedback_settings?.is_enabled ?? true;
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const container = document.querySelector('.conversation-container');
|
||||
if (container) {
|
||||
container.scrollTo({
|
||||
top: container.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [answer, conversation]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
mb: 0,
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction='column'
|
||||
gap={2}
|
||||
className='conversation-container'
|
||||
sx={{
|
||||
overflow: 'auto',
|
||||
height: 'calc(100% - 100px)',
|
||||
}}
|
||||
>
|
||||
{conversation.map((item, index) => (
|
||||
<Box key={index}>
|
||||
<Accordion
|
||||
defaultExpanded={true}
|
||||
sx={{
|
||||
bgcolor:
|
||||
themeMode === 'dark'
|
||||
? 'background.default'
|
||||
: 'background.paper3',
|
||||
}}
|
||||
>
|
||||
<AccordionSummary
|
||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
|
||||
sx={{
|
||||
userSelect: 'text',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
fontWeight: '700',
|
||||
lineHeight: '24px',
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{item.q}
|
||||
</Box>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<MarkDown content={item.a} />
|
||||
{index === conversation.length - 1 && loading && !answer && (
|
||||
<>
|
||||
<Skeleton variant='text' width='100%' />
|
||||
<Skeleton variant='text' width='70%' />
|
||||
</>
|
||||
)}
|
||||
{index === conversation.length - 1 && answer && (
|
||||
<MarkDown content={answer} />
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
{(index !== conversation.length - 1 || !loading) && (
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='space-between'
|
||||
gap={3}
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'text.tertiary',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<Box>{kbDetail?.settings?.disclaimer_settings?.content}</Box>
|
||||
<Stack direction='row' gap={3} alignItems='center'>
|
||||
<span>生成于 {dayjs(item.update_time).fromNow()}</span>
|
||||
|
||||
{isFeedbackEnabled && (
|
||||
<>
|
||||
{item.score === 1 && (
|
||||
<IconZaned sx={{ cursor: 'pointer' }} />
|
||||
)}
|
||||
{item.score !== 1 && (
|
||||
<IconZan
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
if (item.score === 0)
|
||||
handleScore(item.message_id, 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.score !== -1 && (
|
||||
<IconCai
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
if (item.score === 0) {
|
||||
setConversationItem(item);
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.score === -1 && (
|
||||
<IconCaied sx={{ cursor: 'pointer' }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
<ChatInput
|
||||
onSearch={onSearch}
|
||||
thinking={thinking}
|
||||
loading={loading}
|
||||
handleSearchAbort={handleSearchAbort}
|
||||
setThinking={setThinking}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</Box>
|
||||
<Feedback
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onSubmit={handleScore}
|
||||
data={conversationItem}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWindow;
|
||||
|
|
@ -1,434 +0,0 @@
|
|||
'use client';
|
||||
import Logo from '@/assets/images/logo.png';
|
||||
import noDocImage from '@/assets/images/no-doc.png';
|
||||
import { useStore } from '@/provider';
|
||||
import { postShareV1ChatWidgetSearch } from '@/request';
|
||||
import { DomainNodeContentChunkSSE } from '@/request/types';
|
||||
import { message } from '@ctzhian/ui';
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
Skeleton,
|
||||
Stack,
|
||||
styled,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
IconFasong,
|
||||
IconJinsousuo,
|
||||
IconMianbaoxie,
|
||||
IconWenjian,
|
||||
} from '@panda-wiki/icons';
|
||||
import Image from 'next/image';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const StyledSearchResultItem = styled(Stack)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
borderBottom: '1px dashed',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
},
|
||||
'&::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
borderBottom: '1px dashed',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
},
|
||||
padding: theme.spacing(2),
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.02),
|
||||
'.hover-primary': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const SearchDocSkeleton = () => {
|
||||
return (
|
||||
<StyledSearchResultItem>
|
||||
<Stack gap={1}>
|
||||
<Skeleton variant='rounded' height={16} width={200} />
|
||||
<Skeleton variant='rounded' height={22} width={400} />
|
||||
<Skeleton variant='rounded' height={16} width={500} />
|
||||
</Stack>
|
||||
</StyledSearchResultItem>
|
||||
);
|
||||
};
|
||||
interface SearchDocContentProps {
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const SearchDocContent: React.FC<SearchDocContentProps> = ({
|
||||
inputRef,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { kbDetail } = useStore();
|
||||
// 模糊搜索相关状态
|
||||
const [fuzzySuggestions, setFuzzySuggestions] = useState<string[]>([]);
|
||||
const [showFuzzySuggestions, setShowFuzzySuggestions] = useState(false);
|
||||
const [input, setInput] = useState('');
|
||||
const [hasSearch, setHasSearch] = useState(false);
|
||||
// 搜索结果相关状态
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
DomainNodeContentChunkSSE[]
|
||||
>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
// 处理输入变化,显示模糊搜索建议
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setInput(value);
|
||||
|
||||
// if (value.trim().length > 0) {
|
||||
// // 改进的模糊搜索逻辑
|
||||
// const filtered = mockFuzzySuggestions
|
||||
// .filter(suggestion => {
|
||||
// const lowerSuggestion = suggestion.toLowerCase();
|
||||
// const lowerValue = value.toLowerCase();
|
||||
// // 支持前缀匹配和包含匹配
|
||||
// return (
|
||||
// lowerSuggestion.startsWith(lowerValue) ||
|
||||
// lowerSuggestion.includes(lowerValue)
|
||||
// );
|
||||
// })
|
||||
// .slice(0, 5); // 限制显示数量
|
||||
|
||||
// setFuzzySuggestions(filtered);
|
||||
// setShowFuzzySuggestions(true);
|
||||
// } else {
|
||||
// setShowFuzzySuggestions(false);
|
||||
// setFuzzySuggestions([]);
|
||||
// }
|
||||
};
|
||||
|
||||
// 选择模糊搜索建议
|
||||
const handleFuzzySuggestionClick = (suggestion: string) => {
|
||||
setInput(suggestion);
|
||||
setShowFuzzySuggestions(false);
|
||||
setFuzzySuggestions([]);
|
||||
};
|
||||
|
||||
// 执行搜索
|
||||
const handleSearch = async () => {
|
||||
if (isSearching) return;
|
||||
if (!input.trim()) return;
|
||||
|
||||
setIsSearching(true);
|
||||
setSearchResults([]);
|
||||
setShowFuzzySuggestions(false);
|
||||
setFuzzySuggestions([]);
|
||||
|
||||
let token = '';
|
||||
const Cap = (await import('@cap.js/widget')).default;
|
||||
const cap = new Cap({
|
||||
apiEndpoint: '/share/v1/captcha/',
|
||||
});
|
||||
try {
|
||||
const solution = await cap.solve();
|
||||
token = solution.token;
|
||||
} catch (error) {
|
||||
message.error('验证失败');
|
||||
console.log(error, 'error---------');
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
postShareV1ChatWidgetSearch({ message: input, captcha_token: token })
|
||||
.then(res => {
|
||||
setSearchResults(res.node_result || []);
|
||||
setHasSearch(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSearching(false);
|
||||
});
|
||||
};
|
||||
|
||||
// 处理搜索结果点击
|
||||
const handleSearchResultClick = (result: DomainNodeContentChunkSSE) => {
|
||||
window.open(`/node/${result.node_id}`, '_blank');
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// 高亮显示匹配的文本
|
||||
const highlightMatch = (text: string, query: string) => {
|
||||
if (!query.trim()) return text;
|
||||
|
||||
// 转义特殊字符,避免正则表达式错误
|
||||
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
const parts = text.split(regex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
// 检查是否匹配(不区分大小写)
|
||||
if (part.toLowerCase() === query.toLowerCase()) {
|
||||
return (
|
||||
<Box
|
||||
component='span'
|
||||
key={index}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
}}
|
||||
>
|
||||
{part}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems='center'
|
||||
justifyContent='center'
|
||||
gap={2}
|
||||
sx={{ mb: 3, mt: 1 }}
|
||||
>
|
||||
<Image
|
||||
src={kbDetail?.settings?.icon || Logo.src}
|
||||
alt='logo'
|
||||
width={46}
|
||||
height={46}
|
||||
unoptimized
|
||||
style={{
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
variant='h6'
|
||||
sx={{ fontSize: 32, color: 'text.primary', fontWeight: 700 }}
|
||||
>
|
||||
{kbDetail?.settings?.title}
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* 搜索输入框 */}
|
||||
<TextField
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
placeholder={placeholder}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
fullWidth
|
||||
autoFocus
|
||||
sx={theme => ({
|
||||
boxShadow: `0px 20px 40px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
borderRadius: 2,
|
||||
'& .MuiInputBase-root': {
|
||||
fontSize: 16,
|
||||
backgroundColor: theme.palette.background.default,
|
||||
'& fieldset': {
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
},
|
||||
'&:hover fieldset': {
|
||||
borderColor: 'primary.main',
|
||||
},
|
||||
'&.Mui-focused fieldset': {
|
||||
borderColor: `${theme.palette.primary.main} !important`,
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
'& .MuiInputBase-input': {
|
||||
py: 1.5,
|
||||
},
|
||||
})}
|
||||
slotProps={{
|
||||
input: {
|
||||
startAdornment: (
|
||||
<InputAdornment position='start'>
|
||||
<IconJinsousuo sx={{ fontSize: 20, color: 'text.secondary' }} />
|
||||
</InputAdornment>
|
||||
),
|
||||
endAdornment: (
|
||||
<InputAdornment position='end'>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={handleSearch}
|
||||
disabled={!input.trim() || isSearching}
|
||||
sx={{
|
||||
color: 'primary.main',
|
||||
'&:hover': { bgcolor: 'primary.lighter' },
|
||||
'&.Mui-disabled': { color: 'action.disabled' },
|
||||
}}
|
||||
>
|
||||
{isSearching ? (
|
||||
<CircularProgress size={20} />
|
||||
) : (
|
||||
<IconFasong
|
||||
sx={{
|
||||
fontSize: 22,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{/* 模糊搜索建议列表 */}
|
||||
{showFuzzySuggestions && fuzzySuggestions.length > 0 && (
|
||||
<Stack
|
||||
sx={{
|
||||
mt: 1,
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
gap={0.5}
|
||||
>
|
||||
{fuzzySuggestions.map((suggestion, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
onClick={() => handleFuzzySuggestionClick(suggestion)}
|
||||
sx={{
|
||||
py: 1,
|
||||
px: 2,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
bgcolor: 'transparent',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{highlightMatch(suggestion, input)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{/* 搜索结果列表 */}
|
||||
{searchResults.length > 0 && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
{/* 搜索结果统计 */}
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
mb: 2,
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
共找到 {searchResults.length} 个结果
|
||||
</Typography>
|
||||
|
||||
{/* 搜索结果列表 */}
|
||||
<Stack sx={{ overflow: 'auto', maxHeight: 'calc(100vh - 334px)' }}>
|
||||
{searchResults.map((result, index) => (
|
||||
<StyledSearchResultItem
|
||||
direction='row'
|
||||
justifyContent='space-between'
|
||||
alignItems='center'
|
||||
key={result.node_id}
|
||||
gap={2}
|
||||
onClick={() => handleSearchResultClick(result)}
|
||||
>
|
||||
<Stack sx={{ flex: 1, width: 0 }} gap={0.5}>
|
||||
{/* 路径 */}
|
||||
<Typography
|
||||
variant='caption'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
fontSize: 12,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{(result.node_path_names || []).length > 0
|
||||
? result.node_path_names?.join(' > ')
|
||||
: result.name}
|
||||
</Typography>
|
||||
|
||||
{/* 标题和图标 */}
|
||||
|
||||
<Typography
|
||||
variant='h6'
|
||||
className='hover-primary'
|
||||
sx={{
|
||||
gap: 0.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'text.primary',
|
||||
flex: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{result.emoji || <IconWenjian />} {result.name}
|
||||
</Typography>
|
||||
|
||||
{/* 描述 */}
|
||||
<Typography
|
||||
variant='body2'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{result.summary || '暂无摘要'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<IconMianbaoxie sx={{ fontSize: 12 }} />
|
||||
</StyledSearchResultItem>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{searchResults.length === 0 && !isSearching && hasSearch && (
|
||||
<Box sx={{ my: 5, textAlign: 'center' }}>
|
||||
<Image src={noDocImage} alt='暂无结果' width={250} />
|
||||
<Typography variant='body2' sx={{ color: 'text.tertiary' }}>
|
||||
暂无相关结果
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 搜索中状态 */}
|
||||
{isSearching && (
|
||||
<Stack sx={{ mt: 2 }}>
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<SearchDocSkeleton key={index} />
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDocContent;
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionSummary,
|
||||
Box,
|
||||
IconButton,
|
||||
Stack,
|
||||
TextField,
|
||||
alpha,
|
||||
styled,
|
||||
} from '@mui/material';
|
||||
|
||||
// 布局容器组件
|
||||
export const StyledMainContainer = styled(Box)(() => ({
|
||||
flex: 1,
|
||||
}));
|
||||
|
||||
export const StyledConversationContainer = styled(Stack)(() => ({
|
||||
maxHeight: 'calc(100vh - 332px)',
|
||||
overflow: 'auto',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 操作区域组件
|
||||
export const StyledActionStack = styled(Stack)(({ theme }) => ({
|
||||
fontSize: 12,
|
||||
color: alpha(theme.palette.text.primary, 0.35),
|
||||
}));
|
||||
|
||||
// 输入区域组件
|
||||
export const StyledInputContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
}));
|
||||
|
||||
export const StyledInputWrapper = styled(Stack)(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(1.5),
|
||||
paddingRight: theme.spacing(1.5),
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
borderRadius: '10px',
|
||||
border: '1px solid',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: theme.spacing(2),
|
||||
backgroundColor: theme.palette.background.default,
|
||||
boxShadow: `0px 20px 40px 0px ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
transition: 'border-color 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
'&:focus-within': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
// 图片预览组件
|
||||
export const StyledImagePreviewStack = styled(Stack)(() => ({
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
}));
|
||||
|
||||
export const StyledImagePreviewItem = styled(Box)(({ theme }) => ({
|
||||
position: 'relative',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
border: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
}));
|
||||
|
||||
export const StyledImageRemoveButton = styled(IconButton)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
border: '1px solid',
|
||||
borderColor: theme.palette.divider,
|
||||
transition: 'opacity 0.2s',
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
},
|
||||
}));
|
||||
|
||||
// 输入框组件
|
||||
export const StyledTextField = styled(TextField)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.background.default,
|
||||
'.MuiInputBase-root': {
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
height: '52px !important',
|
||||
},
|
||||
textarea: {
|
||||
borderRadius: 0,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
padding: '2px',
|
||||
},
|
||||
fieldset: {
|
||||
border: 'none',
|
||||
},
|
||||
}));
|
||||
|
||||
// 操作按钮组件
|
||||
export const StyledActionButtonStack = styled(Stack)(() => ({
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
// 搜索建议组件
|
||||
export const StyledFuzzySuggestionsStack = styled(Stack)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1),
|
||||
position: 'relative',
|
||||
zIndex: 1000,
|
||||
}));
|
||||
|
||||
export const StyledFuzzySuggestionItem = styled(Box)(({ theme }) => ({
|
||||
paddingTop: theme.spacing(1),
|
||||
paddingBottom: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.text.primary,
|
||||
'&:hover': {
|
||||
backgroundColor: theme.palette.action.hover,
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
}));
|
||||
|
||||
// 热门搜索组件
|
||||
export const StyledHotSearchStack = styled(Stack)(({ theme }) => ({
|
||||
marginTop: theme.spacing(2),
|
||||
}));
|
||||
|
||||
export const StyledHotSearchItem = styled(Box)(({ theme }) => ({
|
||||
paddingTop: theme.spacing(0.75),
|
||||
paddingBottom: theme.spacing(0.75),
|
||||
paddingLeft: theme.spacing(2),
|
||||
paddingRight: theme.spacing(2),
|
||||
marginBottom: theme.spacing(1),
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: alpha(theme.palette.text.primary, 0.02),
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.01)}`,
|
||||
color: alpha(theme.palette.text.primary, 0.75),
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
alignSelf: 'flex-start',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
width: 'auto',
|
||||
}));
|
||||
|
||||
// 热门搜索容器
|
||||
export const StyledHotSearchContainer = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
gap: theme.spacing(2),
|
||||
}));
|
||||
|
||||
// 热门搜索列
|
||||
export const StyledHotSearchColumn = styled(Box)(({ theme }) => ({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(1),
|
||||
paddingLeft: theme.spacing(2),
|
||||
borderLeft: `1px solid ${alpha(theme.palette.text.primary, 0.06)}`,
|
||||
}));
|
||||
|
||||
// 热门搜索列项目
|
||||
export const StyledHotSearchColumnItem = styled(Box)(({ theme }) => ({
|
||||
paddingRight: theme.spacing(2),
|
||||
borderRadius: '10px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
backgroundColor: 'transparent',
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
'&:hover': {
|
||||
color: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// 常量定义
|
||||
export const MAX_IMAGES = 9;
|
||||
export const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
export const CONVERSATION_MAX_HEIGHT = 'calc(100vh - 334px)';
|
||||
export const FUZZY_SUGGESTIONS_LIMIT = 5;
|
||||
|
||||
// 回答状态
|
||||
export const AnswerStatus = {
|
||||
1: '正在搜索结果...',
|
||||
2: '思考中...',
|
||||
3: '正在回答',
|
||||
4: '',
|
||||
} as const;
|
||||
|
||||
export type AnswerStatusType = keyof typeof AnswerStatus;
|
||||
|
||||
// CAP配置
|
||||
export const CAP_CONFIG = {
|
||||
apiEndpoint: '/share/v1/captcha/',
|
||||
wasmUrl: '/cap@0.0.6/cap_wasm.min.js',
|
||||
} as const;
|
||||
|
||||
// SSE配置
|
||||
export const SSE_CONFIG = {
|
||||
url: '/share/v1/chat/message',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import { useState, useCallback, useRef } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { ConversationItem } from '../types';
|
||||
import { ChunkResultItem } from '@/assets/type';
|
||||
|
||||
export const useConversation = () => {
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([]);
|
||||
const [fullAnswer, setFullAnswer] = useState<string>('');
|
||||
const [chunkResult, setChunkResult] = useState<ChunkResultItem[]>([]);
|
||||
const [thinkingContent, setThinkingContent] = useState<string>('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [isChunkResult, setIsChunkResult] = useState(false);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
|
||||
const messageIdRef = useRef('');
|
||||
|
||||
const addQuestion = useCallback(
|
||||
(q: string, reset: boolean = false) => {
|
||||
const newConversation = reset
|
||||
? []
|
||||
: conversation.some(item => item.source === 'history')
|
||||
? []
|
||||
: [...conversation];
|
||||
|
||||
newConversation.push({
|
||||
q,
|
||||
a: '',
|
||||
score: 0,
|
||||
message_id: '',
|
||||
update_time: '',
|
||||
source: 'chat',
|
||||
chunk_result: [],
|
||||
thinking_content: '',
|
||||
});
|
||||
|
||||
messageIdRef.current = '';
|
||||
setConversation(newConversation);
|
||||
setChunkResult([]);
|
||||
setThinkingContent('');
|
||||
setAnswer('');
|
||||
setFullAnswer('');
|
||||
},
|
||||
[conversation],
|
||||
);
|
||||
|
||||
const updateLastConversation = useCallback(() => {
|
||||
setAnswer(prevAnswer => {
|
||||
setThinkingContent(prevThinkingContent => {
|
||||
setChunkResult(prevChunkResult => {
|
||||
setConversation(prev => {
|
||||
const newConversation = [...prev];
|
||||
const lastConversation =
|
||||
newConversation[newConversation.length - 1];
|
||||
if (lastConversation) {
|
||||
lastConversation.a = prevAnswer;
|
||||
lastConversation.update_time = dayjs().format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
);
|
||||
lastConversation.message_id = messageIdRef.current;
|
||||
lastConversation.source = 'chat';
|
||||
lastConversation.chunk_result = prevChunkResult;
|
||||
lastConversation.thinking_content = prevThinkingContent;
|
||||
}
|
||||
return newConversation;
|
||||
});
|
||||
return prevChunkResult;
|
||||
});
|
||||
return prevThinkingContent;
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
setFullAnswer('');
|
||||
}, []);
|
||||
|
||||
const updateConversationScore = useCallback(
|
||||
(message_id: string, score: number) => {
|
||||
setConversation(prev =>
|
||||
prev.map(item =>
|
||||
item.message_id === message_id ? { ...item, score } : item,
|
||||
),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const resetConversation = useCallback(() => {
|
||||
setConversation([]);
|
||||
setChunkResult([]);
|
||||
setAnswer('');
|
||||
setFullAnswer('');
|
||||
setThinkingContent('');
|
||||
messageIdRef.current = '';
|
||||
}, []);
|
||||
|
||||
return {
|
||||
conversation,
|
||||
setConversation,
|
||||
fullAnswer,
|
||||
setFullAnswer,
|
||||
chunkResult,
|
||||
setChunkResult,
|
||||
thinkingContent,
|
||||
setThinkingContent,
|
||||
answer,
|
||||
setAnswer,
|
||||
isChunkResult,
|
||||
setIsChunkResult,
|
||||
isThinking,
|
||||
setIsThinking,
|
||||
messageIdRef,
|
||||
addQuestion,
|
||||
updateLastConversation,
|
||||
updateConversationScore,
|
||||
resetConversation,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { useState, useRef, useCallback } from 'react';
|
||||
import { message } from '@ctzhian/ui';
|
||||
import { UploadedImage } from '../types';
|
||||
import { MAX_IMAGES, MAX_IMAGE_SIZE } from '../constants';
|
||||
|
||||
export const useImageUpload = () => {
|
||||
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const cleanupImageUrls = useCallback((images: UploadedImage[]) => {
|
||||
images.forEach(img => {
|
||||
if (img.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(img.url);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleImageSelect = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const remainingSlots = MAX_IMAGES - uploadedImages.length;
|
||||
if (remainingSlots <= 0) {
|
||||
message.warning(`最多只能上传 ${MAX_IMAGES} 张图片`);
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToAdd = Array.from(files).slice(0, remainingSlots);
|
||||
const newImages: UploadedImage[] = [];
|
||||
|
||||
for (const file of filesToAdd) {
|
||||
if (!file.type.startsWith('image/')) {
|
||||
message.error('只支持上传图片文件');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
message.error('图片大小不能超过 10MB');
|
||||
continue;
|
||||
}
|
||||
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
newImages.push({
|
||||
id: Date.now().toString() + Math.random(),
|
||||
url: localUrl,
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
setUploadedImages(prev => [...prev, ...newImages]);
|
||||
},
|
||||
[uploadedImages.length],
|
||||
);
|
||||
|
||||
const handleImageUpload = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
handleImageSelect(event.target.files);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
[handleImageSelect],
|
||||
);
|
||||
|
||||
const handleRemoveImage = useCallback((id: string) => {
|
||||
setUploadedImages(prev => {
|
||||
const imageToRemove = prev.find(img => img.id === id);
|
||||
if (imageToRemove && imageToRemove.url.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(imageToRemove.url);
|
||||
}
|
||||
return prev.filter(img => img.id !== id);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
const dataTransfer = new DataTransfer();
|
||||
imageFiles.forEach(file => dataTransfer.items.add(file));
|
||||
await handleImageSelect(dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[handleImageSelect],
|
||||
);
|
||||
|
||||
const clearImages = useCallback(() => {
|
||||
cleanupImageUrls(uploadedImages);
|
||||
setUploadedImages([]);
|
||||
}, [uploadedImages, cleanupImageUrls]);
|
||||
|
||||
return {
|
||||
uploadedImages,
|
||||
fileInputRef,
|
||||
handleImageUpload,
|
||||
handleRemoveImage,
|
||||
handlePaste,
|
||||
clearImages,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,217 +0,0 @@
|
|||
import SSEClient from '@/utils/fetch';
|
||||
import { message } from '@ctzhian/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { AnswerStatusType, CAP_CONFIG, SSE_CONFIG } from '../constants';
|
||||
import { ChatRequestData, SSEMessageData } from '../types';
|
||||
import { handleThinkingContent } from '../utils';
|
||||
|
||||
interface UseSSEChatProps {
|
||||
conversationId: string;
|
||||
setConversationId: React.Dispatch<React.SetStateAction<string>>;
|
||||
nonce: string;
|
||||
setNonce: React.Dispatch<React.SetStateAction<string>>;
|
||||
messageIdRef: React.MutableRefObject<string>;
|
||||
setFullAnswer: React.Dispatch<React.SetStateAction<string>>;
|
||||
setAnswer: React.Dispatch<React.SetStateAction<string>>;
|
||||
setThinkingContent: React.Dispatch<React.SetStateAction<string>>;
|
||||
setChunkResult: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
setConversation: React.Dispatch<React.SetStateAction<any[]>>;
|
||||
setIsChunkResult: (value: boolean) => void;
|
||||
setIsThinking: (value: boolean) => void;
|
||||
setThinking: (value: AnswerStatusType) => void;
|
||||
setLoading: (value: boolean) => void;
|
||||
scrollToBottom: () => void;
|
||||
}
|
||||
|
||||
export const useSSEChat = ({
|
||||
conversationId,
|
||||
setConversationId,
|
||||
nonce,
|
||||
setNonce,
|
||||
messageIdRef,
|
||||
setFullAnswer,
|
||||
setAnswer,
|
||||
setThinkingContent,
|
||||
setChunkResult,
|
||||
setConversation,
|
||||
setIsChunkResult,
|
||||
setIsThinking,
|
||||
setThinking,
|
||||
setLoading,
|
||||
scrollToBottom,
|
||||
}: UseSSEChatProps) => {
|
||||
const sseClientRef = useRef<SSEClient<SSEMessageData> | null>(null);
|
||||
|
||||
const initializeSSE = useCallback(() => {
|
||||
sseClientRef.current = new SSEClient({
|
||||
url: SSE_CONFIG.url,
|
||||
headers: SSE_CONFIG.headers,
|
||||
onCancel: () => {
|
||||
setLoading(false);
|
||||
setThinking(4);
|
||||
setAnswer(prev => {
|
||||
let value = '';
|
||||
if (prev) {
|
||||
value = prev + '\n\n<error>Request canceled</error>';
|
||||
}
|
||||
setConversation(prev => {
|
||||
const newConversation = [...prev];
|
||||
if (newConversation[newConversation.length - 1]) {
|
||||
newConversation[newConversation.length - 1].a = value;
|
||||
newConversation[newConversation.length - 1].update_time =
|
||||
dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
newConversation[newConversation.length - 1].message_id =
|
||||
messageIdRef.current;
|
||||
}
|
||||
return newConversation;
|
||||
});
|
||||
return '';
|
||||
});
|
||||
},
|
||||
});
|
||||
}, [messageIdRef, setAnswer, setConversation, setLoading, setThinking]);
|
||||
|
||||
const chatAnswer = useCallback(
|
||||
async (q: string) => {
|
||||
setLoading(true);
|
||||
setThinking(1);
|
||||
|
||||
let token = '';
|
||||
try {
|
||||
const Cap = (await import('@cap.js/widget')).default;
|
||||
const cap = new Cap({ apiEndpoint: CAP_CONFIG.apiEndpoint });
|
||||
const solution = await cap.solve();
|
||||
token = solution.token;
|
||||
} catch (error) {
|
||||
message.error('验证失败');
|
||||
console.error('Captcha error:', error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const reqData: ChatRequestData = {
|
||||
message: q,
|
||||
nonce: nonce || '',
|
||||
conversation_id: conversationId || '',
|
||||
app_type: 1,
|
||||
captcha_token: token,
|
||||
};
|
||||
|
||||
if (sseClientRef.current) {
|
||||
sseClientRef.current.subscribe(
|
||||
JSON.stringify(reqData),
|
||||
({ type, content, chunk_result }) => {
|
||||
if (type === 'conversation_id') {
|
||||
setConversationId(prev => prev + content);
|
||||
} else if (type === 'message_id') {
|
||||
messageIdRef.current += content;
|
||||
} else if (type === 'nonce') {
|
||||
setNonce(prev => prev + content);
|
||||
} else if (type === 'error') {
|
||||
setLoading(false);
|
||||
setIsChunkResult(false);
|
||||
setIsThinking(false);
|
||||
setThinking(4);
|
||||
setAnswer(prev => {
|
||||
if (content) {
|
||||
return prev + `\n\n回答出现错误:<error>${content}</error>`;
|
||||
}
|
||||
return prev + '\n\n回答出现错误,请重试';
|
||||
});
|
||||
if (content) message.error(content);
|
||||
} else if (type === 'done') {
|
||||
setAnswer(prevAnswer => {
|
||||
setThinkingContent(prevThinkingContent => {
|
||||
setChunkResult(prevChunkResult => {
|
||||
setConversation(prev => {
|
||||
const newConversation = [...prev];
|
||||
const lastConversation =
|
||||
newConversation[newConversation.length - 1];
|
||||
if (lastConversation) {
|
||||
lastConversation.a = prevAnswer;
|
||||
lastConversation.update_time = dayjs().format(
|
||||
'YYYY-MM-DD HH:mm:ss',
|
||||
);
|
||||
lastConversation.message_id = messageIdRef.current;
|
||||
lastConversation.source = 'chat';
|
||||
lastConversation.chunk_result = prevChunkResult;
|
||||
lastConversation.thinking_content = prevThinkingContent;
|
||||
}
|
||||
return newConversation;
|
||||
});
|
||||
return prevChunkResult;
|
||||
});
|
||||
return prevThinkingContent;
|
||||
});
|
||||
return '';
|
||||
});
|
||||
|
||||
setFullAnswer('');
|
||||
setLoading(false);
|
||||
setIsChunkResult(false);
|
||||
setIsThinking(false);
|
||||
setThinking(4);
|
||||
} else if (type === 'data') {
|
||||
setIsChunkResult(false);
|
||||
setFullAnswer(prevFullAnswer => {
|
||||
const newFullAnswer = prevFullAnswer + content;
|
||||
const { thinkingContent, answerContent } =
|
||||
handleThinkingContent(newFullAnswer);
|
||||
|
||||
setThinkingContent(thinkingContent);
|
||||
setAnswer(answerContent);
|
||||
|
||||
if (newFullAnswer.includes('</think>')) {
|
||||
setIsThinking(false);
|
||||
setThinking(3);
|
||||
} else if (newFullAnswer.includes('<think>')) {
|
||||
setIsThinking(true);
|
||||
setThinking(2);
|
||||
} else {
|
||||
setThinking(3);
|
||||
}
|
||||
|
||||
return newFullAnswer;
|
||||
});
|
||||
} else if (type === 'chunk_result') {
|
||||
setChunkResult(prev => [...prev, chunk_result]);
|
||||
setIsChunkResult(true);
|
||||
setTimeout(scrollToBottom, 200);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
conversationId,
|
||||
nonce,
|
||||
messageIdRef,
|
||||
setConversationId,
|
||||
setNonce,
|
||||
setLoading,
|
||||
setThinking,
|
||||
setAnswer,
|
||||
setFullAnswer,
|
||||
setThinkingContent,
|
||||
setChunkResult,
|
||||
setConversation,
|
||||
setIsChunkResult,
|
||||
setIsThinking,
|
||||
scrollToBottom,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSearchAbort = useCallback(() => {
|
||||
sseClientRef.current?.unsubscribe();
|
||||
setLoading(false);
|
||||
setThinking(4);
|
||||
}, [setLoading, setThinking]);
|
||||
|
||||
return {
|
||||
sseClientRef,
|
||||
initializeSSE,
|
||||
chatAnswer,
|
||||
handleSearchAbort,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,222 +1,413 @@
|
|||
'use client';
|
||||
import { WidgetInfo } from '@/assets/type';
|
||||
|
||||
import { ChunkResultItem, ConversationItem } from '@/assets/type';
|
||||
import { IconFile, IconFolder, IconLogo } from '@/components/icons';
|
||||
import { useStore } from '@/provider';
|
||||
import {
|
||||
alpha,
|
||||
Box,
|
||||
Button,
|
||||
lighten,
|
||||
Stack,
|
||||
styled,
|
||||
Tab,
|
||||
Tabs,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { IconJinsousuo, IconZhinengwenda } from '@panda-wiki/icons';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import AiQaContent from './AiQaContent';
|
||||
import SearchDocContent from './SearchDocContent';
|
||||
|
||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
|
||||
minHeight: 'auto',
|
||||
position: 'relative',
|
||||
borderRadius: '10px',
|
||||
padding: theme.spacing(0.5),
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
|
||||
'& .MuiTabs-indicator': {
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
zIndex: 0,
|
||||
},
|
||||
'& .MuiTabs-flexContainer': {
|
||||
gap: theme.spacing(0.5),
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
// 样式化的 Tab 组件 - 白色背景,圆角,深灰色文字
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
minHeight: 'auto',
|
||||
padding: theme.spacing(0.75, 2),
|
||||
borderRadius: '6px',
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: 12,
|
||||
fontWeight: 400,
|
||||
textTransform: 'none',
|
||||
transition: 'color 0.3s ease-in-out',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
lineHeight: 1,
|
||||
'&:hover': {
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
'&.Mui-selected': {
|
||||
color: theme.palette.primary.contrastText,
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
import SSEClient from '@/utils/fetch';
|
||||
import { Box, Stack, useMediaQuery } from '@mui/material';
|
||||
import { Ellipsis, message } from '@ctzhian/ui';
|
||||
import dayjs from 'dayjs';
|
||||
import Link from 'next/link';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AnswerStatus } from '../chat/constant';
|
||||
import ChatInput from './ChatInput';
|
||||
import ChatWindow from './ChatWindow';
|
||||
import WaterMarkProvider from '@/components/watermark/WaterMarkProvider';
|
||||
|
||||
const Widget = () => {
|
||||
const { widget, mobile } = useStore();
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'));
|
||||
const { widget, themeMode } = useStore();
|
||||
|
||||
const defaultSearchMode = useMemo(() => {
|
||||
return widget?.settings?.widget_bot_settings?.search_mode || 'all';
|
||||
}, [widget]);
|
||||
const chatContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const sseClientRef = useRef<SSEClient<{
|
||||
type: string;
|
||||
content: string;
|
||||
chunk_result: ChunkResultItem[];
|
||||
}> | null>(null);
|
||||
|
||||
const [searchMode, setSearchMode] = useState<
|
||||
WidgetInfo['settings']['widget_bot_settings']['search_mode']
|
||||
>(defaultSearchMode !== 'doc' ? 'qa' : 'doc');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const aiQaInputRef = useRef<HTMLInputElement>(null);
|
||||
const messageIdRef = useRef<string>('');
|
||||
const [conversation, setConversation] = useState<ConversationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [thinking, setThinking] = useState<keyof typeof AnswerStatus>(4);
|
||||
const [nonce, setNonce] = useState('');
|
||||
const [conversationId, setConversationId] = useState('');
|
||||
const [answer, setAnswer] = useState('');
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
|
||||
const placeholder = useMemo(() => {
|
||||
return widget?.settings?.widget_bot_settings?.placeholder || '搜索...';
|
||||
}, [widget]);
|
||||
const chatAnswer = async (q: string) => {
|
||||
setLoading(true);
|
||||
setThinking(1);
|
||||
setIsUserScrolling(false);
|
||||
|
||||
const hotSearch = useMemo(() => {
|
||||
return widget?.settings?.widget_bot_settings?.recommend_questions || [];
|
||||
}, [widget]);
|
||||
const reqData = {
|
||||
message: q,
|
||||
nonce: '',
|
||||
conversation_id: '',
|
||||
app_type: 2,
|
||||
};
|
||||
if (conversationId) reqData.conversation_id = conversationId;
|
||||
if (nonce) reqData.nonce = nonce;
|
||||
|
||||
// modal打开时自动聚焦
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (searchMode === 'qa') {
|
||||
aiQaInputRef.current?.querySelector('textarea')?.focus();
|
||||
} else {
|
||||
inputRef.current?.querySelector('input')?.focus();
|
||||
if (sseClientRef.current) {
|
||||
sseClientRef.current.subscribe(
|
||||
JSON.stringify(reqData),
|
||||
({ type, content }) => {
|
||||
if (type === 'conversation_id') {
|
||||
setConversationId(prev => prev + content);
|
||||
} else if (type === 'message_id') {
|
||||
messageIdRef.current += content;
|
||||
} else if (type === 'nonce') {
|
||||
setNonce(prev => prev + content);
|
||||
} else if (type === 'error') {
|
||||
setLoading(false);
|
||||
setThinking(4);
|
||||
setAnswer(prev => {
|
||||
if (content) {
|
||||
return prev + `\n\n回答出现错误:<error>${content}</error>`;
|
||||
}
|
||||
}, 100);
|
||||
}, [searchMode]);
|
||||
return prev + '\n\n回答出现错误,请重试';
|
||||
});
|
||||
if (content) message.error(content);
|
||||
} else if (type === 'done') {
|
||||
setAnswer(prevAnswer => {
|
||||
setConversation(prev => {
|
||||
const newConversation = [...prev];
|
||||
newConversation[newConversation.length - 1].a = prevAnswer;
|
||||
newConversation[newConversation.length - 1].update_time =
|
||||
dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
newConversation[newConversation.length - 1].message_id =
|
||||
messageIdRef.current;
|
||||
return newConversation;
|
||||
});
|
||||
return '';
|
||||
});
|
||||
setLoading(false);
|
||||
setThinking(4);
|
||||
} else if (type === 'data') {
|
||||
setAnswer(prev => {
|
||||
const newAnswer = prev + content;
|
||||
if (newAnswer.includes('</think>')) {
|
||||
setThinking(3);
|
||||
return newAnswer;
|
||||
}
|
||||
if (newAnswer.includes('<think>')) {
|
||||
setThinking(2);
|
||||
return newAnswer;
|
||||
}
|
||||
setThinking(3);
|
||||
return newAnswer;
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearch = (q: string, reset: boolean = false) => {
|
||||
if (loading || !q.trim()) return;
|
||||
const newConversation = reset ? [] : [...conversation];
|
||||
newConversation.push({
|
||||
q,
|
||||
a: '',
|
||||
score: 0,
|
||||
update_time: '',
|
||||
message_id: '',
|
||||
source: 'chat',
|
||||
chunk_result: [],
|
||||
});
|
||||
messageIdRef.current = '';
|
||||
setConversation(newConversation);
|
||||
setAnswer('');
|
||||
setTimeout(() => {
|
||||
chatAnswer(q);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleSearchAbort = () => {
|
||||
if (loading) {
|
||||
sseClientRef.current?.unsubscribe();
|
||||
}
|
||||
setLoading(false);
|
||||
setThinking(4);
|
||||
};
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (chatContainerRef?.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } =
|
||||
chatContainerRef.current;
|
||||
setIsUserScrolling(scrollTop + clientHeight < scrollHeight);
|
||||
}
|
||||
}, [chatContainerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserScrolling && chatContainerRef?.current) {
|
||||
chatContainerRef.current.scrollTop =
|
||||
chatContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, [answer, isUserScrolling]);
|
||||
|
||||
useEffect(() => {
|
||||
const chatContainer = chatContainerRef?.current;
|
||||
chatContainer?.addEventListener('scroll', handleScroll);
|
||||
return () => {
|
||||
chatContainer?.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [handleScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
sseClientRef.current = new SSEClient({
|
||||
url: `/share/v1/chat/widget`,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
onCancel: () => {
|
||||
setLoading(false);
|
||||
setThinking(4);
|
||||
setAnswer(prev => {
|
||||
let value = '';
|
||||
if (prev) {
|
||||
value = prev + '\n\n<error>Request canceled</error>';
|
||||
}
|
||||
setConversation(prev => {
|
||||
const newConversation = [...prev];
|
||||
if (newConversation.length > 0) {
|
||||
newConversation[newConversation.length - 1].a = value;
|
||||
newConversation[newConversation.length - 1].update_time =
|
||||
dayjs().format('YYYY-MM-DD HH:mm:ss');
|
||||
newConversation[newConversation.length - 1].message_id =
|
||||
messageIdRef.current;
|
||||
}
|
||||
return newConversation;
|
||||
});
|
||||
return '';
|
||||
});
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={theme => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
maxWidth: '100vw',
|
||||
height: '100vh',
|
||||
backgroundColor: lighten(theme.palette.background.default, 0.05),
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
|
||||
overflow: 'hidden',
|
||||
outline: 'none',
|
||||
pb: 2,
|
||||
})}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 顶部标签栏 */}
|
||||
<Box
|
||||
<WaterMarkProvider>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'flex-start'}
|
||||
justifyContent={'space-between'}
|
||||
gap={2}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
pt: 2,
|
||||
pb: 2.5,
|
||||
p: 3,
|
||||
bgcolor: 'primary.main',
|
||||
pb: '36px',
|
||||
}}
|
||||
>
|
||||
{defaultSearchMode === 'all' ? (
|
||||
<StyledTabs
|
||||
value={searchMode}
|
||||
onChange={(_, value) => {
|
||||
setSearchMode(value as 'qa' | 'doc');
|
||||
}}
|
||||
variant='scrollable'
|
||||
scrollButtons={false}
|
||||
<Box sx={{ flex: 1, width: 0, color: 'light.main' }}>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={1}
|
||||
sx={{ lineHeight: '28px', fontSize: 20 }}
|
||||
>
|
||||
<StyledTab
|
||||
label={
|
||||
<Stack direction='row' gap={0.5} alignItems='center'>
|
||||
<IconZhinengwenda sx={{ fontSize: 16 }} />
|
||||
{!mobile && <span>智能问答</span>}
|
||||
</Stack>
|
||||
{widget?.settings?.widget_bot_settings?.btn_logo ||
|
||||
widget?.settings?.icon ? (
|
||||
<img
|
||||
src={
|
||||
widget?.settings?.widget_bot_settings?.btn_logo ||
|
||||
widget?.settings?.icon
|
||||
}
|
||||
value='qa'
|
||||
height={24}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
<StyledTab
|
||||
label={
|
||||
<Stack direction='row' gap={0.5} alignItems='center'>
|
||||
<IconJinsousuo sx={{ fontSize: 16 }} />
|
||||
{!mobile && <span>仅搜索文档</span>}
|
||||
</Stack>
|
||||
}
|
||||
value='doc'
|
||||
/>
|
||||
</StyledTabs>
|
||||
) : (
|
||||
<Box></Box>
|
||||
<IconLogo sx={{ fontSize: 24 }} />
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='primary'
|
||||
size='small'
|
||||
sx={theme => ({
|
||||
minWidth: 'auto',
|
||||
px: 1,
|
||||
py: '1px',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none',
|
||||
color: 'text.secondary',
|
||||
borderColor: alpha(theme.palette.text.primary, 0.1),
|
||||
})}
|
||||
>
|
||||
Esc
|
||||
</Button>
|
||||
</Box>
|
||||
<Ellipsis sx={{ pr: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
flex: 1,
|
||||
display: searchMode === 'qa' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
component={'span'}
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
handleSearchAbort();
|
||||
setConversation([]);
|
||||
}}
|
||||
>
|
||||
<AiQaContent
|
||||
hotSearch={hotSearch}
|
||||
placeholder={placeholder}
|
||||
inputRef={aiQaInputRef}
|
||||
{widget?.settings?.title || '在线客服'}
|
||||
</Box>
|
||||
</Ellipsis>
|
||||
</Stack>
|
||||
<Ellipsis sx={{ fontSize: 14, opacity: 0.7, mt: 0.5 }}>
|
||||
{widget?.settings?.welcome_str || '在线客服'}
|
||||
</Ellipsis>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: themeMode === 'light' ? 'light.main' : 'dark.light',
|
||||
p: 3,
|
||||
mt: -2,
|
||||
borderRadius: '12px 12px 0 0',
|
||||
height: 'calc(100vh - 96px - 24px)',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{conversation.length === 0 ? (
|
||||
<>
|
||||
<Box>
|
||||
<ChatInput
|
||||
loading={loading}
|
||||
thinking={thinking}
|
||||
setThinking={setThinking}
|
||||
onSearch={onSearch}
|
||||
handleSearchAbort={handleSearchAbort}
|
||||
placeholder={
|
||||
widget?.settings?.search_placeholder || '请输入问题'
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
<Stack
|
||||
direction='row'
|
||||
alignItems={'center'}
|
||||
flexWrap='wrap'
|
||||
gap={1.5}
|
||||
sx={{
|
||||
px: 3,
|
||||
flex: 1,
|
||||
display: searchMode === 'doc' ? 'flex' : 'none',
|
||||
flexDirection: 'column',
|
||||
mt: 2,
|
||||
}}
|
||||
>
|
||||
<SearchDocContent inputRef={inputRef} placeholder={placeholder} />
|
||||
{widget?.settings?.recommend_questions?.map(item => (
|
||||
<Box
|
||||
key={item}
|
||||
onClick={() => onSearch(item, true)}
|
||||
sx={{
|
||||
border: '1px solid',
|
||||
borderRadius: '16px',
|
||||
fontSize: 14,
|
||||
color: 'text.secondary',
|
||||
lineHeight: '32px',
|
||||
height: '32px',
|
||||
borderColor: 'divider',
|
||||
px: 2,
|
||||
cursor: 'pointer',
|
||||
bgcolor:
|
||||
themeMode === 'dark'
|
||||
? 'background.paper3'
|
||||
: 'background.default',
|
||||
'&:hover': {
|
||||
borderColor: 'primary.main',
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</Box>
|
||||
|
||||
{/* 底部AI生成提示 */}
|
||||
))}
|
||||
</Stack>
|
||||
{widget?.recommend_nodes && widget.recommend_nodes.length > 0 && (
|
||||
<Box sx={{ mt: 4.5 }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 3,
|
||||
pt: widget?.settings?.widget_bot_settings?.disclaimer ? 2 : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'text.tertiary',
|
||||
lineHeight: '22px',
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant='caption'
|
||||
推荐内容
|
||||
</Box>
|
||||
<Stack direction={'row'} flexWrap={'wrap'}>
|
||||
{widget.recommend_nodes.map(it => {
|
||||
return (
|
||||
<Link
|
||||
href={`/node/${it.id}`}
|
||||
target='_blank'
|
||||
prefetch={false}
|
||||
key={it.id}
|
||||
style={{ width: isMobile ? '100%' : '50%' }}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={1}
|
||||
key={it.id}
|
||||
sx={{
|
||||
color: 'text.disabled',
|
||||
py: 2,
|
||||
pr: isMobile ? 0 : 2,
|
||||
fontSize: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
height: 53,
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'divider',
|
||||
cursor: 'pointer',
|
||||
color: 'text.primary',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box>本网站由 PandaWiki 提供技术支持</Box>
|
||||
</Typography>
|
||||
{it.emoji ? (
|
||||
<Box>{it.emoji}</Box>
|
||||
) : it.type === 1 ? (
|
||||
<IconFolder />
|
||||
) : (
|
||||
<IconFile />
|
||||
)}
|
||||
<Box>{it.name}</Box>
|
||||
</Stack>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<ChatWindow
|
||||
conversation_id={conversationId}
|
||||
conversation={conversation}
|
||||
setConversation={setConversation}
|
||||
answer={answer}
|
||||
loading={loading}
|
||||
thinking={thinking}
|
||||
setThinking={setThinking}
|
||||
onSearch={onSearch}
|
||||
handleSearchAbort={handleSearchAbort}
|
||||
placeholder={widget?.settings?.search_placeholder || '请输入问题'}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={1}
|
||||
justifyContent={'center'}
|
||||
sx={{
|
||||
height: 24,
|
||||
fontSize: 12,
|
||||
bgcolor: themeMode === 'light' ? 'light.main' : 'dark.light',
|
||||
color: 'text.primary',
|
||||
a: {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
本插件由
|
||||
<Link
|
||||
href={'https://pandawiki.docs.baizhi.cloud/'}
|
||||
target='_blank'
|
||||
prefetch={false}
|
||||
>
|
||||
<Stack
|
||||
direction={'row'}
|
||||
alignItems={'center'}
|
||||
gap={0.5}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': {
|
||||
color: 'primary.main',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<IconLogo sx={{ fontSize: 16 }} />
|
||||
<Box sx={{ fontWeight: 'bold' }}>PandaWiki</Box>
|
||||
</Stack>
|
||||
</Link>
|
||||
提供技术支持
|
||||
</Stack>
|
||||
</WaterMarkProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,32 +0,0 @@
|
|||
import { ChunkResultItem } from '@/assets/type';
|
||||
|
||||
export interface ConversationItem {
|
||||
q: string;
|
||||
a: string;
|
||||
score: number;
|
||||
update_time: string;
|
||||
message_id: string;
|
||||
source: 'history' | 'chat';
|
||||
chunk_result: ChunkResultItem[];
|
||||
thinking_content: string;
|
||||
}
|
||||
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
url: string;
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface SSEMessageData {
|
||||
type: string;
|
||||
content: string;
|
||||
chunk_result: ChunkResultItem;
|
||||
}
|
||||
|
||||
export interface ChatRequestData {
|
||||
message: string;
|
||||
nonce: string;
|
||||
conversation_id: string;
|
||||
app_type: number;
|
||||
captcha_token: string;
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
export 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
"dependencies": {
|
||||
"@ctzhian/tiptap": "^1.13.2",
|
||||
"@ctzhian/tiptap": "^1.12.21",
|
||||
"@ctzhian/ui": "^7.0.5",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
|
|
|
|||
1346
web/pnpm-lock.yaml
1346
web/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue