mirror of https://github.com/chaitin/PandaWiki.git
Compare commits
3 Commits
d9e07f89ba
...
035ce0284d
| Author | SHA1 | Date |
|---|---|---|
|
|
035ce0284d | |
|
|
93559125c2 | |
|
|
6c5cf256ac |
|
|
@ -588,6 +588,7 @@ export type ChatConversationItem = {
|
||||||
export type ChatConversationPair = {
|
export type ChatConversationPair = {
|
||||||
user: string;
|
user: string;
|
||||||
assistant: string;
|
assistant: string;
|
||||||
|
thinking_content: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
info: {
|
info: {
|
||||||
feedback_content: string;
|
feedback_content: string;
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -262,7 +262,7 @@ const MemberAdd = ({
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
sx: {
|
sx: {
|
||||||
'.Mui-disabled': {
|
'.Mui-disabled': {
|
||||||
opacity: 1,
|
opacity: '1 !important',
|
||||||
color: 'text.disabled',
|
color: 'text.disabled',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import { ChatConversationPair } from '@/api';
|
||||||
import { getApiV1ConversationDetail } from '@/request/Conversation';
|
import { getApiV1ConversationDetail } from '@/request/Conversation';
|
||||||
import { DomainConversationDetailResp } from '@/request/types';
|
import { DomainConversationDetailResp } from '@/request/types';
|
||||||
import Avatar from '@/components/Avatar';
|
import Avatar from '@/components/Avatar';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import MarkDown from '@/components/MarkDown';
|
import MarkDown from '@/components/MarkDown';
|
||||||
import { useAppSelector } from '@/store';
|
import { useAppSelector } from '@/store';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionDetails,
|
AccordionDetails,
|
||||||
|
|
@ -13,10 +13,169 @@ import {
|
||||||
Box,
|
Box,
|
||||||
Stack,
|
Stack,
|
||||||
useTheme,
|
useTheme,
|
||||||
|
styled,
|
||||||
|
alpha,
|
||||||
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
|
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
|
||||||
import { useEffect, useState } from 'react';
|
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 = ({
|
const Detail = ({
|
||||||
id,
|
id,
|
||||||
open,
|
open,
|
||||||
|
|
@ -55,7 +214,11 @@ const Detail = ({
|
||||||
};
|
};
|
||||||
} else if (message.role === 'assistant') {
|
} else if (message.role === 'assistant') {
|
||||||
if (currentPair.user) {
|
if (currentPair.user) {
|
||||||
currentPair.assistant = message.content;
|
const { thinkingContent, answerContent } = handleThinkingContent(
|
||||||
|
message.content || '',
|
||||||
|
);
|
||||||
|
currentPair.assistant = answerContent;
|
||||||
|
currentPair.thinking_content = thinkingContent;
|
||||||
currentPair.created_at = message.created_at;
|
currentPair.created_at = message.created_at;
|
||||||
// @ts-expect-error 类型不兼容
|
// @ts-expect-error 类型不兼容
|
||||||
currentPair.info = message.info;
|
currentPair.info = message.info;
|
||||||
|
|
@ -167,26 +330,43 @@ const Detail = ({
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
{conversations &&
|
{conversations &&
|
||||||
conversations.map((item, index) => (
|
conversations.map((item, index) => (
|
||||||
<Box key={index}>
|
<StyledConversationItem key={index}>
|
||||||
<Accordion defaultExpanded={true}>
|
{/* 用户问题气泡 - 右对齐 */}
|
||||||
<AccordionSummary
|
<StyledUserBubble>{item.user}</StyledUserBubble>
|
||||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
|
|
||||||
sx={{
|
{/* AI回答气泡 - 左对齐 */}
|
||||||
userSelect: 'text',
|
<StyledAiBubble>
|
||||||
backgroundColor: 'background.paper3',
|
{/* 思考过程 */}
|
||||||
fontSize: '18px',
|
{!!item.thinking_content && (
|
||||||
fontWeight: 'bold',
|
<StyledThinkingAccordion defaultExpanded>
|
||||||
}}
|
<StyledThinkingAccordionSummary
|
||||||
>
|
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||||
{item.user}
|
>
|
||||||
</AccordionSummary>
|
<Stack direction='row' alignItems='center' gap={1}>
|
||||||
<AccordionDetails>
|
<Typography
|
||||||
<MarkDown
|
variant='body2'
|
||||||
content={item.assistant || '未查询到回答内容'}
|
sx={theme => ({
|
||||||
/>
|
fontSize: 12,
|
||||||
</AccordionDetails>
|
color: alpha(theme.palette.text.primary, 0.5),
|
||||||
</Accordion>
|
})}
|
||||||
</Box>
|
>
|
||||||
|
已思考
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</StyledThinkingAccordionSummary>
|
||||||
|
|
||||||
|
<StyledThinkingAccordionDetails>
|
||||||
|
<MarkDown content={item.thinking_content || ''} />
|
||||||
|
</StyledThinkingAccordionDetails>
|
||||||
|
</StyledThinkingAccordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI回答内容 */}
|
||||||
|
<StyledAiBubbleContent>
|
||||||
|
<MarkDown content={item.assistant} />
|
||||||
|
</StyledAiBubbleContent>
|
||||||
|
</StyledAiBubble>
|
||||||
|
</StyledConversationItem>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -446,15 +446,19 @@ const Content = () => {
|
||||||
>
|
>
|
||||||
{ragReStartCount} 个文档未学习,
|
{ragReStartCount} 个文档未学习,
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<ButtonBase
|
||||||
size='small'
|
disableRipple
|
||||||
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
|
sx={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 400,
|
||||||
|
color: 'primary.main',
|
||||||
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRagOpen(true);
|
setRagOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
去学习
|
去学习
|
||||||
</Button>
|
</ButtonBase>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@ import { getApiV1ConversationMessageDetail } from '@/request';
|
||||||
import MarkDown from '@/components/MarkDown';
|
import MarkDown from '@/components/MarkDown';
|
||||||
import { useAppSelector } from '@/store';
|
import { useAppSelector } from '@/store';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import {
|
import { Box, Stack, Typography, alpha } from '@mui/material';
|
||||||
Accordion,
|
import { Ellipsis, Modal } from '@ctzhian/ui';
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Box,
|
|
||||||
} from '@mui/material';
|
|
||||||
import { Ellipsis, Icon, Modal } from '@ctzhian/ui';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
StyledConversationItem,
|
||||||
|
StyledUserBubble,
|
||||||
|
StyledAiBubble,
|
||||||
|
StyledThinkingAccordion,
|
||||||
|
StyledThinkingAccordionSummary,
|
||||||
|
StyledThinkingAccordionDetails,
|
||||||
|
StyledAiBubbleContent,
|
||||||
|
} from '../conversation/Detail';
|
||||||
|
|
||||||
const Detail = ({
|
const Detail = ({
|
||||||
id,
|
id,
|
||||||
|
|
@ -36,6 +40,7 @@ const Detail = ({
|
||||||
user: data.question,
|
user: data.question,
|
||||||
assistant: res.content!,
|
assistant: res.content!,
|
||||||
created_at: res.created_at!,
|
created_at: res.created_at!,
|
||||||
|
thinking_content: '',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -62,24 +67,43 @@ const Detail = ({
|
||||||
>
|
>
|
||||||
<Box sx={{ fontSize: 14 }}>
|
<Box sx={{ fontSize: 14 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Accordion defaultExpanded={true}>
|
<StyledConversationItem>
|
||||||
<AccordionSummary
|
{/* 用户问题气泡 - 右对齐 */}
|
||||||
expandIcon={<ExpandMoreIcon sx={{ fontSize: 24 }} />}
|
<StyledUserBubble>{conversations?.user}</StyledUserBubble>
|
||||||
sx={{
|
|
||||||
userSelect: 'text',
|
{/* AI回答气泡 - 左对齐 */}
|
||||||
backgroundColor: 'background.paper3',
|
<StyledAiBubble>
|
||||||
fontSize: '18px',
|
{/* 思考过程 */}
|
||||||
fontWeight: 'bold',
|
{!!conversations?.thinking_content && (
|
||||||
}}
|
<StyledThinkingAccordion defaultExpanded>
|
||||||
>
|
<StyledThinkingAccordionSummary
|
||||||
{conversations?.user}
|
expandIcon={<ExpandMoreIcon sx={{ fontSize: 16 }} />}
|
||||||
</AccordionSummary>
|
>
|
||||||
<AccordionDetails>
|
<Stack direction='row' alignItems='center' gap={1}>
|
||||||
<MarkDown
|
<Typography
|
||||||
content={conversations?.assistant || '未查询到回答内容'}
|
variant='body2'
|
||||||
/>
|
sx={theme => ({
|
||||||
</AccordionDetails>
|
fontSize: 12,
|
||||||
</Accordion>
|
color: alpha(theme.palette.text.primary, 0.5),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
已思考
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</StyledThinkingAccordionSummary>
|
||||||
|
|
||||||
|
<StyledThinkingAccordionDetails>
|
||||||
|
<MarkDown content={conversations?.thinking_content || ''} />
|
||||||
|
</StyledThinkingAccordionDetails>
|
||||||
|
</StyledThinkingAccordion>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI回答内容 */}
|
||||||
|
<StyledAiBubbleContent>
|
||||||
|
<MarkDown content={conversations?.assistant || ''} />
|
||||||
|
</StyledAiBubbleContent>
|
||||||
|
</StyledAiBubble>
|
||||||
|
</StyledConversationItem>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
sx: {
|
sx: {
|
||||||
'.Mui-disabled': {
|
'.Mui-disabled': {
|
||||||
opacity: 1,
|
opacity: '1 !important',
|
||||||
color: 'text.disabled',
|
color: 'text.disabled',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -873,7 +873,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
|
||||||
MenuProps={{
|
MenuProps={{
|
||||||
sx: {
|
sx: {
|
||||||
'.Mui-disabled': {
|
'.Mui-disabled': {
|
||||||
opacity: 1,
|
opacity: '1 !important',
|
||||||
color: 'text.disabled',
|
color: 'text.disabled',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,11 @@ import {
|
||||||
} from '@/request/types';
|
} from '@/request/types';
|
||||||
import { useAppSelector } from '@/store';
|
import { useAppSelector } from '@/store';
|
||||||
import { Icon, message } from '@ctzhian/ui';
|
import { Icon, message } from '@ctzhian/ui';
|
||||||
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
|
Collapse,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
Link,
|
Link,
|
||||||
Radio,
|
Radio,
|
||||||
|
|
@ -31,6 +34,8 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [isEnabled, setIsEnabled] = useState(false);
|
const [isEnabled, setIsEnabled] = useState(false);
|
||||||
const [detail, setDetail] = useState<DomainAppDetailResp | null>(null);
|
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 { kb_id } = useAppSelector(state => state.config);
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
|
|
@ -43,8 +48,15 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
is_open: 0,
|
is_open: 0,
|
||||||
theme_mode: 'light',
|
theme_mode: 'light',
|
||||||
|
btn_style: 'hover_ball',
|
||||||
|
btn_id: '',
|
||||||
|
btn_position: 'bottom_right',
|
||||||
|
disclaimer: '',
|
||||||
btn_text: '',
|
btn_text: '',
|
||||||
btn_logo: '',
|
btn_logo: '',
|
||||||
|
modal_position: 'follow',
|
||||||
|
search_mode: 'all',
|
||||||
|
placeholder: '',
|
||||||
recommend_questions: [] as string[],
|
recommend_questions: [] as string[],
|
||||||
recommend_node_ids: [] as string[],
|
recommend_node_ids: [] as string[],
|
||||||
},
|
},
|
||||||
|
|
@ -54,6 +66,8 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
||||||
|
|
||||||
const recommend_questions = watch('recommend_questions') || [];
|
const recommend_questions = watch('recommend_questions') || [];
|
||||||
const recommend_node_ids = watch('recommend_node_ids') || [];
|
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>({
|
const recommendQuestionsField = useCommitPendingInput<string>({
|
||||||
value: recommend_questions,
|
value: recommend_questions,
|
||||||
|
|
@ -87,8 +101,17 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
||||||
reset({
|
reset({
|
||||||
is_open: res.settings?.widget_bot_settings?.is_open ? 1 : 0,
|
is_open: res.settings?.widget_bot_settings?.is_open ? 1 : 0,
|
||||||
theme_mode: res.settings?.widget_bot_settings?.theme_mode || 'light',
|
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_text: res.settings?.widget_bot_settings?.btn_text || '在线客服',
|
||||||
btn_logo: res.settings?.widget_bot_settings?.btn_logo,
|
btn_logo: res.settings?.widget_bot_settings?.btn_logo || '',
|
||||||
|
modal_position:
|
||||||
|
res.settings?.widget_bot_settings?.modal_position || 'follow',
|
||||||
|
search_mode: res.settings?.widget_bot_settings?.search_mode || 'all',
|
||||||
|
placeholder: res.settings?.widget_bot_settings?.placeholder || '',
|
||||||
|
disclaimer: res.settings?.widget_bot_settings?.disclaimer || '',
|
||||||
recommend_questions:
|
recommend_questions:
|
||||||
res.settings?.widget_bot_settings?.recommend_questions || [],
|
res.settings?.widget_bot_settings?.recommend_questions || [],
|
||||||
recommend_node_ids:
|
recommend_node_ids:
|
||||||
|
|
@ -108,8 +131,15 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
||||||
widget_bot_settings: {
|
widget_bot_settings: {
|
||||||
is_open: data.is_open === 1 ? true : false,
|
is_open: data.is_open === 1 ? true : false,
|
||||||
theme_mode: data.theme_mode as 'light' | 'dark',
|
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_text: data.btn_text,
|
||||||
btn_logo: data.btn_logo,
|
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_questions: data.recommend_questions || [],
|
||||||
recommend_node_ids: data.recommend_node_ids || [],
|
recommend_node_ids: data.recommend_node_ids || [],
|
||||||
},
|
},
|
||||||
|
|
@ -151,146 +181,469 @@ const CardRobotWebComponent = ({ kb }: CardRobotWebComponentProps) => {
|
||||||
</Link>
|
</Link>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<FormItem label='网页挂件机器人'>
|
<Stack spacing={3}>
|
||||||
<Controller
|
<FormItem label='网页挂件机器人'>
|
||||||
control={control}
|
<Controller
|
||||||
name='is_open'
|
control={control}
|
||||||
render={({ field }) => (
|
name='is_open'
|
||||||
<RadioGroup
|
render={({ field }) => (
|
||||||
row
|
<RadioGroup
|
||||||
{...field}
|
row
|
||||||
onChange={e => {
|
{...field}
|
||||||
field.onChange(+e.target.value as 1 | 0);
|
onChange={e => {
|
||||||
setIsEnabled((+e.target.value as 1 | 0) === 1);
|
field.onChange(+e.target.value as 1 | 0);
|
||||||
setIsEdit(true);
|
setIsEnabled((+e.target.value as 1 | 0) === 1);
|
||||||
}}
|
setIsEdit(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value={1}
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>启用</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value={0}
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>禁用</Box>}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
{isEnabled && (
|
||||||
|
<>
|
||||||
|
<FormItem
|
||||||
|
label='嵌入代码'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
>
|
>
|
||||||
<FormControlLabel
|
{url ? (
|
||||||
value={1}
|
<ShowText
|
||||||
control={<Radio size='small' />}
|
noEllipsis
|
||||||
label={<Box sx={{ width: 100 }}>启用</Box>}
|
text={[
|
||||||
/>
|
`<!--// Head 标签引入样式 -->`,
|
||||||
<FormControlLabel
|
`<link rel="stylesheet" href="${url}/widget-bot.css">`,
|
||||||
value={0}
|
`<!--// Body 标签引入挂件 -->`,
|
||||||
control={<Radio size='small' />}
|
`<script src="${url}/widget-bot.js"></script>`,
|
||||||
label={<Box sx={{ width: 100 }}>禁用</Box>}
|
]}
|
||||||
/>
|
/>
|
||||||
</RadioGroup>
|
) : (
|
||||||
)}
|
<Stack
|
||||||
/>
|
direction='row'
|
||||||
</FormItem>
|
alignItems={'center'}
|
||||||
{isEnabled && (
|
gap={0.5}
|
||||||
<>
|
sx={{
|
||||||
<FormItem label='配色方案'>
|
color: 'warning.main',
|
||||||
<Controller
|
fontSize: 14,
|
||||||
control={control}
|
p: 1.5,
|
||||||
name='theme_mode'
|
borderRadius: 1,
|
||||||
render={({ field }) => (
|
bgcolor: 'warning.light',
|
||||||
<RadioGroup
|
|
||||||
row
|
|
||||||
{...field}
|
|
||||||
onChange={e => {
|
|
||||||
field.onChange(e.target.value);
|
|
||||||
setIsEdit(true);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FormControlLabel
|
<Icon type='icon-jinggao' />
|
||||||
value={'light'}
|
未配置域名,可在右侧
|
||||||
control={<Radio size='small' />}
|
<Box component={'span'} sx={{ fontWeight: 500 }}>
|
||||||
label={<Box sx={{ width: 100 }}>浅色模式</Box>}
|
服务监听方式
|
||||||
/>
|
</Box>{' '}
|
||||||
<FormControlLabel
|
中配置
|
||||||
value={'dark'}
|
</Stack>
|
||||||
control={<Radio size='small' />}
|
|
||||||
label={<Box sx={{ width: 100 }}>深色模式</Box>}
|
|
||||||
/>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</FormItem>
|
||||||
</FormItem>
|
<FormItem
|
||||||
<FormItem label='侧边按钮文字'>
|
label='挂件配置'
|
||||||
<Controller
|
sx={{ alignItems: 'flex-start' }}
|
||||||
control={control}
|
labelSx={{ mt: 1 }}
|
||||||
name='btn_text'
|
>
|
||||||
render={({ field }) => (
|
<Box>
|
||||||
<TextField
|
{!widgetConfigOpen && (
|
||||||
fullWidth
|
<Button
|
||||||
{...field}
|
size='small'
|
||||||
placeholder='输入侧边按钮文字'
|
variant='outlined'
|
||||||
error={!!errors.btn_text}
|
onClick={() => setWidgetConfigOpen(true)}
|
||||||
helperText={errors.btn_text?.message}
|
endIcon={<ExpandMoreIcon />}
|
||||||
onChange={event => {
|
>
|
||||||
setIsEdit(true);
|
展开
|
||||||
field.onChange(event);
|
</Button>
|
||||||
}}
|
)}
|
||||||
/>
|
<Collapse in={widgetConfigOpen}>
|
||||||
)}
|
<Stack spacing={2.5}>
|
||||||
/>
|
<FormItem
|
||||||
</FormItem>
|
label='按钮样式'
|
||||||
<FormItem label='侧边按钮 Logo'>
|
sx={{ alignItems: 'flex-start' }}
|
||||||
<Controller
|
labelSx={{ mt: 1 }}
|
||||||
control={control}
|
>
|
||||||
name='btn_logo'
|
<Controller
|
||||||
render={({ field }) => (
|
control={control}
|
||||||
<UploadFile
|
name='btn_style'
|
||||||
{...field}
|
render={({ field }) => (
|
||||||
id='btn_logo'
|
<RadioGroup
|
||||||
type='url'
|
row
|
||||||
accept='image/*'
|
{...field}
|
||||||
width={80}
|
onChange={e => {
|
||||||
onChange={url => {
|
const value = e.target.value;
|
||||||
field.onChange(url);
|
field.onChange(value);
|
||||||
setIsEdit(true);
|
if (value === 'btn_trigger') {
|
||||||
}}
|
setValue('modal_position', 'fixed');
|
||||||
/>
|
}
|
||||||
)}
|
setIsEdit(true);
|
||||||
/>
|
}}
|
||||||
</FormItem>
|
>
|
||||||
<FormItem label='推荐问题'>
|
<FormControlLabel
|
||||||
<FreeSoloAutocomplete
|
value='hover_ball'
|
||||||
{...recommendQuestionsField}
|
control={<Radio size='small' />}
|
||||||
placeholder='回车确认,填写下一个推荐问题'
|
label={<Box sx={{ width: 100 }}>悬浮球</Box>}
|
||||||
/>
|
/>
|
||||||
</FormItem>
|
<FormControlLabel
|
||||||
<FormItem label='推荐文档'>
|
value='side_sticky'
|
||||||
<RecommendDocDragList
|
control={<Radio size='small' />}
|
||||||
ids={recommend_node_ids}
|
label={<Box sx={{ width: 100 }}>侧边吸附</Box>}
|
||||||
onChange={(value: string[]) => {
|
/>
|
||||||
setIsEdit(true);
|
<FormControlLabel
|
||||||
setValue('recommend_node_ids', value);
|
value='btn_trigger'
|
||||||
}}
|
control={<Radio size='small' />}
|
||||||
/>
|
label={<Box sx={{ width: 100 }}>自定义按钮</Box>}
|
||||||
</FormItem>
|
/>
|
||||||
<FormItem label='嵌入代码'>
|
</RadioGroup>
|
||||||
{url ? (
|
)}
|
||||||
<ShowText
|
/>
|
||||||
noEllipsis
|
</FormItem>
|
||||||
text={[
|
{isCustomButton ? (
|
||||||
`<!--// Head 标签引入样式 -->`,
|
<FormItem
|
||||||
`<link rel="stylesheet" href="${url}/widget-bot.css">`,
|
label='自定义按钮 ID'
|
||||||
'',
|
required
|
||||||
`<!--// Body 标签引入挂件 -->`,
|
sx={{ alignItems: 'flex-start' }}
|
||||||
`<script src="${url}/widget-bot.js"></script>`,
|
labelSx={{ mt: 1 }}
|
||||||
]}
|
>
|
||||||
/>
|
<Controller
|
||||||
) : (
|
control={control}
|
||||||
<Stack
|
name='btn_id'
|
||||||
direction='row'
|
rules={{
|
||||||
alignItems={'center'}
|
required: '自定义按钮 ID 不能为空',
|
||||||
gap={0.5}
|
}}
|
||||||
sx={{ color: 'warning.main', fontSize: 14 }}
|
render={({ field }) => (
|
||||||
>
|
<TextField
|
||||||
<Icon type='icon-jinggao' />
|
{...field}
|
||||||
未配置域名,可在右侧
|
fullWidth
|
||||||
<Box component={'span'} sx={{ fontWeight: 500 }}>
|
placeholder='嵌入网站中自定义按钮的 #id 点击触发,如: pandawiki-widget-bot-btn'
|
||||||
服务监听方式
|
error={!!errors.btn_id}
|
||||||
</Box>{' '}
|
helperText={errors.btn_id?.message}
|
||||||
中配置
|
onChange={event => {
|
||||||
</Stack>
|
setIsEdit(true);
|
||||||
)}
|
field.onChange(event);
|
||||||
</FormItem>
|
}}
|
||||||
</>
|
/>
|
||||||
)}
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FormItem
|
||||||
|
label='按钮位置'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='btn_position'
|
||||||
|
render={({ field }) => (
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
{...field}
|
||||||
|
onChange={e => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
setIsEdit(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value='top_left'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>左上</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='top_right'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>右上</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='bottom_left'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>左下</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='bottom_right'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>右下</Box>}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
{btn_style !== 'hover_ball' && (
|
||||||
|
<FormItem
|
||||||
|
label='按钮文字'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='btn_text'
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
{...field}
|
||||||
|
fullWidth
|
||||||
|
placeholder='输入按钮文字'
|
||||||
|
error={!!errors.btn_text}
|
||||||
|
helperText={errors.btn_text?.message}
|
||||||
|
onChange={event => {
|
||||||
|
setIsEdit(true);
|
||||||
|
field.onChange(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
<FormItem
|
||||||
|
label='按钮图标'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='btn_logo'
|
||||||
|
render={({ field }) => (
|
||||||
|
<UploadFile
|
||||||
|
{...field}
|
||||||
|
id='btn_logo'
|
||||||
|
type='url'
|
||||||
|
accept='image/*'
|
||||||
|
width={80}
|
||||||
|
onChange={url => {
|
||||||
|
field.onChange(url);
|
||||||
|
setIsEdit(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
label='弹框配置'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
{!modalConfigOpen && (
|
||||||
|
<Button
|
||||||
|
size='small'
|
||||||
|
variant='outlined'
|
||||||
|
onClick={() => setModalConfigOpen(true)}
|
||||||
|
endIcon={<ExpandMoreIcon />}
|
||||||
|
>
|
||||||
|
展开
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Collapse in={modalConfigOpen}>
|
||||||
|
<Stack spacing={2.5}>
|
||||||
|
{/* <FormItem label='配色方案' sx={{ alignItems: 'flex-start' }} labelSx={{ mt: 1 }}>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='theme_mode'
|
||||||
|
render={({ field }) => (
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
{...field}
|
||||||
|
onChange={e => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
setIsEdit(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value='light'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>浅色模式</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='dark'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>深色模式</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='system'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>跟随系统</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='wiki'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>跟随 WIKI 网站</Box>}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem> */}
|
||||||
|
<FormItem
|
||||||
|
label='弹窗位置'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='modal_position'
|
||||||
|
render={({ field }) => {
|
||||||
|
const isDisabled = btn_style === 'btn_trigger';
|
||||||
|
return (
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
{...field}
|
||||||
|
value={isDisabled ? 'fixed' : field.value}
|
||||||
|
onChange={e => {
|
||||||
|
if (!isDisabled) {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
setIsEdit(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value='follow'
|
||||||
|
control={
|
||||||
|
<Radio size='small' disabled={isDisabled} />
|
||||||
|
}
|
||||||
|
label={<Box sx={{ width: 100 }}>跟随按钮</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='fixed'
|
||||||
|
control={
|
||||||
|
<Radio size='small' disabled={isDisabled} />
|
||||||
|
}
|
||||||
|
label={<Box sx={{ width: 100 }}>居中展示</Box>}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
label='搜索模式'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='search_mode'
|
||||||
|
render={({ field }) => (
|
||||||
|
<RadioGroup
|
||||||
|
row
|
||||||
|
{...field}
|
||||||
|
onChange={e => {
|
||||||
|
field.onChange(e.target.value);
|
||||||
|
setIsEdit(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormControlLabel
|
||||||
|
value='all'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={<Box sx={{ width: 100 }}>双模式切换</Box>}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='qa'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={
|
||||||
|
<Box sx={{ width: 100 }}>智能问答模式</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
value='doc'
|
||||||
|
control={<Radio size='small' />}
|
||||||
|
label={
|
||||||
|
<Box sx={{ width: 100 }}>搜索文档模式</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
label='搜索提示语'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='placeholder'
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
placeholder='输入搜索提示语'
|
||||||
|
error={!!errors.placeholder}
|
||||||
|
helperText={errors.placeholder?.message}
|
||||||
|
onChange={event => {
|
||||||
|
setIsEdit(true);
|
||||||
|
field.onChange(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
label='推荐问题'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<FreeSoloAutocomplete
|
||||||
|
{...recommendQuestionsField}
|
||||||
|
placeholder='回车确认,填写下一个推荐问题'
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
label='推荐文档'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<RecommendDocDragList
|
||||||
|
ids={recommend_node_ids}
|
||||||
|
onChange={(value: string[]) => {
|
||||||
|
setIsEdit(true);
|
||||||
|
setValue('recommend_node_ids', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem
|
||||||
|
label='免责声明'
|
||||||
|
sx={{ alignItems: 'flex-start' }}
|
||||||
|
labelSx={{ mt: 1 }}
|
||||||
|
>
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name='disclaimer'
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
{...field}
|
||||||
|
placeholder='输入免责声明'
|
||||||
|
error={!!errors.disclaimer}
|
||||||
|
helperText={errors.disclaimer?.message}
|
||||||
|
onChange={event => {
|
||||||
|
setIsEdit(true);
|
||||||
|
field.onChange(event);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</Stack>
|
||||||
|
</Collapse>
|
||||||
|
</Box>
|
||||||
|
</FormItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
</SettingCardItem>
|
</SettingCardItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1365,6 +1365,13 @@ export interface DomainWidgetBotSettings {
|
||||||
recommend_node_ids?: string[];
|
recommend_node_ids?: string[];
|
||||||
recommend_questions?: string[];
|
recommend_questions?: string[];
|
||||||
theme_mode?: string;
|
theme_mode?: string;
|
||||||
|
btn_style?: string;
|
||||||
|
btn_id?: string;
|
||||||
|
btn_position?: string;
|
||||||
|
modal_position?: string;
|
||||||
|
search_mode?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disclaimer?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GithubComChaitinPandaWikiApiAuthV1AuthGetResp {
|
export interface GithubComChaitinPandaWikiApiAuthV1AuthGetResp {
|
||||||
|
|
@ -1735,23 +1742,23 @@ export interface DeleteApiV1AuthDeleteParams {
|
||||||
export interface GetApiV1AuthGetParams {
|
export interface GetApiV1AuthGetParams {
|
||||||
kb_id?: string;
|
kb_id?: string;
|
||||||
source_type:
|
source_type:
|
||||||
| "dingtalk"
|
| "dingtalk"
|
||||||
| "feishu"
|
| "feishu"
|
||||||
| "wecom"
|
| "wecom"
|
||||||
| "oauth"
|
| "oauth"
|
||||||
| "github"
|
| "github"
|
||||||
| "cas"
|
| "cas"
|
||||||
| "ldap"
|
| "ldap"
|
||||||
| "widget"
|
| "widget"
|
||||||
| "dingtalk_bot"
|
| "dingtalk_bot"
|
||||||
| "feishu_bot"
|
| "feishu_bot"
|
||||||
| "lark_bot"
|
| "lark_bot"
|
||||||
| "wechat_bot"
|
| "wechat_bot"
|
||||||
| "wecom_ai_bot"
|
| "wecom_ai_bot"
|
||||||
| "wechat_service_bot"
|
| "wechat_service_bot"
|
||||||
| "discord_bot"
|
| "discord_bot"
|
||||||
| "wechat_official_account"
|
| "wechat_official_account"
|
||||||
| "openai_api";
|
| "openai_api";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GetApiV1CommentParams {
|
export interface GetApiV1CommentParams {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:20-alpine
|
FROM node:22-alpine
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
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.
|
// 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.
|
// This can increase your server load as well as your hosting bill.
|
||||||
// Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
|
// Note: Check that the configured route will not match with your Next.js proxy, otherwise reporting of client-
|
||||||
// side errors will fail.
|
// side errors will fail.
|
||||||
tunnelRoute: '/monitoring',
|
tunnelRoute: '/monitoring',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 3010",
|
"dev": "next dev -p 3010",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"@emoji-mart/data": "^1.2.1",
|
"@emoji-mart/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@emotion/cache": "^11.14.0",
|
"@emotion/cache": "^11.14.0",
|
||||||
"@mui/material-nextjs": "^7.1.0",
|
"@mui/material-nextjs": "^7.3.5",
|
||||||
"@sentry/nextjs": "^10.8.0",
|
"@sentry/nextjs": "^10.8.0",
|
||||||
"@types/markdown-it": "13.0.1",
|
"@types/markdown-it": "13.0.1",
|
||||||
"@vscode/markdown-it-katex": "^1.1.2",
|
"@vscode/markdown-it-katex": "^1.1.2",
|
||||||
|
|
@ -25,12 +25,13 @@
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html-react-parser": "^5.2.5",
|
"html-react-parser": "^5.2.5",
|
||||||
"html-to-image": "^1.11.13",
|
"html-to-image": "^1.11.13",
|
||||||
|
"import-in-the-middle": "^1.4.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"markdown-it": "13.0.1",
|
"markdown-it": "13.0.1",
|
||||||
"markdown-it-highlightjs": "^4.2.0",
|
"markdown-it-highlightjs": "^4.2.0",
|
||||||
"mermaid": "^11.9.0",
|
"mermaid": "^11.9.0",
|
||||||
"next": "15.4.6",
|
"next": "^16.0.0",
|
||||||
"react-device-detect": "^2.2.3",
|
"react-device-detect": "^2.2.3",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-photo-view": "^1.2.7",
|
"react-photo-view": "^1.2.7",
|
||||||
|
|
@ -41,17 +42,23 @@
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
|
"require-in-the-middle": "^7.5.2",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@ctzhian/cx-swagger-api": "^1.0.0",
|
"@ctzhian/cx-swagger-api": "^1.0.0",
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@next/eslint-plugin-next": "^15.4.5",
|
"@next/eslint-plugin-next": "^16.0.0",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/rangy": "^1.3.0",
|
"@types/rangy": "^1.3.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"eslint-config-next": "15.3.2",
|
"eslint-config-next": "16.0.0",
|
||||||
"eslint-config-prettier": "^9.1.2"
|
"eslint-config-prettier": "^9.1.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"require-in-the-middle": "^7.5.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,39 @@
|
||||||
/* 挂件按钮样式 - 基于MUI主题 */
|
/* 挂件按钮基础样式 */
|
||||||
.widget-bot-button {
|
.widget-bot-button {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
|
||||||
bottom: 190px;
|
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: all 0.2s ease-in-out;
|
||||||
border-radius: 18px 0 0 18px;
|
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17);
|
|
||||||
padding: 11px;
|
|
||||||
min-height: 120px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
|
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif);
|
||||||
border: none;
|
border: none;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
/* 优化拖拽性能 */
|
||||||
|
will-change: transform;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button:hover {
|
.widget-bot-button:hover:not(.dragging) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2);
|
}
|
||||||
|
|
||||||
|
.widget-bot-hover-ball:hover:not(.dragging) {
|
||||||
|
transform: scale(1.1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button.dragging {
|
.widget-bot-button.dragging {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
transform: rotate(2deg);
|
transition: none !important;
|
||||||
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25);
|
/* 拖拽时禁用过渡,提升性能 */
|
||||||
|
/* transform 由 JS 控制,包含 rotate 和 translate */
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button-content {
|
.widget-bot-button-content {
|
||||||
|
|
@ -39,14 +43,13 @@
|
||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-logo {
|
/* 图标样式 */
|
||||||
width: 20px;
|
.widget-bot-icon {
|
||||||
height: 20px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 文字样式 */
|
||||||
.widget-bot-text {
|
.widget-bot-text {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
|
|
@ -60,6 +63,47 @@
|
||||||
margin: 1px 0;
|
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主题 */
|
/* 模态框样式 - 基于MUI主题 */
|
||||||
.widget-bot-modal {
|
.widget-bot-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
@ -75,6 +119,11 @@
|
||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-bot-modal-fixed {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.widget-bot-modal-content {
|
.widget-bot-modal-content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 600px;
|
width: 600px;
|
||||||
|
|
@ -88,6 +137,14 @@
|
||||||
animation: slideInUp 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
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 {
|
@keyframes slideInUp {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
@ -100,34 +157,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 关闭按钮样式 - 基于MUI IconButton */
|
/* 关闭按钮样式 - 透明框 */
|
||||||
.widget-bot-close-btn {
|
.widget-bot-close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 22.5px;
|
||||||
right: 12px;
|
right: 16px;
|
||||||
background: none;
|
background: transparent;
|
||||||
width: 36px;
|
width: 36.26px;
|
||||||
height: 36px;
|
height: 25px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 0;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 18px;
|
font-size: 0;
|
||||||
opacity: 0.5;
|
|
||||||
z-index: 10001;
|
|
||||||
transition: all 0.1s ease-in-out;
|
|
||||||
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.widget-bot-close-btn:hover {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
z-index: 10001;
|
||||||
|
transition: none;
|
||||||
.widget-bot-close-btn:active {
|
font-family: var(--font-gilory, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
||||||
transform: scale(0.95);
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
/* 允许鼠标穿透到下方 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iframe样式 */
|
/* iframe样式 */
|
||||||
|
|
@ -140,6 +193,11 @@
|
||||||
background: #F8F9FA;
|
background: #F8F9FA;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.widget-bot-modal-content-fixed .widget-bot-iframe {
|
||||||
|
min-height: 600px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* 防止页面滚动 */
|
/* 防止页面滚动 */
|
||||||
body.widget-bot-modal-open {
|
body.widget-bot-modal-open {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
@ -147,19 +205,34 @@ body.widget-bot-modal-open {
|
||||||
|
|
||||||
/* 暗色主题支持 - 基于data-theme属性 */
|
/* 暗色主题支持 - 基于data-theme属性 */
|
||||||
.widget-bot-button[data-theme="dark"] {
|
.widget-bot-button[data-theme="dark"] {
|
||||||
background: #6E73FE;
|
|
||||||
box-shadow: 0 2px 4px rgba(110, 115, 254, 0.15);
|
box-shadow: 0 2px 4px rgba(110, 115, 254, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button[data-theme="dark"]:hover {
|
.widget-bot-side-sticky[data-theme="dark"] {
|
||||||
|
background: #6E73FE;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-side-sticky[data-theme="dark"]:hover {
|
||||||
background: #5d68fd;
|
background: #5d68fd;
|
||||||
box-shadow: 0 4px 8px rgba(110, 115, 254, 0.2);
|
box-shadow: 0 4px 8px rgba(110, 115, 254, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button[data-theme="dark"].dragging {
|
.widget-bot-side-sticky[data-theme="dark"].dragging {
|
||||||
box-shadow: 0 6px 12px rgba(110, 115, 254, 0.25);
|
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"] {
|
.widget-bot-modal[data-theme="dark"] {
|
||||||
background: rgba(0, 0, 0, 0.7);
|
background: rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|
@ -169,61 +242,63 @@ body.widget-bot-modal-open {
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 移动端适配 */
|
/* 移动端适配 - 统一处理 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.widget-bot-button {
|
.widget-bot-side-sticky {
|
||||||
bottom: 16px;
|
width: 48px;
|
||||||
padding: 8px;
|
padding: 6px 6px 12px 6px;
|
||||||
border-radius: 10px 0 0 10px;
|
border-radius: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-hover-ball {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-hover-ball .widget-bot-hover-ball-icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-text {
|
.widget-bot-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-logo {
|
.widget-bot-icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端弹框统一居中显示,宽度100%-32px,高度90vh */
|
||||||
.widget-bot-modal-content {
|
.widget-bot-modal-content {
|
||||||
width: calc(100% - 60.5px);
|
position: relative !important;
|
||||||
height: 90%;
|
width: calc(100% - 32px) !important;
|
||||||
max-width: none;
|
height: 90vh !important;
|
||||||
max-height: none;
|
max-width: none !important;
|
||||||
|
max-height: 90vh !important;
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
right: auto !important;
|
||||||
|
bottom: auto !important;
|
||||||
|
margin: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-modal-content-fixed {
|
||||||
|
width: calc(100% - 32px) !important;
|
||||||
|
height: 90vh !important;
|
||||||
|
max-height: 90vh !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-close-btn {
|
.widget-bot-close-btn {
|
||||||
top: 8px;
|
top: 22.5px;
|
||||||
right: 8px;
|
right: 16px;
|
||||||
width: 32px;
|
width: 36.26px;
|
||||||
height: 32px;
|
height: 25px;
|
||||||
font-size: 16px;
|
font-size: 0;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 小屏幕适配 */
|
|
||||||
@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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -274,19 +349,32 @@ body.widget-bot-modal-open {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 浅色主题样式 - 显式定义 */
|
/* 浅色主题样式 - 显式定义 */
|
||||||
.widget-bot-button[data-theme="light"] {
|
.widget-bot-side-sticky[data-theme="light"] {
|
||||||
background: #3248F2;
|
background: #FFFFFF;
|
||||||
color: #FFFFFF;
|
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
|
||||||
box-shadow: 0px 6px 10px 0px rgba(54, 59, 76, 0.17);
|
border: 1px solid #ECEEF1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button[data-theme="light"]:hover {
|
.widget-bot-side-sticky[data-theme="light"]:hover {
|
||||||
background: #2a3cdb;
|
background: #FFFFFF;
|
||||||
box-shadow: 0 4px 8px rgba(50, 72, 242, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-button[data-theme="light"].dragging {
|
.widget-bot-side-sticky[data-theme="light"].dragging {
|
||||||
box-shadow: 0 6px 12px rgba(50, 72, 242, 0.25);
|
background: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-hover-ball[data-theme="light"] {
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0px 6px 16px 0px rgba(33, 34, 45, 0.02), 0px 8px 40px 0px rgba(50, 73, 45, 0.12);
|
||||||
|
border: 1px solid #ECEEF1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-hover-ball[data-theme="light"]:hover {
|
||||||
|
transform: scale(1.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-bot-hover-ball[data-theme="light"].dragging {
|
||||||
|
box-shadow: 0 8px 20px rgba(50, 72, 242, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.widget-bot-modal[data-theme="light"] {
|
.widget-bot-modal[data-theme="light"] {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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 currentScript = document.currentScript || document.querySelector('script[src*="widget-bot.js"]');
|
||||||
const widgetDomain = currentScript ? new URL(currentScript.src).origin : window.location.origin;
|
const widgetDomain = currentScript ? new URL(currentScript.src).origin : window.location.origin;
|
||||||
|
|
@ -11,6 +15,13 @@
|
||||||
let isDragging = false;
|
let isDragging = false;
|
||||||
let dragOffset = { x: 0, y: 0 };
|
let dragOffset = { x: 0, y: 0 };
|
||||||
let currentTheme = 'light'; // 默认浅色主题
|
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) {
|
function applyTheme(theme_mode) {
|
||||||
|
|
@ -60,13 +71,22 @@
|
||||||
applyTheme(widgetInfo.theme_mode);
|
applyTheme(widgetInfo.theme_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
createWidget();
|
// 根据 btn_style 创建不同的挂件
|
||||||
|
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
|
||||||
|
if (btnStyle === 'btn_trigger') {
|
||||||
|
createCustomTrigger();
|
||||||
|
} else {
|
||||||
|
createWidget();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取挂件信息失败:', error);
|
console.error('获取挂件信息失败:', error);
|
||||||
// 使用默认值
|
// 使用默认值
|
||||||
widgetInfo = {
|
widgetInfo = {
|
||||||
btn_text: '在线客服',
|
btn_text: '在线客服',
|
||||||
btn_logo: '',
|
btn_logo: `''`,
|
||||||
|
btn_style: defaultBtnStyle,
|
||||||
|
btn_position: defaultBtnPosition,
|
||||||
|
modal_position: defaultModalPosition,
|
||||||
theme_mode: 'light'
|
theme_mode: 'light'
|
||||||
};
|
};
|
||||||
applyTheme(widgetInfo.theme_mode);
|
applyTheme(widgetInfo.theme_mode);
|
||||||
|
|
@ -78,53 +98,92 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建垂直文字
|
// 创建两行文字(每行两个字)
|
||||||
function createVerticalText(text) {
|
function createTwoLineText(text) {
|
||||||
return text.split('').map((char, index) =>
|
const chars = text.split('').filter(it => !!it.trim());
|
||||||
`<span>${char}</span>`
|
const lines = [];
|
||||||
).join('');
|
for (let i = 0; i < chars.length; i += 2) {
|
||||||
|
lines.push(chars.slice(i, i + 2).join(''));
|
||||||
|
}
|
||||||
|
return lines.map(line => `<span>${line}</span>`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建挂件按钮
|
// 应用按钮位置
|
||||||
function createWidget() {
|
function applyButtonPosition(button, position) {
|
||||||
// 如果已存在,先删除
|
const pos = position || defaultBtnPosition;
|
||||||
if (widgetButton) {
|
button.style.top = 'auto';
|
||||||
widgetButton.remove();
|
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 = document.createElement('div');
|
||||||
widgetButton.className = 'widget-bot-button';
|
widgetButton.className = 'widget-bot-button widget-bot-side-sticky';
|
||||||
widgetButton.setAttribute('role', 'button');
|
widgetButton.setAttribute('role', 'button');
|
||||||
widgetButton.setAttribute('tabindex', '0');
|
widgetButton.setAttribute('tabindex', '0');
|
||||||
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text}窗口`);
|
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
|
||||||
widgetButton.setAttribute('data-theme', currentTheme);
|
widgetButton.setAttribute('data-theme', currentTheme);
|
||||||
|
|
||||||
const buttonContent = document.createElement('div');
|
const buttonContent = document.createElement('div');
|
||||||
buttonContent.className = 'widget-bot-button-content';
|
buttonContent.className = 'widget-bot-button-content';
|
||||||
|
|
||||||
// 添加logo(如果有)
|
// 侧边吸附显示图标和文字(btn_logo 以及 btn_text)
|
||||||
if (widgetInfo.btn_logo) {
|
const icon = document.createElement('img');
|
||||||
const logo = document.createElement('img');
|
const defaultIconSrc = widgetDomain + '/favicon.png';
|
||||||
logo.src = widgetDomain + widgetInfo.btn_logo;
|
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
|
||||||
logo.alt = 'logo';
|
icon.alt = 'icon';
|
||||||
logo.className = 'widget-bot-logo';
|
icon.className = 'widget-bot-icon';
|
||||||
logo.onerror = () => {
|
icon.onerror = () => {
|
||||||
logo.style.display = 'none';
|
// 如果当前不是 favicon.png,尝试使用 favicon.png 作为备用
|
||||||
};
|
if (icon.src !== defaultIconSrc) {
|
||||||
buttonContent.appendChild(logo);
|
icon.src = defaultIconSrc;
|
||||||
}
|
} else {
|
||||||
|
// 如果 favicon.png 也加载失败,隐藏图标
|
||||||
|
icon.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
buttonContent.appendChild(icon);
|
||||||
|
|
||||||
// 添加文字
|
// 添加文字
|
||||||
const textDiv = document.createElement('div');
|
const textDiv = document.createElement('div');
|
||||||
textDiv.className = 'widget-bot-text';
|
textDiv.className = 'widget-bot-text';
|
||||||
textDiv.innerHTML = createVerticalText(widgetInfo.btn_text || '在线客服');
|
textDiv.innerHTML = createTwoLineText(widgetInfo.btn_text || '在线客服');
|
||||||
buttonContent.appendChild(textDiv);
|
buttonContent.appendChild(textDiv);
|
||||||
|
|
||||||
widgetButton.appendChild(buttonContent);
|
widgetButton.appendChild(buttonContent);
|
||||||
|
|
||||||
|
// 应用位置 - 距离边缘16px,垂直方向190px
|
||||||
|
const position = widgetInfo.btn_position || defaultBtnPosition;
|
||||||
|
applyButtonPosition(widgetButton, position);
|
||||||
|
|
||||||
|
// 设置 border-radius 为 24px(统一圆角)
|
||||||
|
widgetButton.style.borderRadius = '24px';
|
||||||
|
|
||||||
// 添加事件监听器
|
// 添加事件监听器
|
||||||
widgetButton.addEventListener('click', showModal);
|
widgetButton.addEventListener('click', handleButtonClick);
|
||||||
widgetButton.addEventListener('mousedown', startDrag);
|
widgetButton.addEventListener('mousedown', startDrag);
|
||||||
widgetButton.addEventListener('keydown', handleKeyDown);
|
widgetButton.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
|
@ -134,6 +193,69 @@
|
||||||
widgetButton.addEventListener('touchend', handleTouchEnd);
|
widgetButton.addEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
document.body.appendChild(widgetButton);
|
document.body.appendChild(widgetButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建悬浮球按钮
|
||||||
|
function createHoverBallButton() {
|
||||||
|
widgetButton = document.createElement('div');
|
||||||
|
widgetButton.className = 'widget-bot-button widget-bot-hover-ball';
|
||||||
|
widgetButton.setAttribute('role', 'button');
|
||||||
|
widgetButton.setAttribute('tabindex', '0');
|
||||||
|
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
|
||||||
|
widgetButton.setAttribute('data-theme', currentTheme);
|
||||||
|
|
||||||
|
const buttonContent = document.createElement('div');
|
||||||
|
buttonContent.className = 'widget-bot-button-content';
|
||||||
|
|
||||||
|
// 悬浮球只显示图标(btn_logo)
|
||||||
|
const icon = document.createElement('img');
|
||||||
|
const defaultIconSrc = widgetDomain + '/favicon.png';
|
||||||
|
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
|
||||||
|
icon.alt = 'icon';
|
||||||
|
icon.className = 'widget-bot-icon widget-bot-hover-ball-icon';
|
||||||
|
icon.onerror = () => {
|
||||||
|
// 如果当前不是 favicon.png,尝试使用 favicon.png 作为备用
|
||||||
|
if (icon.src !== defaultIconSrc) {
|
||||||
|
icon.src = defaultIconSrc;
|
||||||
|
} else {
|
||||||
|
// 如果 favicon.png 也加载失败,隐藏图标
|
||||||
|
icon.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
buttonContent.appendChild(icon);
|
||||||
|
|
||||||
|
widgetButton.appendChild(buttonContent);
|
||||||
|
|
||||||
|
// 应用位置 - 距离边缘16px,垂直方向190px
|
||||||
|
applyButtonPosition(widgetButton, widgetInfo.btn_position || defaultBtnPosition);
|
||||||
|
|
||||||
|
// 添加事件监听器
|
||||||
|
widgetButton.addEventListener('click', handleButtonClick);
|
||||||
|
widgetButton.addEventListener('mousedown', startDrag);
|
||||||
|
widgetButton.addEventListener('keydown', handleKeyDown);
|
||||||
|
|
||||||
|
// 添加触摸事件支持
|
||||||
|
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||||
|
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
widgetButton.addEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
|
document.body.appendChild(widgetButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建挂件按钮
|
||||||
|
function createWidget() {
|
||||||
|
// 如果已存在,先删除
|
||||||
|
if (widgetButton) {
|
||||||
|
widgetButton.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
|
||||||
|
|
||||||
|
if (btnStyle === 'hover_ball') {
|
||||||
|
createHoverBallButton();
|
||||||
|
} else {
|
||||||
|
createSideStickyButton();
|
||||||
|
}
|
||||||
|
|
||||||
// 创建模态框
|
// 创建模态框
|
||||||
createModal();
|
createModal();
|
||||||
|
|
@ -145,6 +267,109 @@
|
||||||
}, 100);
|
}, 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) {
|
function handleKeyDown(e) {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
|
@ -176,7 +401,8 @@
|
||||||
Math.pow(touch.clientY - touchStartPos.y, 2)
|
Math.pow(touch.clientY - touchStartPos.y, 2)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (distance < 10) {
|
// 只有在没有拖拽且移动距离很小的情况下才认为是点击
|
||||||
|
if (!hasDragged && distance < 10) {
|
||||||
// 判断为点击事件
|
// 判断为点击事件
|
||||||
setTimeout(() => showModal(), 100);
|
setTimeout(() => showModal(), 100);
|
||||||
}
|
}
|
||||||
|
|
@ -198,22 +424,41 @@
|
||||||
widgetModal.setAttribute('aria-labelledby', 'widget-modal-title');
|
widgetModal.setAttribute('aria-labelledby', 'widget-modal-title');
|
||||||
widgetModal.setAttribute('data-theme', currentTheme);
|
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');
|
const modalContent = document.createElement('div');
|
||||||
modalContent.className = 'widget-bot-modal-content';
|
modalContent.className = 'widget-bot-modal-content';
|
||||||
|
if (modalPosition === 'fixed') {
|
||||||
|
modalContent.classList.add('widget-bot-modal-content-fixed');
|
||||||
|
}
|
||||||
|
|
||||||
// 创建关闭按钮
|
// 创建关闭按钮(透明框)
|
||||||
const closeBtn = document.createElement('button');
|
const closeBtn = document.createElement('button');
|
||||||
closeBtn.className = 'widget-bot-close-btn';
|
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('aria-label', '关闭窗口');
|
||||||
closeBtn.setAttribute('type', 'button');
|
closeBtn.setAttribute('type', 'button');
|
||||||
closeBtn.addEventListener('click', hideModal);
|
|
||||||
|
// 创建一个内部元素来处理实际的点击事件(因为按钮设置了 pointer-events: none)
|
||||||
|
const closeBtnArea = document.createElement('div');
|
||||||
|
closeBtnArea.style.width = '100%';
|
||||||
|
closeBtnArea.style.height = '100%';
|
||||||
|
closeBtnArea.style.pointerEvents = 'auto'; // 内部元素可以接收事件
|
||||||
|
closeBtnArea.style.cursor = 'pointer';
|
||||||
|
closeBtnArea.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
hideModal();
|
||||||
|
});
|
||||||
|
closeBtn.appendChild(closeBtnArea);
|
||||||
|
|
||||||
// 创建iframe
|
// 创建iframe
|
||||||
const iframe = document.createElement('iframe');
|
const iframe = document.createElement('iframe');
|
||||||
iframe.className = 'widget-bot-iframe';
|
iframe.className = 'widget-bot-iframe';
|
||||||
iframe.src = `${widgetDomain}/widget`;
|
iframe.src = `${widgetDomain}/widget`;
|
||||||
iframe.setAttribute('title', `${widgetInfo.btn_text}服务窗口`);
|
iframe.setAttribute('title', `${widgetInfo.btn_text || '在线客服'}服务窗口`);
|
||||||
iframe.setAttribute('allow', 'camera; microphone; geolocation');
|
iframe.setAttribute('allow', 'camera; microphone; geolocation');
|
||||||
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation');
|
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation');
|
||||||
|
|
||||||
|
|
@ -224,6 +469,156 @@
|
||||||
document.body.appendChild(widgetModal);
|
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() {
|
function showModal() {
|
||||||
if (!widgetModal) return;
|
if (!widgetModal) return;
|
||||||
|
|
@ -231,27 +626,31 @@
|
||||||
widgetModal.style.display = 'flex';
|
widgetModal.style.display = 'flex';
|
||||||
document.body.classList.add('widget-bot-modal-open');
|
document.body.classList.add('widget-bot-modal-open');
|
||||||
|
|
||||||
// 计算模态框位置
|
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
|
||||||
requestAnimationFrame(() => {
|
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
||||||
const buttonRect = widgetButton.getBoundingClientRect();
|
|
||||||
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
|
||||||
|
|
||||||
if (modalContent) {
|
// 移动端强制居中显示
|
||||||
// 设置模态框位置:距离按钮16px,距离底部24px
|
if (isMobile()) {
|
||||||
const modalBottom = 24;
|
modalContent.style.position = 'relative';
|
||||||
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16);
|
modalContent.style.top = 'auto';
|
||||||
|
modalContent.style.left = 'auto';
|
||||||
modalContent.style.bottom = modalBottom + 'px';
|
modalContent.style.right = 'auto';
|
||||||
modalContent.style.right = modalRight + 'px';
|
modalContent.style.bottom = 'auto';
|
||||||
|
modalContent.style.margin = 'auto';
|
||||||
// 确保模态框不会超出屏幕
|
modalContent.style.width = 'calc(100% - 32px)';
|
||||||
const modalRect = modalContent.getBoundingClientRect();
|
modalContent.style.height = 'auto';
|
||||||
if (modalRect.left < 16) {
|
} else if (modalPosition === 'fixed') {
|
||||||
modalContent.style.right = '16px';
|
// 桌面端固定模式:居中展示
|
||||||
modalContent.style.left = '16px';
|
modalContent.style.position = 'relative';
|
||||||
}
|
modalContent.style.top = 'auto';
|
||||||
}
|
modalContent.style.left = 'auto';
|
||||||
});
|
modalContent.style.right = 'auto';
|
||||||
|
modalContent.style.bottom = 'auto';
|
||||||
|
modalContent.style.margin = 'auto';
|
||||||
|
} else {
|
||||||
|
// 桌面端跟随模式:跟随按钮位置 - 智能定位,确保弹框完整显示在屏幕内
|
||||||
|
positionModalFollow(modalContent);
|
||||||
|
}
|
||||||
|
|
||||||
// 添加ESC键关闭功能
|
// 添加ESC键关闭功能
|
||||||
document.addEventListener('keydown', handleEscKey);
|
document.addEventListener('keydown', handleEscKey);
|
||||||
|
|
@ -287,42 +686,98 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
isDragging = true;
|
isDragging = true;
|
||||||
|
hasDragged = false; // 重置拖拽标记
|
||||||
|
|
||||||
const rect = widgetButton.getBoundingClientRect();
|
const rect = widgetButton.getBoundingClientRect();
|
||||||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||||||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||||||
|
|
||||||
dragOffset.x = clientX - rect.left;
|
// 记录拖拽开始位置
|
||||||
dragOffset.y = clientY - rect.top;
|
dragStartPos.x = clientX;
|
||||||
|
dragStartPos.y = clientY;
|
||||||
|
|
||||||
// 清除bottom定位,使用top定位
|
// 缓存按钮尺寸,避免拖拽过程中频繁读取
|
||||||
widgetButton.style.bottom = 'auto';
|
buttonSize.width = rect.width;
|
||||||
widgetButton.style.top = rect.top + 'px';
|
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.position = 'fixed';
|
||||||
|
widgetButton.style.top = realRect.top + 'px';
|
||||||
|
widgetButton.style.left = realRect.left + 'px';
|
||||||
|
widgetButton.style.right = 'auto';
|
||||||
|
widgetButton.style.bottom = 'auto';
|
||||||
|
|
||||||
document.addEventListener('mousemove', drag);
|
// 禁用过渡效果,提升拖拽性能
|
||||||
|
widgetButton.style.transition = 'none';
|
||||||
|
|
||||||
|
// 提示浏览器优化(使用 left/top 定位)
|
||||||
|
widgetButton.style.willChange = 'left, top';
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', drag, { passive: false });
|
||||||
document.addEventListener('mouseup', stopDrag);
|
document.addEventListener('mouseup', stopDrag);
|
||||||
|
|
||||||
widgetButton.classList.add('dragging');
|
widgetButton.classList.add('dragging');
|
||||||
widgetButton.style.zIndex = '10001';
|
widgetButton.style.zIndex = '10001';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 拖拽中
|
// 拖拽中 - 直接更新位置,实现丝滑跟随
|
||||||
function drag(e) {
|
function drag(e) {
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
if (e.preventDefault) {
|
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);
|
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 newTop = clientY - dragOffset.y;
|
||||||
const maxTop = window.innerHeight - widgetButton.offsetHeight;
|
|
||||||
|
|
||||||
// 限制在屏幕范围内
|
// 垂直位置:限制在屏幕范围内,距离顶部和底部最小距离为 24px
|
||||||
const constrainedTop = Math.max(0, Math.min(newTop, maxTop));
|
const minTop = 24;
|
||||||
|
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
|
||||||
|
const constrainedTop = Math.max(minTop, Math.min(newTop, maxTop));
|
||||||
|
|
||||||
|
// 水平位置:限制在屏幕范围内
|
||||||
|
const maxLeft = windowWidth - buttonWidth;
|
||||||
|
const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft));
|
||||||
|
|
||||||
|
// 直接使用 left/top 定位,实现无延迟的丝滑跟随
|
||||||
|
// 使用 transform: none 确保不会有任何 transform 干扰
|
||||||
|
widgetButton.style.left = constrainedLeft + 'px';
|
||||||
widgetButton.style.top = constrainedTop + 'px';
|
widgetButton.style.top = constrainedTop + 'px';
|
||||||
|
widgetButton.style.right = 'auto';
|
||||||
|
widgetButton.style.bottom = 'auto';
|
||||||
|
widgetButton.style.transform = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止拖拽
|
// 停止拖拽
|
||||||
|
|
@ -330,26 +785,75 @@
|
||||||
if (!isDragging) return;
|
if (!isDragging) return;
|
||||||
|
|
||||||
isDragging = false;
|
isDragging = false;
|
||||||
|
|
||||||
|
// 取消待执行的动画帧
|
||||||
|
if (dragAnimationFrame) {
|
||||||
|
cancelAnimationFrame(dragAnimationFrame);
|
||||||
|
dragAnimationFrame = null;
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener('mousemove', drag);
|
document.removeEventListener('mousemove', drag);
|
||||||
document.removeEventListener('mouseup', stopDrag);
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
|
|
||||||
widgetButton.classList.remove('dragging');
|
widgetButton.classList.remove('dragging');
|
||||||
widgetButton.style.zIndex = '9999';
|
widgetButton.style.zIndex = '9999';
|
||||||
|
|
||||||
// 吸附到右侧,恢复bottom定位
|
// 恢复过渡效果
|
||||||
|
widgetButton.style.transition = '';
|
||||||
|
widgetButton.style.willChange = '';
|
||||||
|
|
||||||
|
// 根据按钮类型和当前位置进行最终定位
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const currentTop = parseInt(widgetButton.style.top);
|
const buttonRect = widgetButton.getBoundingClientRect();
|
||||||
|
const currentLeft = buttonRect.left;
|
||||||
|
const currentTop = buttonRect.top;
|
||||||
|
const windowWidth = window.innerWidth;
|
||||||
const windowHeight = window.innerHeight;
|
const windowHeight = window.innerHeight;
|
||||||
const buttonHeight = widgetButton.offsetHeight;
|
const buttonWidth = buttonSize.width;
|
||||||
|
const buttonHeight = buttonSize.height;
|
||||||
|
|
||||||
// 计算距离底部的位置
|
// 两种模式使用相同的停止拖拽逻辑:只能左右侧边缘吸附
|
||||||
const bottomPosition = windowHeight - currentTop - buttonHeight;
|
// 根据按钮实际位置判断左右,保持当前位置
|
||||||
|
const screenCenterX = windowWidth / 2;
|
||||||
|
const buttonCenterX = currentLeft + buttonWidth / 2;
|
||||||
|
const isLeftSide = buttonCenterX < screenCenterX;
|
||||||
|
const sideDistance = 16; // 距离边缘的距离
|
||||||
|
|
||||||
// 恢复right和bottom定位,清除top
|
// 垂直位置:保持在当前位置,限制在屏幕范围内,距离顶部和底部最小距离为 24px
|
||||||
widgetButton.style.right = '0';
|
const minTop = 24;
|
||||||
widgetButton.style.bottom = Math.max(20, bottomPosition) + 'px';
|
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
|
||||||
widgetButton.style.top = 'auto';
|
const finalTop = Math.max(minTop, Math.min(currentTop, maxTop));
|
||||||
widgetButton.style.left = 'auto';
|
let finalLeft;
|
||||||
|
|
||||||
|
// 水平位置:距离左右边16px
|
||||||
|
if (isLeftSide) {
|
||||||
|
finalLeft = sideDistance;
|
||||||
|
widgetButton.style.left = sideDistance + 'px';
|
||||||
|
widgetButton.style.right = 'auto';
|
||||||
|
} else {
|
||||||
|
finalLeft = windowWidth - sideDistance - buttonWidth;
|
||||||
|
widgetButton.style.right = sideDistance + 'px';
|
||||||
|
widgetButton.style.left = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetButton.style.top = finalTop + 'px';
|
||||||
|
widgetButton.style.bottom = 'auto';
|
||||||
|
|
||||||
|
// 清除 transform,使用 left/top 定位
|
||||||
|
widgetButton.style.transform = 'none';
|
||||||
|
|
||||||
|
// 更新 border-radius(现在都是24px圆角)
|
||||||
|
widgetButton.style.borderRadius = '24px';
|
||||||
|
|
||||||
|
// 更新初始位置,为下次拖拽做准备
|
||||||
|
if (finalLeft !== undefined && finalTop !== undefined) {
|
||||||
|
initialPosition.left = finalLeft;
|
||||||
|
initialPosition.top = finalTop;
|
||||||
|
} else {
|
||||||
|
// 如果未定义,使用当前实际位置
|
||||||
|
initialPosition.left = buttonRect.left;
|
||||||
|
initialPosition.top = buttonRect.top;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -390,19 +894,30 @@
|
||||||
// 窗口大小改变时重新定位
|
// 窗口大小改变时重新定位
|
||||||
window.addEventListener('resize', function () {
|
window.addEventListener('resize', function () {
|
||||||
if (widgetModal && widgetModal.style.display === 'flex') {
|
if (widgetModal && widgetModal.style.display === 'flex') {
|
||||||
// 重新计算模态框位置
|
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
||||||
setTimeout(() => {
|
if (!modalContent) return;
|
||||||
const buttonRect = widgetButton.getBoundingClientRect();
|
|
||||||
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
|
||||||
|
|
||||||
if (modalContent) {
|
// 移动端强制居中显示
|
||||||
const modalBottom = 24;
|
if (isMobile()) {
|
||||||
const modalRight = Math.max(16, window.innerWidth - buttonRect.left + 16);
|
modalContent.style.position = 'relative';
|
||||||
|
modalContent.style.top = 'auto';
|
||||||
|
modalContent.style.left = 'auto';
|
||||||
|
modalContent.style.right = 'auto';
|
||||||
|
modalContent.style.bottom = 'auto';
|
||||||
|
modalContent.style.margin = 'auto';
|
||||||
|
modalContent.style.width = 'calc(100% - 32px)';
|
||||||
|
modalContent.style.height = 'auto';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
modalContent.style.bottom = modalBottom + 'px';
|
const modalPosition = widgetInfo?.modal_position || defaultModalPosition;
|
||||||
modalContent.style.right = modalRight + 'px';
|
if (modalPosition === 'fixed') {
|
||||||
}
|
// 固定居中模式不需要重新定位
|
||||||
}, 100);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重新计算模态框位置(使用智能定位)
|
||||||
|
positionModalFollow(modalContent);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -423,8 +938,13 @@
|
||||||
if (widgetModal) {
|
if (widgetModal) {
|
||||||
widgetModal.remove();
|
widgetModal.remove();
|
||||||
}
|
}
|
||||||
|
if (customTriggerElement && customTriggerHandler) {
|
||||||
|
customTriggerElement.removeEventListener('click', customTriggerHandler);
|
||||||
|
customTriggerElement.removeAttribute('data-widget-trigger-attached');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 启动
|
// 启动
|
||||||
init();
|
init();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { ThemeStoreProvider } from '@/provider/themeStore';
|
||||||
import { getShareV1AppWebInfo } from '@/request/ShareApp';
|
import { getShareV1AppWebInfo } from '@/request/ShareApp';
|
||||||
import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth';
|
import { getShareProV1AuthInfo } from '@/request/pro/ShareAuth';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter';
|
import { AppRouterCacheProvider } from '@mui/material-nextjs/v16-appRouter';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import localFont from 'next/font/local';
|
import localFont from 'next/font/local';
|
||||||
import { headers, cookies } from 'next/headers';
|
import { headers, cookies } from 'next/headers';
|
||||||
|
|
@ -92,7 +92,7 @@ const Layout = async ({
|
||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang='en'>
|
||||||
<body
|
<body
|
||||||
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : ''}`}
|
className={`${gilory.variable} ${themeMode === 'dark' ? 'dark' : 'light'}`}
|
||||||
>
|
>
|
||||||
<AppRouterCacheProvider>
|
<AppRouterCacheProvider>
|
||||||
<ThemeStoreProvider themeMode={themeMode}>
|
<ThemeStoreProvider themeMode={themeMode}>
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
--color-primary-main: #6e73fe;
|
--color-primary-main: #6e73fe;
|
||||||
|
|
||||||
/* 代码块颜色 */
|
/* 代码块颜色 */
|
||||||
--code-bg: #ffffff;
|
--code-bg: rgba(0, 0, 0, 0.03);
|
||||||
--code-color: #21222d;
|
--code-color: #21222d;
|
||||||
--inline-code-bg: #fff5f5;
|
--inline-code-bg: #fff5f5;
|
||||||
--inline-code-color: #ff502c;
|
--inline-code-color: #ff502c;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import StoreProvider from '@/provider';
|
import StoreProvider from '@/provider';
|
||||||
import { darkThemeWidget, lightThemeWidget } from '@/theme';
|
|
||||||
import { getShareV1AppWidgetInfo } from '@/request/ShareApp';
|
import { getShareV1AppWidgetInfo } from '@/request/ShareApp';
|
||||||
import { ThemeProvider } from '@ctzhian/ui';
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
const Layout = async ({
|
const Layout = async ({
|
||||||
|
|
@ -12,18 +9,7 @@ const Layout = async ({
|
||||||
}>) => {
|
}>) => {
|
||||||
const widgetDetail: any = await getShareV1AppWidgetInfo();
|
const widgetDetail: any = await getShareV1AppWidgetInfo();
|
||||||
|
|
||||||
const themeMode =
|
return <StoreProvider widget={widgetDetail}>{children}</StoreProvider>;
|
||||||
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;
|
export default Layout;
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,3 @@
|
||||||
import Widget from '@/views/widget';
|
import Widget from '@/views/widget';
|
||||||
import { Box } from '@mui/material';
|
|
||||||
|
|
||||||
const Page = () => {
|
export default Widget;
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
width: '100vw',
|
|
||||||
height: '100vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Widget />
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
|
|
|
||||||
|
|
@ -109,10 +109,19 @@ export type WidgetInfo = {
|
||||||
search_placeholder: string;
|
search_placeholder: string;
|
||||||
recommend_questions: string[];
|
recommend_questions: string[];
|
||||||
widget_bot_settings: {
|
widget_bot_settings: {
|
||||||
btn_logo: string;
|
btn_logo?: string;
|
||||||
btn_text: string;
|
btn_text?: string;
|
||||||
is_open: boolean;
|
btn_style?: string;
|
||||||
theme_mode: 'light' | 'dark';
|
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;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Logo from '@/assets/images/logo.png';
|
import Logo from '@/assets/images/logo.png';
|
||||||
import { Box } from '@mui/material';
|
import { Stack, Box, IconButton, alpha, Tooltip } from '@mui/material';
|
||||||
|
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
|
||||||
|
import { IconDengchu } from '@panda-wiki/icons';
|
||||||
import { useStore } from '@/provider';
|
import { useStore } from '@/provider';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import ErrorIcon from '@mui/icons-material/Error';
|
||||||
|
import { Modal } from '@ctzhian/ui';
|
||||||
import {
|
import {
|
||||||
Header as CustomHeader,
|
Header as CustomHeader,
|
||||||
WelcomeHeader as WelcomeHeaderComponent,
|
WelcomeHeader as WelcomeHeaderComponent,
|
||||||
|
|
@ -16,8 +20,53 @@ interface HeaderProps {
|
||||||
isWelcomePage?: boolean;
|
isWelcomePage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LogoutButton = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const handleLogout = () => {
|
||||||
|
return postShareProV1AuthLogout().then(() => {
|
||||||
|
window.location.href = '/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}
|
||||||
|
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 Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
|
||||||
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
|
const {
|
||||||
|
mobile = false,
|
||||||
|
kbDetail,
|
||||||
|
catalogWidth,
|
||||||
|
setQaModalOpen,
|
||||||
|
authInfo,
|
||||||
|
} = useStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const docWidth = useMemo(() => {
|
const docWidth = useMemo(() => {
|
||||||
if (isWelcomePage) return 'full';
|
if (isWelcomePage) return 'full';
|
||||||
|
|
@ -55,16 +104,23 @@ const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onQaClick={() => setQaModalOpen?.(true)}
|
onQaClick={() => setQaModalOpen?.(true)}
|
||||||
>
|
>
|
||||||
<Box sx={{ ml: 2 }}>
|
<Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
|
||||||
<ThemeSwitch />
|
<ThemeSwitch />
|
||||||
</Box>
|
{!!authInfo && <LogoutButton />}
|
||||||
|
</Stack>
|
||||||
<QaModal />
|
<QaModal />
|
||||||
</CustomHeader>
|
</CustomHeader>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WelcomeHeader = () => {
|
export const WelcomeHeader = () => {
|
||||||
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
|
const {
|
||||||
|
mobile = false,
|
||||||
|
kbDetail,
|
||||||
|
catalogWidth,
|
||||||
|
setQaModalOpen,
|
||||||
|
authInfo,
|
||||||
|
} = useStore();
|
||||||
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
|
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
|
||||||
if (value?.trim()) {
|
if (value?.trim()) {
|
||||||
if (type === 'chat') {
|
if (type === 'chat') {
|
||||||
|
|
@ -91,6 +147,7 @@ export const WelcomeHeader = () => {
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onQaClick={() => setQaModalOpen?.(true)}
|
onQaClick={() => setQaModalOpen?.(true)}
|
||||||
>
|
>
|
||||||
|
<Box sx={{ ml: 2 }}>{!!authInfo && <LogoutButton />}</Box>
|
||||||
<QaModal />
|
<QaModal />
|
||||||
</WelcomeHeaderComponent>
|
</WelcomeHeaderComponent>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,12 @@ import React, { useState, useEffect } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { styled, SvgIcon, SvgIconProps } from '@mui/material';
|
import { styled, SvgIcon, SvgIconProps } from '@mui/material';
|
||||||
|
|
||||||
// ==================== 图片数据缓存 ====================
|
// ==================== 图片数据缓存工具函数 ====================
|
||||||
// 全局图片 blob URL 缓存,避免重复请求 OSS
|
|
||||||
const imageBlobCache = new Map<string, string>();
|
|
||||||
|
|
||||||
// 下载图片并转换为 blob URL
|
// 下载图片并转换为 blob URL
|
||||||
const fetchImageAsBlob = async (src: string): Promise<string> => {
|
const fetchImageAsBlob = async (
|
||||||
|
src: string,
|
||||||
|
imageBlobCache: Map<string, string>,
|
||||||
|
): Promise<string> => {
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
if (imageBlobCache.has(src)) {
|
if (imageBlobCache.has(src)) {
|
||||||
return imageBlobCache.get(src)!;
|
return imageBlobCache.get(src)!;
|
||||||
|
|
@ -39,12 +39,8 @@ const fetchImageAsBlob = async (src: string): Promise<string> => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 导出获取图片 blob URL 的函数
|
// 清理图片 blob 缓存
|
||||||
export const getImageBlobUrl = (src: string): string | null => {
|
export const clearImageBlobCache = (imageBlobCache: Map<string, string>) => {
|
||||||
return imageBlobCache.get(src) || null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearImageBlobCache = () => {
|
|
||||||
imageBlobCache.forEach(url => {
|
imageBlobCache.forEach(url => {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
});
|
});
|
||||||
|
|
@ -54,7 +50,7 @@ export const clearImageBlobCache = () => {
|
||||||
const StyledErrorContainer = styled('div')(({ theme }) => ({
|
const StyledErrorContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(1, 6),
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
|
|
@ -71,7 +67,7 @@ const StyledErrorContainer = styled('div')(({ theme }) => ({
|
||||||
|
|
||||||
const StyledErrorText = styled('div')(() => ({
|
const StyledErrorText = styled('div')(() => ({
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
marginBottom: 16,
|
marginBottom: 10,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const ImageErrorIcon = (props: SvgIconProps) => {
|
export const ImageErrorIcon = (props: SvgIconProps) => {
|
||||||
|
|
@ -102,7 +98,7 @@ export const ImageErrorIcon = (props: SvgIconProps) => {
|
||||||
const ImageErrorDisplay: React.FC = () => (
|
const ImageErrorDisplay: React.FC = () => (
|
||||||
<StyledErrorContainer>
|
<StyledErrorContainer>
|
||||||
<ImageErrorIcon
|
<ImageErrorIcon
|
||||||
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 160 }}
|
sx={{ color: 'var(--mui-palette-text-tertiary)', fontSize: 140 }}
|
||||||
/>
|
/>
|
||||||
<StyledErrorText>图片加载失败</StyledErrorText>
|
<StyledErrorText>图片加载失败</StyledErrorText>
|
||||||
</StyledErrorContainer>
|
</StyledErrorContainer>
|
||||||
|
|
@ -116,7 +112,7 @@ interface ImageComponentProps {
|
||||||
imageIndex: number;
|
imageIndex: number;
|
||||||
onLoad: (index: number, html: string) => void;
|
onLoad: (index: number, html: string) => void;
|
||||||
onError: (index: number, html: string) => void;
|
onError: (index: number, html: string) => void;
|
||||||
onImageClick: (src: string) => void;
|
imageBlobCache: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 图片组件 ====================
|
// ==================== 图片组件 ====================
|
||||||
|
|
@ -127,7 +123,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
||||||
imageIndex,
|
imageIndex,
|
||||||
onLoad,
|
onLoad,
|
||||||
onError,
|
onError,
|
||||||
onImageClick,
|
imageBlobCache,
|
||||||
}) => {
|
}) => {
|
||||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
|
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
|
||||||
'loading',
|
'loading',
|
||||||
|
|
@ -149,7 +145,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
||||||
// 获取图片 blob URL
|
// 获取图片 blob URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
fetchImageAsBlob(src)
|
fetchImageAsBlob(src, imageBlobCache)
|
||||||
.then(url => {
|
.then(url => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setBlobUrl(url);
|
setBlobUrl(url);
|
||||||
|
|
@ -166,7 +162,7 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
};
|
};
|
||||||
}, [src]);
|
}, [src, imageBlobCache]);
|
||||||
|
|
||||||
// 解析自定义样式
|
// 解析自定义样式
|
||||||
const parseStyleString = (styleStr: string) => {
|
const parseStyleString = (styleStr: string) => {
|
||||||
|
|
@ -238,7 +234,8 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
onError={handleError}
|
onError={handleError}
|
||||||
onClick={() => onImageClick(src)} // 传递原始 src 用于预览
|
data-original-src={src}
|
||||||
|
className='markdown-image'
|
||||||
{...getOtherProps()}
|
{...getOtherProps()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -264,12 +261,13 @@ const ImageComponent: React.FC<ImageComponentProps> = ({
|
||||||
export interface ImageRendererOptions {
|
export interface ImageRendererOptions {
|
||||||
onImageLoad: (index: number, html: string) => void;
|
onImageLoad: (index: number, html: string) => void;
|
||||||
onImageError: (index: number, html: string) => void;
|
onImageError: (index: number, html: string) => void;
|
||||||
onImageClick: (src: string) => void;
|
|
||||||
imageRenderCache: Map<number, string>;
|
imageRenderCache: Map<number, string>;
|
||||||
|
imageBlobCache: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createImageRenderer = (options: ImageRendererOptions) => {
|
export const createImageRenderer = (options: ImageRendererOptions) => {
|
||||||
const { onImageLoad, onImageError, imageRenderCache, onImageClick } = options;
|
const { onImageLoad, onImageError, imageRenderCache, imageBlobCache } =
|
||||||
|
options;
|
||||||
return (
|
return (
|
||||||
src: string,
|
src: string,
|
||||||
alt: string,
|
alt: string,
|
||||||
|
|
@ -279,29 +277,6 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
|
||||||
// 检查缓存
|
// 检查缓存
|
||||||
const cached = imageRenderCache.get(imageIndex);
|
const cached = imageRenderCache.get(imageIndex);
|
||||||
if (cached) {
|
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;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -323,7 +298,7 @@ export const createImageRenderer = (options: ImageRendererOptions) => {
|
||||||
imageIndex={imageIndex}
|
imageIndex={imageIndex}
|
||||||
onLoad={onImageLoad}
|
onLoad={onImageLoad}
|
||||||
onError={onImageError}
|
onError={onImageError}
|
||||||
onImageClick={onImageClick}
|
imageBlobCache={imageBlobCache}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,7 @@ import React, {
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useSmartScroll } from '@/hooks';
|
import { useSmartScroll } from '@/hooks';
|
||||||
import {
|
import { clearImageBlobCache, createImageRenderer } from './imageRenderer';
|
||||||
clearImageBlobCache,
|
|
||||||
createImageRenderer,
|
|
||||||
getImageBlobUrl,
|
|
||||||
} from './imageRenderer';
|
|
||||||
import { incrementalRender } from './incrementalRenderer';
|
import { incrementalRender } from './incrementalRenderer';
|
||||||
import { createMermaidRenderer } from './mermaidRenderer';
|
import { createMermaidRenderer } from './mermaidRenderer';
|
||||||
import {
|
import {
|
||||||
|
|
@ -88,7 +84,8 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
const lastContentRef = useRef<string>('');
|
const lastContentRef = useRef<string>('');
|
||||||
const mdRef = useRef<MarkdownIt | null>(null);
|
const mdRef = useRef<MarkdownIt | null>(null);
|
||||||
const mermaidSuccessIdRef = useRef<Map<number, string>>(new Map());
|
const mermaidSuccessIdRef = useRef<Map<number, string>>(new Map());
|
||||||
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存
|
const imageRenderCacheRef = useRef<Map<number, string>>(new Map()); // 图片渲染缓存(HTML)
|
||||||
|
const imageBlobCacheRef = useRef<Map<string, string>>(new Map()); // 图片 blob URL 缓存
|
||||||
|
|
||||||
// 使用智能滚动 hook
|
// 使用智能滚动 hook
|
||||||
const { scrollToBottom } = useSmartScroll({
|
const { scrollToBottom } = useSmartScroll({
|
||||||
|
|
@ -125,13 +122,8 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
createImageRenderer({
|
createImageRenderer({
|
||||||
onImageLoad: handleImageLoad,
|
onImageLoad: handleImageLoad,
|
||||||
onImageError: handleImageError,
|
onImageError: handleImageError,
|
||||||
onImageClick: (src: string) => {
|
|
||||||
// 尝试获取缓存的 blob URL,如果不存在则使用原始 src
|
|
||||||
const blobUrl = getImageBlobUrl(src);
|
|
||||||
setPreviewImgBlobUrl(blobUrl || src);
|
|
||||||
setPreviewOpen(true);
|
|
||||||
},
|
|
||||||
imageRenderCache: imageRenderCacheRef.current,
|
imageRenderCache: imageRenderCacheRef.current,
|
||||||
|
imageBlobCache: imageBlobCacheRef.current,
|
||||||
}),
|
}),
|
||||||
[handleImageLoad, handleImageError],
|
[handleImageLoad, handleImageError],
|
||||||
);
|
);
|
||||||
|
|
@ -158,6 +150,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
const originalFenceRender = md.renderer.rules.fence;
|
const originalFenceRender = md.renderer.rules.fence;
|
||||||
// 自定义图片渲染
|
// 自定义图片渲染
|
||||||
let imageCount = 0;
|
let imageCount = 0;
|
||||||
|
let htmlImageCount = 0; // HTML 标签图片计数
|
||||||
let mermaidCount = 0;
|
let mermaidCount = 0;
|
||||||
md.renderer.rules.image = (tokens, idx) => {
|
md.renderer.rules.image = (tokens, idx) => {
|
||||||
imageCount++;
|
imageCount++;
|
||||||
|
|
@ -240,6 +233,38 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 解析 HTML img 标签并提取属性
|
||||||
|
const parseImgTag = (
|
||||||
|
html: string,
|
||||||
|
): {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
attrs: [string, string][];
|
||||||
|
} | null => {
|
||||||
|
// 匹配 <img> 标签(支持自闭合和普通标签)
|
||||||
|
const imgMatch = html.match(/<img\s+([^>]*?)\/?>/i);
|
||||||
|
if (!imgMatch) return null;
|
||||||
|
|
||||||
|
const attrsString = imgMatch[1];
|
||||||
|
const attrs: [string, string][] = [];
|
||||||
|
let src = '';
|
||||||
|
let alt = '';
|
||||||
|
|
||||||
|
// 解析属性:匹配 name="value" 或 name='value' 或 name=value
|
||||||
|
const attrRegex =
|
||||||
|
/(\w+)(?:=["']([^"']*)["']|=(?:["'])?([^\s>]+)(?:["'])?)?/g;
|
||||||
|
let attrMatch;
|
||||||
|
while ((attrMatch = attrRegex.exec(attrsString)) !== null) {
|
||||||
|
const name = attrMatch[1].toLowerCase();
|
||||||
|
const value = attrMatch[2] || attrMatch[3] || '';
|
||||||
|
attrs.push([name, value]);
|
||||||
|
if (name === 'src') src = value;
|
||||||
|
if (name === 'alt') alt = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { src, alt, attrs };
|
||||||
|
};
|
||||||
|
|
||||||
md.renderer.rules.html_block = (
|
md.renderer.rules.html_block = (
|
||||||
tokens,
|
tokens,
|
||||||
idx,
|
idx,
|
||||||
|
|
@ -278,6 +303,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
if (content.includes('<error>')) return '<span class="chat-error">';
|
if (content.includes('<error>')) return '<span class="chat-error">';
|
||||||
if (content.includes('</error>')) return '</span>';
|
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)) {
|
if (!isAllowedTag(content)) {
|
||||||
return md.utils.escapeHtml(content);
|
return md.utils.escapeHtml(content);
|
||||||
|
|
@ -301,6 +341,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
if (content.includes('<error>')) return '<span class="chat-error">';
|
if (content.includes('<error>')) return '<span class="chat-error">';
|
||||||
if (content.includes('</error>')) return '</span>';
|
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)) {
|
if (!isAllowedTag(content)) {
|
||||||
return md.utils.escapeHtml(content);
|
return md.utils.escapeHtml(content);
|
||||||
|
|
@ -352,7 +407,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
}
|
}
|
||||||
}, [content, customizeRenderer, scrollToBottom]);
|
}, [content, customizeRenderer, scrollToBottom]);
|
||||||
|
|
||||||
// 添加代码块点击复制功能
|
// 添加代码块点击复制和图片点击预览功能(事件代理)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
@ -360,6 +415,21 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
const handleClick = (e: MouseEvent) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
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');
|
const preElement = target.closest('pre.hljs');
|
||||||
if (preElement) {
|
if (preElement) {
|
||||||
|
|
@ -368,6 +438,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
const code = codeElement.textContent || '';
|
const code = codeElement.textContent || '';
|
||||||
copyText(code.replace(/\n$/, ''));
|
copyText(code.replace(/\n$/, ''));
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否点击了行内代码
|
// 检查是否点击了行内代码
|
||||||
|
|
@ -380,7 +451,7 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
container.addEventListener('click', handleClick);
|
container.addEventListener('click', handleClick);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearImageBlobCache();
|
clearImageBlobCache(imageBlobCacheRef.current);
|
||||||
container.removeEventListener('click', handleClick);
|
container.removeEventListener('click', handleClick);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -406,6 +477,9 @@ const MarkDown2: React.FC<MarkDown2Props> = ({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
},
|
},
|
||||||
|
'.markdown-image': {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
'.image-error': {
|
'.image-error': {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { getShareV1AppWidgetInfo } from './request/ShareApp';
|
|
||||||
import { middleware as homeMiddleware } from './middleware/home';
|
|
||||||
|
|
||||||
const proxyShare = async (request: NextRequest) => {
|
|
||||||
// 转发到 process.env.TARGET
|
|
||||||
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
|
|
||||||
|
|
||||||
const targetOrigin = process.env.TARGET!;
|
|
||||||
const targetUrl = new URL(
|
|
||||||
request.nextUrl.pathname + request.nextUrl.search,
|
|
||||||
targetOrigin,
|
|
||||||
);
|
|
||||||
// 构造 fetch 选项
|
|
||||||
const fetchHeaders = new Headers(request.headers);
|
|
||||||
fetchHeaders.set('x-kb-id', kb_id);
|
|
||||||
|
|
||||||
const fetchOptions: RequestInit = {
|
|
||||||
method: request.method,
|
|
||||||
headers: fetchHeaders,
|
|
||||||
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request.body,
|
|
||||||
redirect: 'manual',
|
|
||||||
};
|
|
||||||
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
|
|
||||||
const nextRes = new NextResponse(proxyRes.body, {
|
|
||||||
status: proxyRes.status,
|
|
||||||
headers: proxyRes.headers,
|
|
||||||
statusText: proxyRes.statusText,
|
|
||||||
});
|
|
||||||
return nextRes;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
const pathname = url.pathname;
|
|
||||||
if (pathname.startsWith('/widget')) {
|
|
||||||
const widgetInfo: any = await getShareV1AppWidgetInfo();
|
|
||||||
if (widgetInfo) {
|
|
||||||
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
|
|
||||||
return NextResponse.rewrite(new URL('/not-fount', request.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
for (const [key, value] of request.headers.entries()) {
|
|
||||||
headers[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
|
|
||||||
let needSetSessionId = false;
|
|
||||||
|
|
||||||
if (!sessionId) {
|
|
||||||
sessionId = uuidv4();
|
|
||||||
needSetSessionId = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
let response: NextResponse;
|
|
||||||
|
|
||||||
if (pathname.startsWith('/share/')) {
|
|
||||||
response = await proxyShare(request);
|
|
||||||
} else {
|
|
||||||
response = await homeMiddleware(request, headers, sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needSetSessionId) {
|
|
||||||
response.cookies.set('x-pw-session-id', sessionId, {
|
|
||||||
httpOnly: true,
|
|
||||||
maxAge: 60 * 60 * 24 * 365, // 1 年
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!pathname.startsWith('/share')) {
|
|
||||||
response.headers.set('x-current-path', pathname);
|
|
||||||
response.headers.set('x-current-search', url.search);
|
|
||||||
}
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = {
|
|
||||||
matcher: [
|
|
||||||
'/',
|
|
||||||
'/home',
|
|
||||||
'/share/:path*',
|
|
||||||
'/chat/:path*',
|
|
||||||
'/widget',
|
|
||||||
'/welcome',
|
|
||||||
'/auth/login',
|
|
||||||
'/node/:path*',
|
|
||||||
'/node',
|
|
||||||
// '/client/:path*',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { parsePathname } from '@/utils';
|
|
||||||
import type { NextRequest } from 'next/server';
|
|
||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { postShareV1StatPage } from '@/request/ShareStat';
|
|
||||||
import { getShareV1NodeList } from '@/request/ShareNode';
|
|
||||||
import { getShareV1AppWebInfo } from '@/request/ShareApp';
|
|
||||||
import { filterEmptyFolders, convertToTree } from '@/utils/drag';
|
|
||||||
import { deepSearchFirstNode } from '@/utils';
|
|
||||||
|
|
||||||
const StatPage = {
|
|
||||||
welcome: 1,
|
|
||||||
node: 2,
|
|
||||||
chat: 3,
|
|
||||||
auth: 4,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const getFirstNode = async () => {
|
|
||||||
const nodeListResult: any = await getShareV1NodeList();
|
|
||||||
const tree = filterEmptyFolders(convertToTree(nodeListResult || []));
|
|
||||||
return deepSearchFirstNode(tree);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHomePath = async () => {
|
|
||||||
const info = await getShareV1AppWebInfo();
|
|
||||||
return info?.settings?.home_page_setting;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function middleware(
|
|
||||||
request: NextRequest,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
session: string,
|
|
||||||
) {
|
|
||||||
const url = request.nextUrl.clone();
|
|
||||||
const { page, id } = parsePathname(url.pathname);
|
|
||||||
try {
|
|
||||||
// 获取节点列表
|
|
||||||
if (url.pathname === '/') {
|
|
||||||
const homePath = await getHomePath();
|
|
||||||
if (homePath === 'custom') {
|
|
||||||
return NextResponse.rewrite(new URL('/home', request.url));
|
|
||||||
} else {
|
|
||||||
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
|
|
||||||
if (firstNode) {
|
|
||||||
return NextResponse.rewrite(
|
|
||||||
new URL(`/node/${firstNode.id}`, request.url),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return NextResponse.rewrite(new URL('/node', request.url));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 页面上报
|
|
||||||
const pages = Object.keys(StatPage);
|
|
||||||
if (pages.includes(page) || pages.includes(id)) {
|
|
||||||
postShareV1StatPage(
|
|
||||||
{
|
|
||||||
scene: StatPage[page as keyof typeof StatPage],
|
|
||||||
node_id: id || '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'x-pw-session-id': session,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
} catch (error) {
|
|
||||||
if (
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'message' in error &&
|
|
||||||
error.message === 'NEXT_REDIRECT'
|
|
||||||
) {
|
|
||||||
return NextResponse.redirect(
|
|
||||||
new URL(
|
|
||||||
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
|
|
||||||
request.url,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.next();
|
|
||||||
}
|
|
||||||
|
|
@ -35,6 +35,10 @@ export const ThemeStoreProvider = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Cookies.set('theme_mode', themeMode, { expires: 365 * 10 });
|
Cookies.set('theme_mode', themeMode, { expires: 365 * 10 });
|
||||||
}, [themeMode]);
|
}, [themeMode]);
|
||||||
|
|
||||||
|
console.log('themeMode-------', themeMode);
|
||||||
|
console.log('themeMode-------', theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
|
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
|
||||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,181 @@
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { getShareV1AppWidgetInfo } from './request/ShareApp';
|
||||||
|
|
||||||
|
import { parsePathname } from '@/utils';
|
||||||
|
import { postShareV1StatPage } from '@/request/ShareStat';
|
||||||
|
import { getShareV1NodeList } from '@/request/ShareNode';
|
||||||
|
import { getShareV1AppWebInfo } from '@/request/ShareApp';
|
||||||
|
import { filterEmptyFolders, convertToTree } from '@/utils/drag';
|
||||||
|
import { deepSearchFirstNode } from '@/utils';
|
||||||
|
|
||||||
|
const StatPage = {
|
||||||
|
welcome: 1,
|
||||||
|
node: 2,
|
||||||
|
chat: 3,
|
||||||
|
auth: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const getFirstNode = async () => {
|
||||||
|
const nodeListResult: any = await getShareV1NodeList();
|
||||||
|
const tree = filterEmptyFolders(convertToTree(nodeListResult || []));
|
||||||
|
return deepSearchFirstNode(tree);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHomePath = async () => {
|
||||||
|
const info = await getShareV1AppWebInfo();
|
||||||
|
return info?.settings?.home_page_setting;
|
||||||
|
};
|
||||||
|
|
||||||
|
const homeProxy = async (
|
||||||
|
request: NextRequest,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
session: string,
|
||||||
|
) => {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
const { page, id } = parsePathname(url.pathname);
|
||||||
|
try {
|
||||||
|
// 获取节点列表
|
||||||
|
if (url.pathname === '/') {
|
||||||
|
const homePath = await getHomePath();
|
||||||
|
if (homePath === 'custom') {
|
||||||
|
return NextResponse.rewrite(new URL('/home', request.url));
|
||||||
|
} else {
|
||||||
|
const [firstNode] = await Promise.all([getFirstNode(), getHomePath()]);
|
||||||
|
if (firstNode) {
|
||||||
|
return NextResponse.rewrite(
|
||||||
|
new URL(`/node/${firstNode.id}`, request.url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.rewrite(new URL('/node', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面上报
|
||||||
|
const pages = Object.keys(StatPage);
|
||||||
|
if (pages.includes(page) || pages.includes(id)) {
|
||||||
|
postShareV1StatPage(
|
||||||
|
{
|
||||||
|
scene: StatPage[page as keyof typeof StatPage],
|
||||||
|
node_id: id || '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'x-pw-session-id': session,
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'message' in error &&
|
||||||
|
error.message === 'NEXT_REDIRECT'
|
||||||
|
) {
|
||||||
|
return NextResponse.redirect(
|
||||||
|
new URL(
|
||||||
|
`/auth/login?redirect=${encodeURIComponent(url.pathname + url.search)}`,
|
||||||
|
request.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyShare = async (request: NextRequest) => {
|
||||||
|
// 转发到 process.env.TARGET
|
||||||
|
const kb_id = request.headers.get('x-kb-id') || process.env.DEV_KB_ID || '';
|
||||||
|
|
||||||
|
const targetOrigin = process.env.TARGET!;
|
||||||
|
const targetUrl = new URL(
|
||||||
|
request.nextUrl.pathname + request.nextUrl.search,
|
||||||
|
targetOrigin,
|
||||||
|
);
|
||||||
|
// 构造 fetch 选项
|
||||||
|
const fetchHeaders = new Headers(request.headers);
|
||||||
|
fetchHeaders.set('x-kb-id', kb_id);
|
||||||
|
|
||||||
|
const hasBody = !['GET', 'HEAD'].includes(request.method);
|
||||||
|
const fetchOptions: RequestInit = {
|
||||||
|
method: request.method,
|
||||||
|
headers: fetchHeaders,
|
||||||
|
body: hasBody ? request.body : undefined,
|
||||||
|
redirect: 'manual',
|
||||||
|
...(hasBody && { duplex: 'half' as const }),
|
||||||
|
};
|
||||||
|
const proxyRes = await fetch(targetUrl.toString(), fetchOptions);
|
||||||
|
const nextRes = new NextResponse(proxyRes.body, {
|
||||||
|
status: proxyRes.status,
|
||||||
|
headers: proxyRes.headers,
|
||||||
|
statusText: proxyRes.statusText,
|
||||||
|
});
|
||||||
|
return nextRes;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
const pathname = url.pathname;
|
||||||
|
if (pathname.startsWith('/widget')) {
|
||||||
|
const widgetInfo: any = await getShareV1AppWidgetInfo();
|
||||||
|
if (widgetInfo) {
|
||||||
|
if (!widgetInfo?.settings?.widget_bot_settings?.is_open) {
|
||||||
|
return NextResponse.rewrite(new URL('/not-found', request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const [key, value] of request.headers.entries()) {
|
||||||
|
headers[key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionId = request.cookies.get('x-pw-session-id')?.value || '';
|
||||||
|
let needSetSessionId = false;
|
||||||
|
|
||||||
|
if (!sessionId) {
|
||||||
|
sessionId = uuidv4();
|
||||||
|
needSetSessionId = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: NextResponse;
|
||||||
|
|
||||||
|
if (pathname.startsWith('/share/')) {
|
||||||
|
response = await proxyShare(request);
|
||||||
|
} else {
|
||||||
|
response = await homeProxy(request, headers, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needSetSessionId) {
|
||||||
|
response.cookies.set('x-pw-session-id', sessionId, {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 60 * 60 * 24 * 365, // 1 年
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!pathname.startsWith('/share')) {
|
||||||
|
response.headers.set('x-current-path', pathname);
|
||||||
|
response.headers.set('x-current-search', url.search);
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/',
|
||||||
|
'/home',
|
||||||
|
'/share/:path*',
|
||||||
|
'/chat/:path*',
|
||||||
|
'/widget',
|
||||||
|
'/welcome',
|
||||||
|
'/auth/login',
|
||||||
|
'/node/:path*',
|
||||||
|
'/node',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -18,7 +18,6 @@ import {
|
||||||
DomainOpenAICompletionsResponse,
|
DomainOpenAICompletionsResponse,
|
||||||
DomainResponse,
|
DomainResponse,
|
||||||
PostShareV1ChatMessageParams,
|
PostShareV1ChatMessageParams,
|
||||||
PostShareV1ChatWidgetParams,
|
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -92,28 +91,3 @@ export const postShareV1ChatMessage = (
|
||||||
format: "json",
|
format: "json",
|
||||||
...params,
|
...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,
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
/*
|
||||||
|
* ---------------------------------------------------------------
|
||||||
|
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
|
||||||
|
* ## ##
|
||||||
|
* ## AUTHOR: acacode ##
|
||||||
|
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
|
||||||
|
* ---------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import httpRequest, { ContentType, RequestParams } from "./httpClient";
|
||||||
|
import {
|
||||||
|
DomainChatRequest,
|
||||||
|
DomainChatSearchReq,
|
||||||
|
DomainChatSearchResp,
|
||||||
|
DomainResponse,
|
||||||
|
PostShareV1ChatWidgetParams,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description ChatWidget
|
||||||
|
*
|
||||||
|
* @tags Widget
|
||||||
|
* @name PostShareV1ChatWidget
|
||||||
|
* @summary ChatWidget
|
||||||
|
* @request POST:/share/v1/chat/widget
|
||||||
|
* @response `200` `DomainResponse` OK
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const postShareV1ChatWidget = (
|
||||||
|
query: PostShareV1ChatWidgetParams,
|
||||||
|
request: DomainChatRequest,
|
||||||
|
params: RequestParams = {},
|
||||||
|
) =>
|
||||||
|
httpRequest<DomainResponse>({
|
||||||
|
path: `/share/v1/chat/widget`,
|
||||||
|
method: "POST",
|
||||||
|
query: query,
|
||||||
|
body: request,
|
||||||
|
type: ContentType.Json,
|
||||||
|
format: "json",
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description WidgetSearch
|
||||||
|
*
|
||||||
|
* @tags Widget
|
||||||
|
* @name PostShareV1WidgetSearch
|
||||||
|
* @summary WidgetSearch
|
||||||
|
* @request POST:/share/v1/widget/search
|
||||||
|
* @response `200` `(DomainResponse & {
|
||||||
|
data?: DomainChatSearchResp,
|
||||||
|
|
||||||
|
})` OK
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const postShareV1WidgetSearch = (
|
||||||
|
request: DomainChatSearchReq,
|
||||||
|
params: RequestParams = {},
|
||||||
|
) =>
|
||||||
|
httpRequest<
|
||||||
|
DomainResponse & {
|
||||||
|
data?: DomainChatSearchResp;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
path: `/share/v1/widget/search`,
|
||||||
|
method: "POST",
|
||||||
|
body: request,
|
||||||
|
type: ContentType.Json,
|
||||||
|
format: "json",
|
||||||
|
...params,
|
||||||
|
});
|
||||||
|
|
@ -10,5 +10,6 @@ export * from './ShareNode'
|
||||||
export * from './ShareOpenapi'
|
export * from './ShareOpenapi'
|
||||||
export * from './ShareStat'
|
export * from './ShareStat'
|
||||||
export * from './Wechat'
|
export * from './Wechat'
|
||||||
|
export * from './Widget'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
|
GithubComChaitinPandaWikiProApiShareV1AuthInfoResp,
|
||||||
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
|
GithubComChaitinPandaWikiProApiShareV1AuthLDAPReq,
|
||||||
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
|
GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp,
|
||||||
|
GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp,
|
||||||
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
|
GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq,
|
||||||
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
|
GithubComChaitinPandaWikiProApiShareV1AuthOAuthResp,
|
||||||
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
|
GithubComChaitinPandaWikiProApiShareV1AuthWecomReq,
|
||||||
|
|
@ -206,6 +207,32 @@ export const postShareProV1AuthLdap = (
|
||||||
...params,
|
...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登录
|
* @description OAuth登录
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,12 @@ export enum ConstsSourceType {
|
||||||
export enum ConstsLicenseEdition {
|
export enum ConstsLicenseEdition {
|
||||||
/** 开源版 */
|
/** 开源版 */
|
||||||
LicenseEditionFree = 0,
|
LicenseEditionFree = 0,
|
||||||
/** 联创版 */
|
/** 专业版 */
|
||||||
LicenseEditionContributor = 1,
|
LicenseEditionProfession = 1,
|
||||||
/** 企业版 */
|
/** 企业版 */
|
||||||
LicenseEditionEnterprise = 2,
|
LicenseEditionEnterprise = 2,
|
||||||
|
/** 商业版 */
|
||||||
|
LicenseEditionBusiness = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ConstsContributeType {
|
export enum ConstsContributeType {
|
||||||
|
|
@ -455,6 +457,11 @@ export type GithubComChaitinPandaWikiProApiShareV1AuthLDAPResp = Record<
|
||||||
any
|
any
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type GithubComChaitinPandaWikiProApiShareV1AuthLogoutResp = Record<
|
||||||
|
string,
|
||||||
|
any
|
||||||
|
>;
|
||||||
|
|
||||||
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
|
export interface GithubComChaitinPandaWikiProApiShareV1AuthOAuthReq {
|
||||||
kb_id?: string;
|
kb_id?: string;
|
||||||
redirect_url?: string;
|
redirect_url?: string;
|
||||||
|
|
@ -669,8 +676,6 @@ export interface GetApiProV1TokenListParams {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostApiV1LicensePayload {
|
export interface PostApiV1LicensePayload {
|
||||||
/** license edition */
|
|
||||||
license_edition: "contributor" | "enterprise";
|
|
||||||
/** license type */
|
/** license type */
|
||||||
license_type: "file" | "code";
|
license_type: "file" | "code";
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -171,14 +171,21 @@ export enum ConstsNodeAccessPerm {
|
||||||
NodeAccessPermClosed = "closed",
|
NodeAccessPermClosed = "closed",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ConstsModelSettingMode {
|
||||||
|
ModelSettingModeManual = "manual",
|
||||||
|
ModelSettingModeAuto = "auto",
|
||||||
|
}
|
||||||
|
|
||||||
/** @format int32 */
|
/** @format int32 */
|
||||||
export enum ConstsLicenseEdition {
|
export enum ConstsLicenseEdition {
|
||||||
/** 开源版 */
|
/** 开源版 */
|
||||||
LicenseEditionFree = 0,
|
LicenseEditionFree = 0,
|
||||||
/** 联创版 */
|
/** 专业版 */
|
||||||
LicenseEditionContributor = 1,
|
LicenseEditionProfession = 1,
|
||||||
/** 企业版 */
|
/** 企业版 */
|
||||||
LicenseEditionEnterprise = 2,
|
LicenseEditionEnterprise = 2,
|
||||||
|
/** 商业版 */
|
||||||
|
LicenseEditionBusiness = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ConstsHomePageSetting {
|
export enum ConstsHomePageSetting {
|
||||||
|
|
@ -922,6 +929,17 @@ export interface DomainMetricsConfig {
|
||||||
type?: string;
|
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 {
|
export interface DomainMoveNodeReq {
|
||||||
id: string;
|
id: string;
|
||||||
kb_id: string;
|
kb_id: string;
|
||||||
|
|
@ -1195,6 +1213,18 @@ export interface DomainStatPageReq {
|
||||||
scene: 1 | 2 | 3 | 4;
|
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 {
|
export interface DomainTextConfig {
|
||||||
title?: string;
|
title?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
|
@ -1336,11 +1366,18 @@ export interface DomainWecomAIBotSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DomainWidgetBotSettings {
|
export interface DomainWidgetBotSettings {
|
||||||
|
btn_id?: string;
|
||||||
btn_logo?: string;
|
btn_logo?: string;
|
||||||
|
btn_position?: string;
|
||||||
|
btn_style?: string;
|
||||||
btn_text?: string;
|
btn_text?: string;
|
||||||
|
disclaimer?: string;
|
||||||
is_open?: boolean;
|
is_open?: boolean;
|
||||||
|
modal_position?: string;
|
||||||
|
placeholder?: string;
|
||||||
recommend_node_ids?: string[];
|
recommend_node_ids?: string[];
|
||||||
recommend_questions?: string[];
|
recommend_questions?: string[];
|
||||||
|
search_mode?: string;
|
||||||
theme_mode?: string;
|
theme_mode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ const DocContent = ({
|
||||||
setCommentImages([]);
|
setCommentImages([]);
|
||||||
message.success(
|
message.success(
|
||||||
appDetail?.web_app_comment_settings?.moderation_enable
|
appDetail?.web_app_comment_settings?.moderation_enable
|
||||||
? '正在审核中...'
|
? '评论已提交,请耐心等待审核'
|
||||||
: '评论成功',
|
: '评论成功',
|
||||||
);
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,142 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,241 +0,0 @@
|
||||||
'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;
|
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
'use client';
|
||||||
|
import Logo from '@/assets/images/logo.png';
|
||||||
|
import noDocImage from '@/assets/images/no-doc.png';
|
||||||
|
import { useStore } from '@/provider';
|
||||||
|
import { postShareV1WidgetSearch } 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;
|
||||||
|
}
|
||||||
|
postShareV1WidgetSearch({ 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;
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
'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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
// 常量定义
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,217 @@
|
||||||
|
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,413 +1,222 @@
|
||||||
'use client';
|
'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 { useStore } from '@/provider';
|
||||||
import SSEClient from '@/utils/fetch';
|
import {
|
||||||
import { Box, Stack, useMediaQuery } from '@mui/material';
|
alpha,
|
||||||
import { Ellipsis, message } from '@ctzhian/ui';
|
Box,
|
||||||
import dayjs from 'dayjs';
|
Button,
|
||||||
import Link from 'next/link';
|
lighten,
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
Stack,
|
||||||
import { AnswerStatus } from '../chat/constant';
|
styled,
|
||||||
import ChatInput from './ChatInput';
|
Tab,
|
||||||
import ChatWindow from './ChatWindow';
|
Tabs,
|
||||||
import WaterMarkProvider from '@/components/watermark/WaterMarkProvider';
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const Widget = () => {
|
const Widget = () => {
|
||||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'));
|
const { widget, mobile } = useStore();
|
||||||
const { widget, themeMode } = useStore();
|
|
||||||
|
|
||||||
const chatContainerRef = useRef<HTMLDivElement | null>(null);
|
const defaultSearchMode = useMemo(() => {
|
||||||
const sseClientRef = useRef<SSEClient<{
|
return widget?.settings?.widget_bot_settings?.search_mode || 'all';
|
||||||
type: string;
|
}, [widget]);
|
||||||
content: string;
|
|
||||||
chunk_result: ChunkResultItem[];
|
|
||||||
}> | null>(null);
|
|
||||||
|
|
||||||
const messageIdRef = useRef<string>('');
|
const [searchMode, setSearchMode] = useState<
|
||||||
const [conversation, setConversation] = useState<ConversationItem[]>([]);
|
WidgetInfo['settings']['widget_bot_settings']['search_mode']
|
||||||
const [loading, setLoading] = useState(false);
|
>(defaultSearchMode !== 'doc' ? 'qa' : 'doc');
|
||||||
const [thinking, setThinking] = useState<keyof typeof AnswerStatus>(4);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [nonce, setNonce] = useState('');
|
const aiQaInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [conversationId, setConversationId] = useState('');
|
|
||||||
const [answer, setAnswer] = useState('');
|
|
||||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
|
||||||
|
|
||||||
const chatAnswer = async (q: string) => {
|
const placeholder = useMemo(() => {
|
||||||
setLoading(true);
|
return widget?.settings?.widget_bot_settings?.placeholder || '搜索...';
|
||||||
setThinking(1);
|
}, [widget]);
|
||||||
setIsUserScrolling(false);
|
|
||||||
|
|
||||||
const reqData = {
|
const hotSearch = useMemo(() => {
|
||||||
message: q,
|
return widget?.settings?.widget_bot_settings?.recommend_questions || [];
|
||||||
nonce: '',
|
}, [widget]);
|
||||||
conversation_id: '',
|
|
||||||
app_type: 2,
|
|
||||||
};
|
|
||||||
if (conversationId) reqData.conversation_id = conversationId;
|
|
||||||
if (nonce) reqData.nonce = nonce;
|
|
||||||
|
|
||||||
if (sseClientRef.current) {
|
// modal打开时自动聚焦
|
||||||
sseClientRef.current.subscribe(
|
useEffect(() => {
|
||||||
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>`;
|
|
||||||
}
|
|
||||||
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(() => {
|
setTimeout(() => {
|
||||||
chatAnswer(q);
|
if (searchMode === 'qa') {
|
||||||
}, 0);
|
aiQaInputRef.current?.querySelector('textarea')?.focus();
|
||||||
};
|
} else {
|
||||||
|
inputRef.current?.querySelector('input')?.focus();
|
||||||
const handleSearchAbort = () => {
|
}
|
||||||
if (loading) {
|
}, 100);
|
||||||
sseClientRef.current?.unsubscribe();
|
}, [searchMode]);
|
||||||
}
|
|
||||||
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 (
|
return (
|
||||||
<WaterMarkProvider>
|
<Box
|
||||||
<Stack
|
sx={theme => ({
|
||||||
direction={'row'}
|
display: 'flex',
|
||||||
alignItems={'flex-start'}
|
flexDirection: 'column',
|
||||||
justifyContent={'space-between'}
|
flex: 1,
|
||||||
gap={2}
|
maxWidth: '100vw',
|
||||||
sx={{
|
height: '100vh',
|
||||||
p: 3,
|
backgroundColor: lighten(theme.palette.background.default, 0.05),
|
||||||
bgcolor: 'primary.main',
|
borderRadius: '10px',
|
||||||
pb: '36px',
|
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
|
||||||
}}
|
overflow: 'hidden',
|
||||||
>
|
outline: 'none',
|
||||||
<Box sx={{ flex: 1, width: 0, color: 'light.main' }}>
|
pb: 2,
|
||||||
<Stack
|
})}
|
||||||
direction={'row'}
|
onClick={e => e.stopPropagation()}
|
||||||
alignItems={'center'}
|
>
|
||||||
gap={1}
|
{/* 顶部标签栏 */}
|
||||||
sx={{ lineHeight: '28px', fontSize: 20 }}
|
|
||||||
>
|
|
||||||
{widget?.settings?.widget_bot_settings?.btn_logo ||
|
|
||||||
widget?.settings?.icon ? (
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
widget?.settings?.widget_bot_settings?.btn_logo ||
|
|
||||||
widget?.settings?.icon
|
|
||||||
}
|
|
||||||
height={24}
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<IconLogo sx={{ fontSize: 24 }} />
|
|
||||||
)}
|
|
||||||
<Ellipsis sx={{ pr: 2 }}>
|
|
||||||
<Box
|
|
||||||
component={'span'}
|
|
||||||
sx={{ cursor: 'pointer' }}
|
|
||||||
onClick={() => {
|
|
||||||
handleSearchAbort();
|
|
||||||
setConversation([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{widget?.settings?.title || '在线客服'}
|
|
||||||
</Box>
|
|
||||||
</Ellipsis>
|
|
||||||
</Stack>
|
|
||||||
<Ellipsis sx={{ fontSize: 14, opacity: 0.7, mt: 0.5 }}>
|
|
||||||
{widget?.settings?.welcome_str || '在线客服'}
|
|
||||||
</Ellipsis>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
bgcolor: themeMode === 'light' ? 'light.main' : 'dark.light',
|
display: 'flex',
|
||||||
p: 3,
|
alignItems: 'center',
|
||||||
mt: -2,
|
justifyContent: 'space-between',
|
||||||
borderRadius: '12px 12px 0 0',
|
px: 2,
|
||||||
height: 'calc(100vh - 96px - 24px)',
|
pt: 2,
|
||||||
overflow: 'auto',
|
pb: 2.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{conversation.length === 0 ? (
|
{defaultSearchMode === 'all' ? (
|
||||||
<>
|
<StyledTabs
|
||||||
<Box>
|
value={searchMode}
|
||||||
<ChatInput
|
onChange={(_, value) => {
|
||||||
loading={loading}
|
setSearchMode(value as 'qa' | 'doc');
|
||||||
thinking={thinking}
|
|
||||||
setThinking={setThinking}
|
|
||||||
onSearch={onSearch}
|
|
||||||
handleSearchAbort={handleSearchAbort}
|
|
||||||
placeholder={
|
|
||||||
widget?.settings?.search_placeholder || '请输入问题'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Stack
|
|
||||||
direction='row'
|
|
||||||
alignItems={'center'}
|
|
||||||
flexWrap='wrap'
|
|
||||||
gap={1.5}
|
|
||||||
sx={{
|
|
||||||
mt: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
{widget?.recommend_nodes && widget.recommend_nodes.length > 0 && (
|
|
||||||
<Box sx={{ mt: 4.5 }}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
color: 'text.tertiary',
|
|
||||||
lineHeight: '22px',
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
推荐内容
|
|
||||||
</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={{
|
|
||||||
py: 2,
|
|
||||||
pr: isMobile ? 0 : 2,
|
|
||||||
fontSize: 12,
|
|
||||||
height: 53,
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
cursor: 'pointer',
|
|
||||||
color: 'text.primary',
|
|
||||||
'&:hover': {
|
|
||||||
color: 'primary.main',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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',
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
|
variant='scrollable'
|
||||||
|
scrollButtons={false}
|
||||||
>
|
>
|
||||||
<IconLogo sx={{ fontSize: 16 }} />
|
<StyledTab
|
||||||
<Box sx={{ fontWeight: 'bold' }}>PandaWiki</Box>
|
label={
|
||||||
</Stack>
|
<Stack direction='row' gap={0.5} alignItems='center'>
|
||||||
</Link>
|
<IconZhinengwenda sx={{ fontSize: 16 }} />
|
||||||
提供技术支持
|
{!mobile && <span>智能问答</span>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</WaterMarkProvider>
|
}
|
||||||
|
value='qa'
|
||||||
|
/>
|
||||||
|
<StyledTab
|
||||||
|
label={
|
||||||
|
<Stack direction='row' gap={0.5} alignItems='center'>
|
||||||
|
<IconJinsousuo sx={{ fontSize: 16 }} />
|
||||||
|
{!mobile && <span>仅搜索文档</span>}
|
||||||
|
</Stack>
|
||||||
|
}
|
||||||
|
value='doc'
|
||||||
|
/>
|
||||||
|
</StyledTabs>
|
||||||
|
) : (
|
||||||
|
<Box></Box>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
flex: 1,
|
||||||
|
display: searchMode === 'qa' ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AiQaContent
|
||||||
|
hotSearch={hotSearch}
|
||||||
|
placeholder={placeholder}
|
||||||
|
inputRef={aiQaInputRef}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
flex: 1,
|
||||||
|
display: searchMode === 'doc' ? 'flex' : 'none',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SearchDocContent inputRef={inputRef} placeholder={placeholder} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 底部AI生成提示 */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
px: 3,
|
||||||
|
pt: widget?.settings?.widget_bot_settings?.disclaimer ? 2 : 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
variant='caption'
|
||||||
|
sx={{
|
||||||
|
color: 'text.disabled',
|
||||||
|
fontSize: 12,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box>本网站由 PandaWiki 提供技术支持</Box>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
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",
|
"license": "ISC",
|
||||||
"packageManager": "pnpm@10.12.1",
|
"packageManager": "pnpm@10.12.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ctzhian/tiptap": "^1.12.21",
|
"@ctzhian/tiptap": "^1.13.2",
|
||||||
"@ctzhian/ui": "^7.0.5",
|
"@ctzhian/ui": "^7.0.5",
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@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