Compare commits

...

3 Commits

Author SHA1 Message Date
Gavan 035ce0284d feat: 退出登录 & 对话记录 & d 渲染允许 img 标签 & 样式优化 & 升级nextjs 依赖 2025-11-14 18:30:49 +08:00
Coltea 93559125c2
Merge pull request #1519 from KuaiYu95/fe/widget
网页挂件支持三种模式
2025-11-14 18:23:04 +08:00
yu.kuai 6c5cf256ac feat: 后台网页挂件新增配置项
feat: 前台网页挂件支持三种模式:悬浮球,侧边吸附,自定义按钮
fix: markdown 编辑器新增配置项:showAutocomplete,highlightActiveLine
fix: markdown 编辑器输入中午拼音与 placeholder 重叠
fix: 使用官方修复后的 migrateMathStrings.ts
fix: 使用官方修复后的 table-of-contents
2025-11-14 17:54:37 +08:00
48 changed files with 5598 additions and 1950 deletions

View File

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

File diff suppressed because one or more lines are too long

View File

@ -262,7 +262,7 @@ const MemberAdd = ({
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: 1,
opacity: '1 !important',
color: 'text.disabled',
},
},

View File

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

View File

@ -446,15 +446,19 @@ const Content = () => {
>
{ragReStartCount}
</Box>
<Button
size='small'
sx={{ minWidth: 0, p: 0, fontSize: 12 }}
<ButtonBase
disableRipple
sx={{
fontSize: 12,
fontWeight: 400,
color: 'primary.main',
}}
onClick={() => {
setRagOpen(true);
}}
>
</Button>
</ButtonBase>
</>
)}
</Stack>

View File

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

View File

@ -215,7 +215,7 @@ const AddRole = ({ open, onCancel, onOk, selectedIds }: AddRoleProps) => {
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: 1,
opacity: '1 !important',
color: 'text.disabled',
},
},

View File

@ -873,7 +873,7 @@ const CardAuth = ({ kb, refresh }: CardAuthProps) => {
MenuProps={{
sx: {
'.Mui-disabled': {
opacity: 1,
opacity: '1 !important',
color: 'text.disabled',
},
},

View File

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

View File

@ -1365,6 +1365,13 @@ export interface DomainWidgetBotSettings {
recommend_node_ids?: string[];
recommend_questions?: 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 {
@ -1735,23 +1742,23 @@ export interface DeleteApiV1AuthDeleteParams {
export interface GetApiV1AuthGetParams {
kb_id?: string;
source_type:
| "dingtalk"
| "feishu"
| "wecom"
| "oauth"
| "github"
| "cas"
| "ldap"
| "widget"
| "dingtalk_bot"
| "feishu_bot"
| "lark_bot"
| "wechat_bot"
| "wecom_ai_bot"
| "wechat_service_bot"
| "discord_bot"
| "wechat_official_account"
| "openai_api";
| "dingtalk"
| "feishu"
| "wecom"
| "oauth"
| "github"
| "cas"
| "ldap"
| "widget"
| "dingtalk_bot"
| "feishu_bot"
| "lark_bot"
| "wechat_bot"
| "wecom_ai_bot"
| "wechat_service_bot"
| "discord_bot"
| "wechat_official_account"
| "openai_api";
}
export interface GetApiV1CommentParams {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,14 @@
'use client';
import Logo from '@/assets/images/logo.png';
import { Box } from '@mui/material';
import { Stack, Box, IconButton, alpha, Tooltip } from '@mui/material';
import { postShareProV1AuthLogout } from '@/request/pro/ShareAuth';
import { IconDengchu } from '@panda-wiki/icons';
import { useStore } from '@/provider';
import { usePathname } from 'next/navigation';
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import ErrorIcon from '@mui/icons-material/Error';
import { Modal } from '@ctzhian/ui';
import {
Header as CustomHeader,
WelcomeHeader as WelcomeHeaderComponent,
@ -16,8 +20,53 @@ interface HeaderProps {
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 { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const pathname = usePathname();
const docWidth = useMemo(() => {
if (isWelcomePage) return 'full';
@ -55,16 +104,23 @@ const Header = ({ isDocPage = false, isWelcomePage = false }: HeaderProps) => {
onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)}
>
<Box sx={{ ml: 2 }}>
<Stack sx={{ ml: 2 }} direction='row' alignItems='center' gap={1}>
<ThemeSwitch />
</Box>
{!!authInfo && <LogoutButton />}
</Stack>
<QaModal />
</CustomHeader>
);
};
export const WelcomeHeader = () => {
const { mobile = false, kbDetail, catalogWidth, setQaModalOpen } = useStore();
const {
mobile = false,
kbDetail,
catalogWidth,
setQaModalOpen,
authInfo,
} = useStore();
const handleSearch = (value?: string, type: 'chat' | 'search' = 'chat') => {
if (value?.trim()) {
if (type === 'chat') {
@ -91,6 +147,7 @@ export const WelcomeHeader = () => {
onSearch={handleSearch}
onQaClick={() => setQaModalOpen?.(true)}
>
<Box sx={{ ml: 2 }}>{!!authInfo && <LogoutButton />}</Box>
<QaModal />
</WelcomeHeaderComponent>
);

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@ -0,0 +1,75 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import httpRequest, { ContentType, RequestParams } from "./httpClient";
import {
DomainChatRequest,
DomainChatSearchReq,
DomainChatSearchResp,
DomainResponse,
PostShareV1ChatWidgetParams,
} from "./types";
/**
* @description ChatWidget
*
* @tags Widget
* @name PostShareV1ChatWidget
* @summary ChatWidget
* @request POST:/share/v1/chat/widget
* @response `200` `DomainResponse` OK
*/
export const postShareV1ChatWidget = (
query: PostShareV1ChatWidgetParams,
request: DomainChatRequest,
params: RequestParams = {},
) =>
httpRequest<DomainResponse>({
path: `/share/v1/chat/widget`,
method: "POST",
query: query,
body: request,
type: ContentType.Json,
format: "json",
...params,
});
/**
* @description WidgetSearch
*
* @tags Widget
* @name 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,
});

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@ -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,
},
}));

View File

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

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -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,
};
};

View File

@ -1,413 +1,222 @@
'use client';
import { ChunkResultItem, ConversationItem } from '@/assets/type';
import { IconFile, IconFolder, IconLogo } from '@/components/icons';
import { WidgetInfo } from '@/assets/type';
import { useStore } from '@/provider';
import SSEClient from '@/utils/fetch';
import { Box, Stack, useMediaQuery } from '@mui/material';
import { Ellipsis, message } from '@ctzhian/ui';
import dayjs from 'dayjs';
import Link from 'next/link';
import { useCallback, useEffect, useRef, useState } from 'react';
import { AnswerStatus } from '../chat/constant';
import ChatInput from './ChatInput';
import ChatWindow from './ChatWindow';
import WaterMarkProvider from '@/components/watermark/WaterMarkProvider';
import {
alpha,
Box,
Button,
lighten,
Stack,
styled,
Tab,
Tabs,
Typography,
} from '@mui/material';
import { IconJinsousuo, IconZhinengwenda } from '@panda-wiki/icons';
import { useEffect, useMemo, useRef, useState } from 'react';
import AiQaContent from './AiQaContent';
import SearchDocContent from './SearchDocContent';
const StyledTabs = styled(Tabs)(({ theme }) => ({
minHeight: 'auto',
position: 'relative',
borderRadius: '10px',
padding: theme.spacing(0.5),
border: `1px solid ${alpha(theme.palette.text.primary, 0.1)}`,
'& .MuiTabs-indicator': {
height: '100%',
borderRadius: '8px',
backgroundColor: theme.palette.primary.main,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
zIndex: 0,
},
'& .MuiTabs-flexContainer': {
gap: theme.spacing(0.5),
position: 'relative',
zIndex: 1,
},
}));
// 样式化的 Tab 组件 - 白色背景,圆角,深灰色文字
const StyledTab = styled(Tab)(({ theme }) => ({
minHeight: 'auto',
padding: theme.spacing(0.75, 2),
borderRadius: '6px',
backgroundColor: 'transparent',
fontSize: 12,
fontWeight: 400,
textTransform: 'none',
transition: 'color 0.3s ease-in-out',
position: 'relative',
zIndex: 1,
lineHeight: 1,
'&:hover': {
color: theme.palette.text.primary,
},
'&.Mui-selected': {
color: theme.palette.primary.contrastText,
fontWeight: 500,
},
}));
const Widget = () => {
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'));
const { widget, themeMode } = useStore();
const { widget, mobile } = useStore();
const chatContainerRef = useRef<HTMLDivElement | null>(null);
const sseClientRef = useRef<SSEClient<{
type: string;
content: string;
chunk_result: ChunkResultItem[];
}> | null>(null);
const defaultSearchMode = useMemo(() => {
return widget?.settings?.widget_bot_settings?.search_mode || 'all';
}, [widget]);
const messageIdRef = useRef<string>('');
const [conversation, setConversation] = useState<ConversationItem[]>([]);
const [loading, setLoading] = useState(false);
const [thinking, setThinking] = useState<keyof typeof AnswerStatus>(4);
const [nonce, setNonce] = useState('');
const [conversationId, setConversationId] = useState('');
const [answer, setAnswer] = useState('');
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [searchMode, setSearchMode] = useState<
WidgetInfo['settings']['widget_bot_settings']['search_mode']
>(defaultSearchMode !== 'doc' ? 'qa' : 'doc');
const inputRef = useRef<HTMLInputElement>(null);
const aiQaInputRef = useRef<HTMLInputElement>(null);
const chatAnswer = async (q: string) => {
setLoading(true);
setThinking(1);
setIsUserScrolling(false);
const placeholder = useMemo(() => {
return widget?.settings?.widget_bot_settings?.placeholder || '搜索...';
}, [widget]);
const reqData = {
message: q,
nonce: '',
conversation_id: '',
app_type: 2,
};
if (conversationId) reqData.conversation_id = conversationId;
if (nonce) reqData.nonce = nonce;
const hotSearch = useMemo(() => {
return widget?.settings?.widget_bot_settings?.recommend_questions || [];
}, [widget]);
if (sseClientRef.current) {
sseClientRef.current.subscribe(
JSON.stringify(reqData),
({ type, content }) => {
if (type === 'conversation_id') {
setConversationId(prev => prev + content);
} else if (type === 'message_id') {
messageIdRef.current += content;
} else if (type === 'nonce') {
setNonce(prev => prev + content);
} else if (type === 'error') {
setLoading(false);
setThinking(4);
setAnswer(prev => {
if (content) {
return prev + `\n\n回答出现错误<error>${content}</error>`;
}
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('');
// modal打开时自动聚焦
useEffect(() => {
setTimeout(() => {
chatAnswer(q);
}, 0);
};
const handleSearchAbort = () => {
if (loading) {
sseClientRef.current?.unsubscribe();
}
setLoading(false);
setThinking(4);
};
const handleScroll = useCallback(() => {
if (chatContainerRef?.current) {
const { scrollTop, scrollHeight, clientHeight } =
chatContainerRef.current;
setIsUserScrolling(scrollTop + clientHeight < scrollHeight);
}
}, [chatContainerRef]);
useEffect(() => {
if (!isUserScrolling && chatContainerRef?.current) {
chatContainerRef.current.scrollTop =
chatContainerRef.current.scrollHeight;
}
}, [answer, isUserScrolling]);
useEffect(() => {
const chatContainer = chatContainerRef?.current;
chatContainer?.addEventListener('scroll', handleScroll);
return () => {
chatContainer?.removeEventListener('scroll', handleScroll);
};
}, [handleScroll]);
useEffect(() => {
sseClientRef.current = new SSEClient({
url: `/share/v1/chat/widget`,
headers: {
'Content-Type': 'application/json',
},
onCancel: () => {
setLoading(false);
setThinking(4);
setAnswer(prev => {
let value = '';
if (prev) {
value = prev + '\n\n<error>Request canceled</error>';
}
setConversation(prev => {
const newConversation = [...prev];
if (newConversation.length > 0) {
newConversation[newConversation.length - 1].a = value;
newConversation[newConversation.length - 1].update_time =
dayjs().format('YYYY-MM-DD HH:mm:ss');
newConversation[newConversation.length - 1].message_id =
messageIdRef.current;
}
return newConversation;
});
return '';
});
},
});
}, []);
if (searchMode === 'qa') {
aiQaInputRef.current?.querySelector('textarea')?.focus();
} else {
inputRef.current?.querySelector('input')?.focus();
}
}, 100);
}, [searchMode]);
return (
<WaterMarkProvider>
<Stack
direction={'row'}
alignItems={'flex-start'}
justifyContent={'space-between'}
gap={2}
sx={{
p: 3,
bgcolor: 'primary.main',
pb: '36px',
}}
>
<Box sx={{ flex: 1, width: 0, color: 'light.main' }}>
<Stack
direction={'row'}
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
sx={theme => ({
display: 'flex',
flexDirection: 'column',
flex: 1,
maxWidth: '100vw',
height: '100vh',
backgroundColor: lighten(theme.palette.background.default, 0.05),
borderRadius: '10px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.12)',
overflow: 'hidden',
outline: 'none',
pb: 2,
})}
onClick={e => e.stopPropagation()}
>
{/* 顶部标签栏 */}
<Box
sx={{
bgcolor: themeMode === 'light' ? 'light.main' : 'dark.light',
p: 3,
mt: -2,
borderRadius: '12px 12px 0 0',
height: 'calc(100vh - 96px - 24px)',
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2,
pt: 2,
pb: 2.5,
}}
>
{conversation.length === 0 ? (
<>
<Box>
<ChatInput
loading={loading}
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',
},
{defaultSearchMode === 'all' ? (
<StyledTabs
value={searchMode}
onChange={(_, value) => {
setSearchMode(value as 'qa' | 'doc');
}}
variant='scrollable'
scrollButtons={false}
>
<IconLogo sx={{ fontSize: 16 }} />
<Box sx={{ fontWeight: 'bold' }}>PandaWiki</Box>
</Stack>
</Link>
</Stack>
</WaterMarkProvider>
<StyledTab
label={
<Stack direction='row' gap={0.5} alignItems='center'>
<IconZhinengwenda sx={{ fontSize: 16 }} />
{!mobile && <span></span>}
</Stack>
}
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>
);
};

View File

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

View File

@ -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,
};
};

View File

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

File diff suppressed because it is too large Load Diff