1
This commit is contained in:
188
src/components/bs-comp/cardComponent/index.tsx
Normal file
188
src/components/bs-comp/cardComponent/index.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { AssistantIcon } from "@/components/bs-icons/assistant";
|
||||
import { cname } from "@/components/bs-ui/utils";
|
||||
import { useState } from "react";
|
||||
import { AddToIcon } from "../../bs-icons/addTo";
|
||||
import { DelIcon } from "../../bs-icons/del";
|
||||
import { GoIcon } from "../../bs-icons/go";
|
||||
import { PlusIcon } from "../../bs-icons/plus";
|
||||
import { SettingIcon } from "../../bs-icons/setting";
|
||||
import { SkillIcon } from "../../bs-icons/skill";
|
||||
import { UserIcon } from "../../bs-icons/user";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "../../bs-ui/card";
|
||||
import { Switch } from "../../bs-ui/switch";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface IProps<T> {
|
||||
data: T,
|
||||
/** id为''时,表示新建 */
|
||||
id?: number | string,
|
||||
type: "skill" | "sheet" | "assist" | "setting", // 技能列表|侧边弹窗列表
|
||||
title: string,
|
||||
edit?: boolean,
|
||||
description: React.ReactNode | string,
|
||||
checked?: boolean,
|
||||
user?: string,
|
||||
isAdmin?: boolean,
|
||||
headSelecter?: React.ReactNode,
|
||||
footer?: React.ReactNode,
|
||||
icon?: any,
|
||||
onClick?: () => void,
|
||||
onSwitchClick?: () => void,
|
||||
onAddTemp?: (data: T) => void,
|
||||
onCheckedChange?: (b: boolean, data: T) => Promise<any>
|
||||
onDelete?: (data: T) => void,
|
||||
onSetting?: (data: T) => void,
|
||||
}
|
||||
|
||||
export const gradients = [
|
||||
'bg-amber-500',
|
||||
'bg-orange-600',
|
||||
'bg-teal-500',
|
||||
'bg-purple-600',
|
||||
'bg-blue-700'
|
||||
]
|
||||
|
||||
// 'bg-slate-600',
|
||||
// 'bg-amber-500',
|
||||
// 'bg-red-600',
|
||||
// 'bg-orange-600',
|
||||
// 'bg-teal-500',
|
||||
// 'bg-purple-600',
|
||||
// 'bg-blue-700',
|
||||
// 'bg-yellow-600',
|
||||
// 'bg-emerald-600',
|
||||
// 'bg-green-700',
|
||||
// 'bg-cyan-600',
|
||||
// 'bg-sky-600',
|
||||
// 'bg-indigo-600',
|
||||
// 'bg-violet-600',
|
||||
// 'bg-purple-600',
|
||||
// 'bg-fuchsia-700',
|
||||
// 'bg-pink-600',
|
||||
// 'bg-rose-600'
|
||||
export function TitleIconBg({ id, className = '', children = <SkillIcon /> }) {
|
||||
return <div className={cname(`rounded-sm flex justify-center items-center ${gradients[parseInt(id + '', 16) % gradients.length]}`, className)}>{children}</div>
|
||||
}
|
||||
|
||||
export default function CardComponent<T>({
|
||||
id = '',
|
||||
data,
|
||||
type,
|
||||
icon: Icon = SkillIcon,
|
||||
edit = false,
|
||||
user,
|
||||
title,
|
||||
checked,
|
||||
isAdmin,
|
||||
description,
|
||||
footer = null,
|
||||
headSelecter = null,
|
||||
onClick,
|
||||
onSwitchClick,
|
||||
onDelete,
|
||||
onAddTemp,
|
||||
onCheckedChange,
|
||||
onSetting
|
||||
}: IProps<T>) {
|
||||
|
||||
const [_checked, setChecked] = useState(checked)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCheckedChange = async (bln) => {
|
||||
const res = await onCheckedChange(bln, data)
|
||||
if (res === false) return
|
||||
setChecked(bln)
|
||||
}
|
||||
|
||||
// 新建小卡片(sheet)
|
||||
if (!id && type === 'sheet') return <Card className="group w-[320px] cursor-pointer border-dashed border-[#BEC6D6] transition hover:border-primary hover:shadow-none bg-background-new" onClick={onClick}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between pb-2"><PlusIcon className="group-hover:text-primary transition-none" /></div>
|
||||
<CardTitle className="">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-0 overflow-auto scrollbar-hide p-2">
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end h-10">
|
||||
<div className="rounded cursor-pointer"><GoIcon className="group-hover:text-primary transition-none" /></div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
|
||||
// 新建卡片
|
||||
if (!id) return <Card className="group w-[320px] cursor-pointer border-dashed border-[#BEC6D6] transition hover:border-primary hover:shadow-none bg-background-new" onClick={onClick}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between pb-2"><PlusIcon className="group-hover:text-primary transition-none" /></div>
|
||||
<CardTitle className="">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[140px] overflow-auto scrollbar-hide">
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-end h-10">
|
||||
<div className="rounded cursor-pointer"><GoIcon className="group-hover:text-primary transition-none" /></div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
|
||||
// 侧边弹窗列表(sheet)
|
||||
if (type === 'sheet') return <Card className="group w-[320px] cursor-pointer bg-[#F7F9FC] hover:bg-[#EDEFF6] hover:shadow-none relative" onClick={onClick}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="truncate-doubleline">
|
||||
<div className="flex gap-2 pb-2 items-center">
|
||||
<TitleIconBg id={id}>
|
||||
<Icon />
|
||||
</TitleIconBg>
|
||||
<p className=" align-middle">{title}</p>
|
||||
</div>
|
||||
{/* <span></span> */}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-fit max-h-[60px] overflow-auto scrollbar-hide mb-2">
|
||||
<CardDescription className="break-all">{description}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className=" block">
|
||||
{footer}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
|
||||
// 技能组件
|
||||
return <Card className="group w-[320px] cursor-pointer bg-background-Assistant hover:bg-background-hoverAssistant" onClick={() => edit && onClick()}>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between pb-2">
|
||||
<TitleIconBg id={id} >
|
||||
{type === 'skill' ? <SkillIcon /> : <AssistantIcon />}
|
||||
</TitleIconBg>
|
||||
<div className="flex gap-1 items-center">
|
||||
{headSelecter}
|
||||
<Switch
|
||||
checked={_checked}
|
||||
className="w-12"
|
||||
texts={[t('skills.online'), t('skills.offline')]}
|
||||
onCheckedChange={(b) => edit && handleCheckedChange(b)}
|
||||
onClick={e => { e.stopPropagation(); onSwitchClick?.() }}
|
||||
></Switch>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="truncate-doubleline leading-5">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[140px] overflow-auto scrollbar-hide">
|
||||
<CardDescription className="break-all">{description}</CardDescription>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between h-10">
|
||||
<div className="flex gap-1 items-center">
|
||||
<UserIcon />
|
||||
<span className="text-sm text-muted-foreground">{t('skills.createdBy')}</span>
|
||||
<span className="text-sm font-medium leading-none overflow-hidden text-ellipsis max-w-32 ">{user}</span>
|
||||
</div>
|
||||
{edit
|
||||
&& <div className="hidden group-hover:flex">
|
||||
{!checked && <div className="hover:bg-[#EAEDF3] rounded cursor-pointer" onClick={(e) => { e.stopPropagation(); onSetting(data) }}><SettingIcon /></div>}
|
||||
{isAdmin && type === 'skill' && <div className="hover:bg-[#EAEDF3] rounded cursor-pointer" onClick={(e) => { e.stopPropagation(); onAddTemp(data) }}><AddToIcon /></div>}
|
||||
<div className="hover:bg-[#EAEDF3] rounded cursor-pointer" onClick={(e) => { e.stopPropagation(); onDelete(data) }}><DelIcon /></div>
|
||||
</div>
|
||||
}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
};
|
||||
296
src/components/bs-comp/chatComponent/ChatInput.tsx
Normal file
296
src/components/bs-comp/chatComponent/ChatInput.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { FormIcon } from "@/components/bs-icons/form";
|
||||
import { SendIcon } from "@/components/bs-icons/send";
|
||||
import { Textarea } from "@/components/bs-ui/input";
|
||||
import { useToast } from "@/components/bs-ui/toast/use-toast";
|
||||
import { locationContext } from "@/contexts/locationContext";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMessageStore } from "./messageStore";
|
||||
import GuideQuestions from "./GuideQuestions";
|
||||
import { ClearIcon } from "@/components/bs-icons/clear";
|
||||
|
||||
export default function ChatInput({ clear, form, questions, inputForm, wsUrl, onBeforSend }) {
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { appConfig } = useContext(locationContext)
|
||||
|
||||
const [formShow, setFormShow] = useState(false)
|
||||
const [showWhenLocked, setShowWhenLocked] = useState(false) // 强制开启表单按钮,不限制于input锁定
|
||||
const [inputLock, setInputLock] = useState({ locked: false, reason: '' })
|
||||
|
||||
const { messages, chatId, createSendMsg, createWsMsg, updateCurrentMessage, destory, setShowGuideQuestion } = useMessageStore()
|
||||
const currentChatIdRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
|
||||
/**
|
||||
* 记录会话切换状态,等待消息加载完成时,控制表单在新会话自动展开
|
||||
*/
|
||||
const changeChatedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
// console.log('message msg', messages, form);
|
||||
|
||||
if (changeChatedRef.current) {
|
||||
changeChatedRef.current = false
|
||||
// 新建的 form 技能,弹出窗口并锁定 input
|
||||
if (form && messages.length === 0) {
|
||||
setInputLock({ locked: true, reason: '' })
|
||||
setFormShow(true)
|
||||
setShowWhenLocked(true)
|
||||
}
|
||||
}
|
||||
|
||||
}, [messages])
|
||||
useEffect(() => {
|
||||
if (!chatId) return
|
||||
setInputLock({ locked: false, reason: '' })
|
||||
// console.log('message chatid', messages, form, chatId);
|
||||
setShowWhenLocked(false)
|
||||
|
||||
currentChatIdRef.current = chatId
|
||||
changeChatedRef.current = true
|
||||
setFormShow(false)
|
||||
createWebSocket(chatId).then(() => {
|
||||
// 切换会话默认发送一条空消息
|
||||
const [wsMsg] = onBeforSend('', '')
|
||||
sendWsMsg(wsMsg)
|
||||
})
|
||||
}, [chatId])
|
||||
|
||||
// 销毁
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
destory()
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleSendClick = async () => {
|
||||
// 解除锁定状态下 form 按钮开放的状态
|
||||
setShowWhenLocked(false)
|
||||
// 关闭引导词
|
||||
setShowGuideQuestion(false)
|
||||
// 收起表单
|
||||
// formShow && setFormShow(false)
|
||||
setFormShow(false)
|
||||
|
||||
const value = inputRef.current.value
|
||||
if (value.trim() === '') return
|
||||
|
||||
const event = new Event('input', { bubbles: true, cancelable: true });
|
||||
inputRef.current.value = ''
|
||||
inputRef.current.dispatchEvent(event); // 触发调节input高度
|
||||
const [wsMsg, inputKey] = onBeforSend('', value)
|
||||
// msg to store
|
||||
createSendMsg(wsMsg.inputs, inputKey)
|
||||
// 锁定 input
|
||||
setInputLock({ locked: true, reason: '' })
|
||||
await createWebSocket(chatId)
|
||||
sendWsMsg(wsMsg)
|
||||
|
||||
// 滚动聊天到底
|
||||
const messageDom = document.getElementById('message-panne')
|
||||
if (messageDom) {
|
||||
messageDom.scrollTop = messageDom.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const sendWsMsg = async (msg) => {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify(msg))
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'There was an error sending the message',
|
||||
variant: 'error',
|
||||
description: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const wsRef = useRef(null)
|
||||
const createWebSocket = (chatId) => {
|
||||
// 单例
|
||||
if (wsRef.current) return Promise.resolve('ok');
|
||||
const isSecureProtocol = window.location.protocol === "https:";
|
||||
const webSocketProtocol = isSecureProtocol ? "wss" : "ws";
|
||||
|
||||
return new Promise((res, rej) => {
|
||||
try {
|
||||
const ws = new WebSocket(`${webSocketProtocol}://${wsUrl}&chat_id=${chatId}`)
|
||||
wsRef.current = ws
|
||||
// websocket linsen
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket connection established!");
|
||||
res('ok')
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
const errorMsg = data.category === 'error' ? data.intermediate_steps : ''
|
||||
// 异常类型处理,提示
|
||||
if (errorMsg) return setInputLock({ locked: true, reason: errorMsg })
|
||||
// 拦截会话串台情况
|
||||
if (currentChatIdRef.current && currentChatIdRef.current !== data.chat_id) return
|
||||
handleWsMessage(data)
|
||||
// 群聊@自己时,开启input
|
||||
if (['end', 'end_cover'].includes(data.type) && data.receiver?.is_self) {
|
||||
setInputLock({ locked: true, reason: '' })
|
||||
}
|
||||
}
|
||||
ws.onclose = (event) => {
|
||||
wsRef.current = null
|
||||
console.error('链接手动断开 event :>> ', event);
|
||||
if ([1005, 1008].includes(event.code)) {
|
||||
console.warn('即将废弃 :>> ');
|
||||
setInputLock({ locked: true, reason: event.reason })
|
||||
} else {
|
||||
if (event.reason) {
|
||||
toast({
|
||||
title: t('prompt'),
|
||||
variant: 'error',
|
||||
description: event.reason
|
||||
});
|
||||
}
|
||||
setInputLock({ locked: false, reason: '' })
|
||||
}
|
||||
};
|
||||
ws.onerror = (ev) => {
|
||||
wsRef.current = null
|
||||
console.error('链接异常error', ev);
|
||||
toast({
|
||||
title: `${t('chat.networkError')}:`,
|
||||
variant: 'error',
|
||||
description: [
|
||||
t('chat.networkErrorList1'),
|
||||
t('chat.networkErrorList2'),
|
||||
t('chat.networkErrorList3')
|
||||
]
|
||||
});
|
||||
// reConnect(params)
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('创建链接异常', err);
|
||||
rej(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 接受 ws 消息
|
||||
const handleWsMessage = (data) => {
|
||||
if (Array.isArray(data) && data.length) return
|
||||
if (data.type === 'start') {
|
||||
createWsMsg(data)
|
||||
} else if (data.type === 'stream') {
|
||||
updateCurrentMessage({
|
||||
chat_id: data.chat_id,
|
||||
message: data.message,
|
||||
thought: data.intermediate_steps
|
||||
})
|
||||
} else if (['end', 'end_cover'].includes(data.type)) {
|
||||
updateCurrentMessage({
|
||||
...data,
|
||||
end: true,
|
||||
thought: data.intermediate_steps || '',
|
||||
messageId: data.message_id,
|
||||
noAccess: false,
|
||||
liked: 0
|
||||
}, data.type === 'end_cover')
|
||||
} else if (data.type === "close") {
|
||||
setInputLock({ locked: false, reason: '' })
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 监听重发消息事件
|
||||
useEffect(() => {
|
||||
const handleCustomEvent = (e) => {
|
||||
if (!showWhenLocked && inputLock.locked) return console.error('弹窗已锁定,消息无法发送')
|
||||
const { send, message } = e.detail
|
||||
inputRef.current.value = message
|
||||
if (send) handleSendClick()
|
||||
}
|
||||
document.addEventListener('userResendMsgEvent', handleCustomEvent)
|
||||
return () => {
|
||||
document.removeEventListener('userResendMsgEvent', handleCustomEvent)
|
||||
}
|
||||
}, [inputLock.locked, showWhenLocked])
|
||||
|
||||
// 点击引导词
|
||||
const handleClickGuideWord = (message) => {
|
||||
if (!showWhenLocked && inputLock.locked) return console.error('弹窗已锁定,消息无法发送')
|
||||
inputRef.current.value = message
|
||||
handleSendClick()
|
||||
}
|
||||
|
||||
// auto input height
|
||||
const handleTextAreaHeight = (e) => {
|
||||
const textarea = e.target
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
// setInputEmpty(textarea.value.trim() === '')
|
||||
}
|
||||
|
||||
return <div className="absolute bottom-0 w-full pt-1 bg-[#fff] dark:bg-[#1B1B1B]">
|
||||
<div className={`relative ${clear && 'pl-9'}`}>
|
||||
{/* form */}
|
||||
{
|
||||
formShow && <div className="relative">
|
||||
<div className="absolute left-0 border bottom-2 bg-[#fff] px-4 py-2 rounded-md w-[50%] min-w-80">
|
||||
{inputForm}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
{/* 引导问题 */}
|
||||
<GuideQuestions
|
||||
locked={inputLock.locked}
|
||||
chatId={chatId}
|
||||
questions={questions}
|
||||
onClick={handleClickGuideWord}
|
||||
/>
|
||||
{/* clear */}
|
||||
<div className="flex absolute left-0 top-4 z-10">
|
||||
{
|
||||
clear && <div
|
||||
className={`w-6 h-6 rounded-sm hover:bg-gray-200 cursor-pointer flex justify-center items-center `}
|
||||
onClick={() => { !inputLock.locked && destory() }}
|
||||
><ClearIcon className={!showWhenLocked && inputLock.locked ? 'text-gray-400' : 'text-gray-950'} ></ClearIcon></div>
|
||||
}
|
||||
</div>
|
||||
{/* form */}
|
||||
<div className="flex absolute left-3 top-4 z-10">
|
||||
{
|
||||
form && <div
|
||||
className={`w-6 h-6 rounded-sm hover:bg-gray-200 cursor-pointer flex justify-center items-center `}
|
||||
onClick={() => (showWhenLocked || !inputLock.locked) && setFormShow(!formShow)}
|
||||
><FormIcon className={!showWhenLocked && inputLock.locked ? 'text-gray-400' : 'text-gray-950'}></FormIcon></div>
|
||||
}
|
||||
</div>
|
||||
{/* send */}
|
||||
<div className="flex gap-2 absolute right-3 top-4 z-10">
|
||||
<div
|
||||
id="bs-send-btn"
|
||||
className="w-6 h-6 rounded-sm hover:bg-gray-200 cursor-pointer flex justify-center items-center"
|
||||
onClick={() => { !inputLock.locked && handleSendClick() }}
|
||||
><SendIcon className={inputLock.locked ? 'text-gray-400' : 'text-gray-950'}></SendIcon></div>
|
||||
</div>
|
||||
{/* question */}
|
||||
<Textarea
|
||||
id="bs-send-input"
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
style={{ height: 56 }}
|
||||
disabled={inputLock.locked}
|
||||
onInput={handleTextAreaHeight}
|
||||
placeholder={inputLock.locked ? inputLock.reason : t('chat.inputPlaceholder')}
|
||||
className={"resize-none py-4 pr-10 text-md min-h-6 max-h-[200px] scrollbar-hide dark:bg-[#2A2B2E] text-gray-800" + (form && ' pl-10')}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
!inputLock.locked && handleSendClick()
|
||||
}
|
||||
}}
|
||||
></Textarea>
|
||||
</div>
|
||||
<p className="text-center text-sm pt-2 pb-4 text-gray-400">{appConfig.dialogTips}</p>
|
||||
</div>
|
||||
};
|
||||
50
src/components/bs-comp/chatComponent/FileBs.tsx
Normal file
50
src/components/bs-comp/chatComponent/FileBs.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { AvatarIcon } from "@/components/bs-icons/avatar";
|
||||
import { WordIcon } from "@/components/bs-icons/office";
|
||||
import { checkSassUrl } from "@/pages/ChatAppPage/components/FileView";
|
||||
import { downloadFile } from "@/util/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// 颜色列表
|
||||
const colorList = [
|
||||
"#666",
|
||||
"#FF5733",
|
||||
"#3498DB",
|
||||
"#27AE60",
|
||||
"#E74C3C",
|
||||
"#9B59B6",
|
||||
"#F1C40F",
|
||||
"#34495E",
|
||||
"#16A085",
|
||||
"#E67E22",
|
||||
"#95A5A6"
|
||||
]
|
||||
|
||||
export default function FileBs({ data }) {
|
||||
const { t } = useTranslation()
|
||||
const avatarColor = colorList[(data.sender?.split('').reduce((num, s) => num + s.charCodeAt(), 0) || 0) % colorList.length]
|
||||
|
||||
// download file
|
||||
const handleDownloadFile = (file) => {
|
||||
const url = file?.file_url
|
||||
url && downloadFile(checkSassUrl(url), file?.file_name)
|
||||
}
|
||||
|
||||
return <div className="flex w-full py-1">
|
||||
<div className="w-fit min-h-8 rounded-2xl px-6 py-4 max-w-[90%]">
|
||||
{data.sender && <p className="text-primary text-xs mb-2" style={{ background: avatarColor }}>{data.sender}</p>}
|
||||
<div className="flex gap-2 ">
|
||||
<div className="w-6 h-6 min-w-6 flex justify-center items-center rounded-full" style={{ background: avatarColor }} ><AvatarIcon /></div>
|
||||
<div
|
||||
className="flex gap-2 w-52 border border-gray-200 shadow-sm bg-gray-50 px-4 py-2 rounded-sm cursor-pointer"
|
||||
onClick={() => handleDownloadFile(data.files[0])}
|
||||
>
|
||||
<div className="flex items-center"><WordIcon /></div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold">{data.files[0]?.file_name}</h1>
|
||||
<p className="text-xs text-gray-400 mt-1">点击下载</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
49
src/components/bs-comp/chatComponent/GuideQuestions.tsx
Normal file
49
src/components/bs-comp/chatComponent/GuideQuestions.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { useMessageStore } from "./messageStore"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
// 引导词推荐
|
||||
export default function GuideQuestions({ locked, chatId, questions, onClick }) {
|
||||
const [showGuideQuestion, setShowGuideQuestion] = useMessageStore(state => [state.showGuideQuestion, state.setShowGuideQuestion])
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
useEffect(() => {
|
||||
questions.length && setShowGuideQuestion(true)
|
||||
}, [chatId])
|
||||
|
||||
const words = useMemo(() => {
|
||||
if (questions.length < 4) return questions
|
||||
// 随机按序取三个
|
||||
const res = []
|
||||
const randomIndex = Math.floor(Math.random() * questions.length)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const item = questions[(randomIndex + i) % (questions.length - 1)]
|
||||
res.push(item)
|
||||
}
|
||||
return res
|
||||
}, [questions])
|
||||
|
||||
if (locked || !words.length) return null
|
||||
|
||||
if (showGuideQuestion) return <div className="relative">
|
||||
<div className="absolute left-0 bottom-0">
|
||||
<p className="text-gray-950 text-sm mb-2 bg-[rgba(255,255,255,0.8)] rounded-md w-fit px-2 py-1">{t('chat.recommendationQuestions')}</p>
|
||||
{
|
||||
words.map((question, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-fit bg-[#d4dffa] border-2 border-gray-50 shadow-md text-gray-600 rounded-md mb-1 px-4 py-1 text-sm cursor-pointer"
|
||||
onClick={() => {
|
||||
setShowGuideQuestion(false)
|
||||
onClick(question)
|
||||
}}
|
||||
>{question}</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
return null
|
||||
};
|
||||
121
src/components/bs-comp/chatComponent/MessageBs.tsx
Normal file
121
src/components/bs-comp/chatComponent/MessageBs.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { AvatarIcon } from "@/components/bs-icons/avatar";
|
||||
import { LoadIcon } from "@/components/bs-icons/loading";
|
||||
import { CodeBlock } from "@/modals/formModal/chatMessage/codeBlock";
|
||||
import { ChatMessageType } from "@/types/chat";
|
||||
import { copyText } from "@/utils";
|
||||
import { useMemo, useRef } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import rehypeMathjax from "rehype-mathjax";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import MessageButtons from "./MessageButtons";
|
||||
import SourceEntry from "./SourceEntry";
|
||||
import { useMessageStore } from "./messageStore";
|
||||
|
||||
// 颜色列表
|
||||
const colorList = [
|
||||
"#111",
|
||||
"#FF5733",
|
||||
"#3498DB",
|
||||
"#27AE60",
|
||||
"#E74C3C",
|
||||
"#9B59B6",
|
||||
"#F1C40F",
|
||||
"#34495E",
|
||||
"#16A085",
|
||||
"#E67E22",
|
||||
"#95A5A6"
|
||||
]
|
||||
|
||||
export default function MessageBs({ data, onUnlike = () => { }, onSource }: { data: ChatMessageType, onUnlike?: any, onSource?: any }) {
|
||||
const avatarColor = colorList[
|
||||
(data.sender?.split('').reduce((num, s) => num + s.charCodeAt(), 0) || 0) % colorList.length
|
||||
]
|
||||
|
||||
const mkdown = useMemo(
|
||||
() => (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeMathjax]}
|
||||
linkTarget="_blank"
|
||||
className="bs-mkdown inline-block break-all max-w-full text-sm text-text-answer "
|
||||
components={{
|
||||
code: ({ node, inline, className, children, ...props }) => {
|
||||
if (children.length) {
|
||||
if (children[0] === "▍") {
|
||||
return (<span className="form-modal-markdown-span"> ▍ </span>);
|
||||
}
|
||||
|
||||
children[0] = (children[0] as string).replace("`▍`", "▍");
|
||||
}
|
||||
|
||||
const match = /language-(\w+)/.exec(className || "");
|
||||
|
||||
return !inline ? (
|
||||
<CodeBlock
|
||||
key={Math.random()}
|
||||
language={(match && match[1]) || ""}
|
||||
value={String(children).replace(/\n$/, "")}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<code className={className} {...props}> {children} </code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{data.message.toString()}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[data.message, data.message.toString()]
|
||||
)
|
||||
|
||||
const messageRef = useRef<HTMLDivElement>(null)
|
||||
const handleCopyMessage = () => {
|
||||
copyText(messageRef.current)
|
||||
}
|
||||
|
||||
const chatId = useMessageStore(state => state.chatId)
|
||||
|
||||
return <div className="flex w-full py-1">
|
||||
<div className="w-fit max-w-[90%]">
|
||||
{data.sender && <p className="text-gray-600 text-xs mb-2">{data.sender}</p>}
|
||||
<div className="min-h-8 px-6 py-4 rounded-2xl bg-[#F5F6F8] dark:bg-[#313336]">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-6 h-6 min-w-6 flex justify-center items-center rounded-full" style={{ background: avatarColor }} ><AvatarIcon /></div>
|
||||
{data.message.toString() ?
|
||||
<div ref={messageRef} className="text-sm max-w-[calc(100%-24px)]">
|
||||
{mkdown}
|
||||
{/* @user */}
|
||||
{data.receiver && <p className="text-blue-500 text-sm">@ {data.receiver.user_name}</p>}
|
||||
{/* 光标 */}
|
||||
{/* {data.message.toString() && !data.end && <div className="animate-cursor absolute w-2 h-5 ml-1 bg-gray-600" style={{ left: cursor.x, top: cursor.y }}></div>} */}
|
||||
</div>
|
||||
: <div><LoadIcon className="text-gray-400" /></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{/* 附加信息 */}
|
||||
{
|
||||
!!data.id && data.end && <div className="flex justify-between mt-2">
|
||||
<SourceEntry
|
||||
extra={data.extra}
|
||||
end={data.end}
|
||||
source={data.source}
|
||||
className="pl-4"
|
||||
onSource={() => onSource?.({
|
||||
chatId,
|
||||
messageId: data.id,
|
||||
message: data.message || data.thought,
|
||||
})} />
|
||||
<MessageButtons
|
||||
id={data.id}
|
||||
data={data.liked}
|
||||
onUnlike={onUnlike}
|
||||
onCopy={handleCopyMessage}
|
||||
></MessageButtons>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
66
src/components/bs-comp/chatComponent/MessageButtons.tsx
Normal file
66
src/components/bs-comp/chatComponent/MessageButtons.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ThunmbIcon } from "@/components/bs-icons/thumbs";
|
||||
import { likeChatApi } from "@/controllers/API";
|
||||
import { useState } from "react";
|
||||
|
||||
const enum ThumbsState {
|
||||
Default = 0,
|
||||
ThumbsUp,
|
||||
ThumbsDown
|
||||
}
|
||||
|
||||
export default function MessageButtons({ id, onCopy, data, onUnlike }) {
|
||||
|
||||
const [state, setState] = useState<ThumbsState>(data)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleClick = (type: ThumbsState) => {
|
||||
setState(_type => {
|
||||
const newType = type === _type ? ThumbsState.Default : type
|
||||
// api
|
||||
likeChatApi(id, newType);
|
||||
return newType
|
||||
})
|
||||
if (state !== ThumbsState.ThumbsDown && type === ThumbsState.ThumbsDown) onUnlike?.(id)
|
||||
}
|
||||
|
||||
const handleCopy = (e) => {
|
||||
setCopied(true)
|
||||
onCopy()
|
||||
setTimeout(() => {
|
||||
setCopied(false)
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
return <div className="flex gap-1">
|
||||
<ThunmbIcon
|
||||
type='copy'
|
||||
className={`cursor-pointer dark:hidden hover:text-gray-500 ${copied && 'text-primary hover:text-primary'}`}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<ThunmbIcon
|
||||
type='copyDark'
|
||||
className={`cursor-pointer hidden dark:block hover:text-gray-500 ${copied && 'text-primary hover:text-primary'}`}
|
||||
onClick={handleCopy}
|
||||
/>
|
||||
<ThunmbIcon
|
||||
type='like'
|
||||
className={`cursor-pointer dark:hidden hover:text-gray-500 ${state === ThumbsState.ThumbsUp && 'text-primary hover:text-primary'}`}
|
||||
onClick={() => handleClick(ThumbsState.ThumbsUp)}
|
||||
/>
|
||||
<ThunmbIcon
|
||||
type='likeDark'
|
||||
className={`cursor-pointer hidden dark:block hover:text-gray-500 ${state === ThumbsState.ThumbsUp && 'text-primary hover:text-primary'}`}
|
||||
onClick={() => handleClick(ThumbsState.ThumbsUp)}
|
||||
/>
|
||||
<ThunmbIcon
|
||||
type='unLike'
|
||||
className={`cursor-pointer dark:hidden hover:text-gray-500 ${state === ThumbsState.ThumbsDown && 'text-primary hover:text-primary'}`}
|
||||
onClick={() => handleClick(ThumbsState.ThumbsDown)}
|
||||
/>
|
||||
<ThunmbIcon
|
||||
type='unLikeDark'
|
||||
className={`cursor-pointer hidden dark:block hover:text-gray-500 ${state === ThumbsState.ThumbsDown && 'text-primary hover:text-primary'}`}
|
||||
onClick={() => handleClick(ThumbsState.ThumbsDown)}
|
||||
/>
|
||||
</div>
|
||||
};
|
||||
107
src/components/bs-comp/chatComponent/MessagePanne.tsx
Normal file
107
src/components/bs-comp/chatComponent/MessagePanne.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import ResouceModal from "@/pages/ChatAppPage/components/ResouceModal";
|
||||
import ThumbsMessage from "@/pages/ChatAppPage/components/ThumbsMessage";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import FileBs from "./FileBs";
|
||||
import MessageBs from "./MessageBs";
|
||||
import MessageSystem from "./MessageSystem";
|
||||
import MessageUser from "./MessageUser";
|
||||
import RunLog from "./RunLog";
|
||||
import Separator from "./Separator";
|
||||
import { useMessageStore } from "./messageStore";
|
||||
|
||||
export default function MessagePanne({ useName, guideWord, loadMore }) {
|
||||
const { t } = useTranslation()
|
||||
const { chatId, messages } = useMessageStore()
|
||||
|
||||
// 反馈
|
||||
const thumbRef = useRef(null)
|
||||
// 溯源
|
||||
const sourceRef = useRef(null)
|
||||
|
||||
// 自动滚动
|
||||
const messagesRef = useRef(null)
|
||||
const scrollLockRef = useRef(false)
|
||||
useEffect(() => {
|
||||
scrollLockRef.current = false
|
||||
queryLockRef.current = false
|
||||
}, [chatId])
|
||||
useEffect(() => {
|
||||
if (scrollLockRef.current) return
|
||||
messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
|
||||
}, [messages])
|
||||
|
||||
// 消息滚动加载
|
||||
const queryLockRef = useRef(false)
|
||||
useEffect(() => {
|
||||
function handleScroll() {
|
||||
if (queryLockRef.current) return
|
||||
const { scrollTop, clientHeight, scrollHeight } = messagesRef.current
|
||||
// 距离底部 600px内,开启自动滚动
|
||||
scrollLockRef.current = (scrollHeight - scrollTop - clientHeight) > 600
|
||||
|
||||
if (messagesRef.current.scrollTop <= 90) {
|
||||
console.log('请求 :>> ', 1);
|
||||
queryLockRef.current = true
|
||||
loadMore()
|
||||
// TODO 翻页定位
|
||||
// 临时处理防抖
|
||||
setTimeout(() => {
|
||||
queryLockRef.current = false
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
messagesRef.current?.addEventListener('scroll', handleScroll);
|
||||
return () => messagesRef.current?.removeEventListener('scroll', handleScroll)
|
||||
}, [messagesRef.current, messages, chatId]);
|
||||
|
||||
return <div id="message-panne" ref={messagesRef} className="h-full overflow-y-auto scrollbar-hide pt-12 pb-60">
|
||||
{guideWord && <MessageBs
|
||||
key={9999}
|
||||
data={{ message: guideWord, isSend: false, chatKey: '', end: true, user_name: '' }} />}
|
||||
{
|
||||
messages.map(msg => {
|
||||
// 工厂
|
||||
let type = 'llm'
|
||||
if (msg.isSend) {
|
||||
type = 'user'
|
||||
} else if (msg.category === 'divider') {
|
||||
type = 'separator'
|
||||
} else if (msg.files?.length) {
|
||||
type = 'file'
|
||||
} else if (['tool', 'flow', 'knowledge'].includes(msg.category)) {
|
||||
type = 'runLog'
|
||||
} else if (msg.thought) {
|
||||
type = 'system'
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'user':
|
||||
return <MessageUser key={msg.id} useName={useName} data={msg} />;
|
||||
case 'llm':
|
||||
return <MessageBs
|
||||
key={msg.id}
|
||||
data={msg}
|
||||
onUnlike={(chatId) => { thumbRef.current?.openModal(chatId) }}
|
||||
onSource={(data) => { sourceRef.current?.openModal(data) }}
|
||||
/>;
|
||||
case 'system':
|
||||
return <MessageSystem key={msg.id} data={msg} />;
|
||||
case 'separator':
|
||||
return <Separator key={msg.id} text={msg.message || t('chat.roundOver')} />;
|
||||
case 'file':
|
||||
return <FileBs key={msg.id} data={msg} />;
|
||||
case 'runLog':
|
||||
return <RunLog key={msg.id} data={msg} />;
|
||||
default:
|
||||
return <div className="text-sm mt-2 border rounded-md p-2" key={msg.id}>未知消息类型</div>;
|
||||
}
|
||||
})
|
||||
}
|
||||
{/* 踩 反馈 */}
|
||||
<ThumbsMessage ref={thumbRef}></ThumbsMessage>
|
||||
{/* 源文件类型 */}
|
||||
<ResouceModal ref={sourceRef}></ResouceModal>
|
||||
</div>
|
||||
};
|
||||
43
src/components/bs-comp/chatComponent/MessageSystem.tsx
Normal file
43
src/components/bs-comp/chatComponent/MessageSystem.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useToast } from "@/components/bs-ui/toast/use-toast"
|
||||
import { copyText } from "@/utils"
|
||||
import { CopyIcon } from "@radix-ui/react-icons"
|
||||
import { useMemo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
|
||||
export default function MessageSystem({ data }) {
|
||||
const { message } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleCopy = (dom) => {
|
||||
copyText(dom)
|
||||
|
||||
message({
|
||||
variant: 'success',
|
||||
title: t('prompt'),
|
||||
description: t('chat.copyTip')
|
||||
})
|
||||
}
|
||||
|
||||
// 日志markdown
|
||||
const logMkdown = useMemo(
|
||||
() => (
|
||||
data.thought && <ReactMarkdown
|
||||
linkTarget="_blank"
|
||||
className="bs-mkdown text-gray-600 dark:text-[white] inline-block break-all max-w-full text-sm [&>pre]:text-wrap"
|
||||
>
|
||||
{data.thought.toString()}
|
||||
</ReactMarkdown>
|
||||
),
|
||||
[data.thought]
|
||||
)
|
||||
|
||||
const border = { system: 'border-slate-500', question: 'border-amber-500', processing: 'border-cyan-600', answer: 'border-lime-600', report: 'border-slate-500', guide: 'border-none' }
|
||||
|
||||
return <div className="py-1">
|
||||
<div className={`relative rounded-sm px-6 py-4 border text-sm ${data.category === 'guide' ? 'bg-[#EDEFF6]' : 'bg-slate-50'} ${border[data.category || 'system']}`}>
|
||||
{logMkdown}
|
||||
{data.category === 'report' && <CopyIcon className=" absolute right-4 top-2 cursor-pointer" onClick={(e) => handleCopy(e.target.parentNode)}></CopyIcon>}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
50
src/components/bs-comp/chatComponent/MessageUser.tsx
Normal file
50
src/components/bs-comp/chatComponent/MessageUser.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { locationContext } from "@/contexts/locationContext";
|
||||
import { ChatMessageType } from "@/types/chat";
|
||||
import { MagnifyingGlassIcon, Pencil2Icon, ReloadIcon } from "@radix-ui/react-icons";
|
||||
import { useContext } from "react";
|
||||
import { useMessageStore } from "./messageStore";
|
||||
|
||||
export default function MessageUser({ useName, data }: { data: ChatMessageType }) {
|
||||
const msg = data.message[data.chatKey]
|
||||
|
||||
const { appConfig } = useContext(locationContext)
|
||||
const running = useMessageStore(state => state.running)
|
||||
|
||||
const handleSearch = () => {
|
||||
window.open(appConfig.dialogQuickSearch + encodeURIComponent(msg))
|
||||
}
|
||||
|
||||
const handleResend = (send) => {
|
||||
const myEvent = new CustomEvent('userResendMsgEvent', {
|
||||
detail: {
|
||||
send,
|
||||
message: msg
|
||||
}
|
||||
});
|
||||
document.dispatchEvent(myEvent);
|
||||
}
|
||||
|
||||
return <div className="flex justify-end w-full py-1">
|
||||
<div className="w-fit min-h-8 max-w-[90%]">
|
||||
{useName && <p className="text-gray-600 text-xs mb-2 text-right">{useName}</p>}
|
||||
<div className="rounded-2xl px-6 py-4 bg-[#EEF2FF] dark:bg-[#333A48]">
|
||||
<div className="flex gap-2 ">
|
||||
<div className="text-[#0D1638] dark:text-[#CFD5E8] text-sm break-all whitespace-break-spaces">{msg}</div>
|
||||
<div className="w-6 h-6 min-w-6"><img src="/user.png" alt="" /></div>
|
||||
</div>
|
||||
</div>
|
||||
{/* 附加信息 */}
|
||||
{
|
||||
// 数组类型的 data通常是文件上传消息,不展示附加按钮
|
||||
!Array.isArray(data.message.data) && <div className="flex justify-between mt-2">
|
||||
<span></span>
|
||||
<div className="flex gap-2 text-gray-400 cursor-pointer self-end">
|
||||
{!running && <Pencil2Icon className="hover:text-gray-500" onClick={() => handleResend(false)} />}
|
||||
{!running && <ReloadIcon className="hover:text-gray-500" onClick={() => handleResend(true)} />}
|
||||
{appConfig.dialogQuickSearch && <MagnifyingGlassIcon className="hover:text-gray-500" onClick={handleSearch} />}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
58
src/components/bs-comp/chatComponent/RunLog.tsx
Normal file
58
src/components/bs-comp/chatComponent/RunLog.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { LoadIcon } from "@/components/bs-icons/loading";
|
||||
import { ToastIcon } from "@/components/bs-icons/toast";
|
||||
import { cname } from "@/components/bs-ui/utils";
|
||||
import { useAssistantStore } from "@/store/assistantStore";
|
||||
import { CaretDownIcon } from "@radix-ui/react-icons";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export default function RunLog({ data }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// 该组件只有在助手测试页面用到,临时使用耦合方案,取 toollist来匹配 name
|
||||
const assistantState = useAssistantStore(state => state.assistantState)
|
||||
|
||||
const [title, lost] = useMemo(() => {
|
||||
let lost = false
|
||||
let title = ''
|
||||
const status = data.end ? '已使用' : '正在使用'
|
||||
if (data.category === 'flow') {
|
||||
const flow = assistantState.flow_list?.find(flow => flow.id === data.message.tool_key)
|
||||
// if (!flow) throw new Error('调试日志无法匹配到使用的技能详情,id:' + data.message.tool_key)
|
||||
if (flow) {
|
||||
lost = flow.status === 1
|
||||
title = lost ? `${flow.name} 已下线` : `${status} ${flow.name}`
|
||||
} else {
|
||||
title = '技能已被删除,无法获取技能名'
|
||||
}
|
||||
} else if (data.category === 'tool') {
|
||||
const tool = assistantState.tool_list?.find(tool => tool.tool_key === data.message.tool_key)
|
||||
// if (!tool) throw new Error('调试日志无法匹配到使用的工具详情,id:' + data.message.tool_key)
|
||||
|
||||
title = tool ? `${status} ${tool.name}` : '工具已被删除,无法获取工具名'
|
||||
} else if (data.category === 'knowledge') {
|
||||
const knowledge = assistantState.knowledge_list?.find(knowledge => knowledge.id === parseInt(data.message.tool_key))
|
||||
// if (!knowledge) throw new Error('调试日志无法匹配到使用的知识库详情,id:' + data.message.tool_key)
|
||||
|
||||
title = knowledge ? `${data.end ? '已搜索' : '正在搜索'} ${knowledge.name}` : '知识库已被删除,无法获取知识库名'
|
||||
}
|
||||
return [title, lost]
|
||||
}, [assistantState, data])
|
||||
|
||||
return <div className="py-1">
|
||||
<div className="rounded-sm border">
|
||||
<div className="flex justify-between items-center px-4 py-2 cursor-pointer" onClick={() => setOpen(!open)}>
|
||||
<div className="flex items-center font-bold gap-2 text-sm">
|
||||
{
|
||||
data.end ? <ToastIcon type={lost ? 'error' : 'success'} /> :
|
||||
<LoadIcon className="text-primary duration-300" />
|
||||
}
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<CaretDownIcon className={open && 'rotate-180'} />
|
||||
</div>
|
||||
<div className={cname('bg-gray-100 px-4 py-2 text-gray-500 overflow-hidden text-sm ', open ? 'h-auto' : 'h-0 p-0')}>
|
||||
<p>{data.thought}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
6
src/components/bs-comp/chatComponent/Separator.tsx
Normal file
6
src/components/bs-comp/chatComponent/Separator.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export default function Separator({ className = '', text }) {
|
||||
|
||||
return <div className={'flex items-center justify-center py-4 text-gray-400 text-sm ' + className}>
|
||||
----------- {text} -----------
|
||||
</div>
|
||||
};
|
||||
46
src/components/bs-comp/chatComponent/SourceEntry.tsx
Normal file
46
src/components/bs-comp/chatComponent/SourceEntry.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Badge } from "@/components/bs-ui/badge";
|
||||
import { InfoCircledIcon } from "@radix-ui/react-icons";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const enum SourceType {
|
||||
/** 无溯源 */
|
||||
NONE = 0,
|
||||
/** 文件 */
|
||||
FILE = 1,
|
||||
/** 无权限 */
|
||||
NO_PERMISSION = 2,
|
||||
/** 链接s */
|
||||
LINK = 3,
|
||||
/** 已命中的QA */
|
||||
HAS_QA = 4,
|
||||
}
|
||||
|
||||
export default function SourceEntry({ extra, end, source, className = '', onSource }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (source === SourceType.NONE || !end) return <div className={className}></div>
|
||||
const extraObj = extra ? JSON.parse(extra) : null
|
||||
|
||||
return <div className={className}>
|
||||
{(() => {
|
||||
switch (source) {
|
||||
case SourceType.FILE:
|
||||
return <Badge className="cursor-pointer" onClick={onSource}>{t('chat.source')}</Badge>;
|
||||
case SourceType.NO_PERMISSION:
|
||||
return <p className="flex text-xs text-gray-400 gap-1 items-center"><InfoCircledIcon className="text-red-300" />{t('chat.noAccess')}</p>;
|
||||
case SourceType.LINK:
|
||||
return (
|
||||
<div className="flex flex-col text-blue-500 text-xs">
|
||||
{
|
||||
extraObj.doc?.map(el => <a key={el.url} href={el.url} target="_blank">{el.title}</a>)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
case SourceType.HAS_QA:
|
||||
return <a href={extraObj.url} target="_blank" className="text-blue-500 text-xs">{extraObj.qa}</a>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
};
|
||||
10
src/components/bs-comp/chatComponent/index.tsx
Normal file
10
src/components/bs-comp/chatComponent/index.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import ChatInput from "./ChatInput";
|
||||
import MessagePanne from "./MessagePanne";
|
||||
|
||||
export default function ChatComponent({ clear = false, questions = [], form = false, useName, inputForm = null, guideWord, wsUrl, onBeforSend, loadMore = () => { } }) {
|
||||
|
||||
return <div className="relative h-full">
|
||||
<MessagePanne useName={useName} guideWord={guideWord} loadMore={loadMore}></MessagePanne>
|
||||
<ChatInput clear={clear} questions={questions} form={form} wsUrl={wsUrl} inputForm={inputForm} onBeforSend={onBeforSend} ></ChatInput>
|
||||
</div>
|
||||
};
|
||||
251
src/components/bs-comp/chatComponent/messageStore.ts
Normal file
251
src/components/bs-comp/chatComponent/messageStore.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { message } from '@/components/bs-ui/toast/use-toast';
|
||||
import { generateUUID } from '@/components/bs-ui/utils'
|
||||
import { MessageDB, getChatHistory } from '@/controllers/API'
|
||||
import { ChatMessageType } from '@/types/chat'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import { create } from 'zustand'
|
||||
|
||||
/**
|
||||
* 会话消息管理
|
||||
*/
|
||||
|
||||
type State = {
|
||||
running: boolean,
|
||||
/**
|
||||
* 会话 ID
|
||||
* 变更会触发 ws建立,解锁滚动
|
||||
*/
|
||||
chatId: string,
|
||||
/** 没有更多历史纪录 */
|
||||
historyEnd: boolean,
|
||||
messages: ChatMessageType[]
|
||||
/**
|
||||
* 控制引导问题的显示状态
|
||||
*/
|
||||
showGuideQuestion: boolean
|
||||
}
|
||||
|
||||
type Actions = {
|
||||
loadHistoryMsg: (flowid: string, chatId: string) => Promise<void>;
|
||||
loadMoreHistoryMsg: (flowid: string) => Promise<void>;
|
||||
destory: () => void;
|
||||
createSendMsg: (inputs: any, inputKey?: string) => void;
|
||||
createWsMsg: (data: any) => void;
|
||||
updateCurrentMessage: (wsdata: any, cover: boolean) => void;
|
||||
changeChatId: (chatId: string) => void;
|
||||
startNewRound: () => void;
|
||||
insetSeparator: (text: string) => void;
|
||||
insetSystemMsg: (text: string) => void;
|
||||
insetBsMsg: (text: string) => void;
|
||||
setShowGuideQuestion: (text: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
const handleHistoryMsg = (data: any[]): ChatMessageType[] => {
|
||||
const correctedJsonString = (str: string) => str
|
||||
// .replace(/\\([\s\S])|(`)/g, '\\\\$1$2') // 转义反斜线和反引号
|
||||
.replace(/\n/g, '\\n') // 转义换行符
|
||||
.replace(/\r/g, '\\r') // 转义回车符
|
||||
.replace(/\t/g, '\\t') // 转义制表符
|
||||
.replace(/'/g, '"'); // 将单引号替换为双引号
|
||||
return data.map(item => {
|
||||
// let count = 0
|
||||
let { message, files, is_bot, intermediate_steps, ...other } = item
|
||||
try {
|
||||
message = message && message[0] === '{' ? JSON.parse(message) : message || ''
|
||||
} catch (e) {
|
||||
// 未考虑的情况暂不处理
|
||||
console.error('消息 to JSON error :>> ', e);
|
||||
}
|
||||
return {
|
||||
...other,
|
||||
chatKey: typeof message === 'string' ? undefined : Object.keys(message)[0],
|
||||
end: true,
|
||||
files: files ? JSON.parse(files) : [],
|
||||
isSend: !is_bot,
|
||||
message,
|
||||
thought: intermediate_steps,
|
||||
noAccess: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let currentChatId = ''
|
||||
const runLogsTypes = ['tool', 'flow', 'knowledge']
|
||||
export const useMessageStore = create<State & Actions>((set, get) => ({
|
||||
running: false,
|
||||
chatId: '',
|
||||
messages: [],
|
||||
historyEnd: false,
|
||||
showGuideQuestion: false,
|
||||
setShowGuideQuestion(bln: boolean) {
|
||||
set({ showGuideQuestion: bln })
|
||||
},
|
||||
async loadHistoryMsg(flowid, chatId) {
|
||||
const res = await getChatHistory(flowid, chatId, 30, 0)
|
||||
const msgs = handleHistoryMsg(res)
|
||||
currentChatId = chatId
|
||||
set({ historyEnd: false, messages: msgs.reverse() })
|
||||
},
|
||||
async loadMoreHistoryMsg(flowid) {
|
||||
if (get().running) return // 会话进行中禁止加载more历史
|
||||
if (get().historyEnd) return // 没有更多历史纪录
|
||||
const chatId = get().chatId
|
||||
const prevMsgs = get().messages
|
||||
// 最后一条消息id不存在,忽略 loadmore
|
||||
if (!prevMsgs[0]?.id) return
|
||||
const res = await getChatHistory(flowid, chatId, 10, prevMsgs[0]?.id || 0)
|
||||
// 过滤非同一会话消息
|
||||
if (res[0]?.chat_id !== currentChatId) {
|
||||
return console.warn('loadMoreHistoryMsg chatId not match, ignore')
|
||||
}
|
||||
const msgs = handleHistoryMsg(res)
|
||||
if (msgs.length) {
|
||||
set({ messages: [...msgs.reverse(), ...prevMsgs] })
|
||||
} else {
|
||||
set({ historyEnd: true })
|
||||
}
|
||||
},
|
||||
destory() {
|
||||
set({ chatId: '', messages: [] })
|
||||
},
|
||||
createSendMsg(inputs, inputKey) {
|
||||
console.log('change createSendMsg', inputs, inputKey);
|
||||
|
||||
set((state) => ({
|
||||
messages:
|
||||
[...state.messages, {
|
||||
isSend: true,
|
||||
message: inputs,
|
||||
chatKey: inputKey,
|
||||
thought: '',
|
||||
category: '',
|
||||
files: [],
|
||||
end: false,
|
||||
user_name: ""
|
||||
}]
|
||||
}))
|
||||
},
|
||||
// start
|
||||
createWsMsg(data) {
|
||||
console.log('change createWsMsg');
|
||||
set((state) => {
|
||||
let newChat = cloneDeep(state.messages);
|
||||
newChat.push({
|
||||
isSend: false,
|
||||
message: runLogsTypes.includes(data.category) ? JSON.parse(data.message) : '',
|
||||
chatKey: '',
|
||||
thought: data.intermediate_steps || '',
|
||||
category: data.category || '',
|
||||
files: [],
|
||||
end: false,
|
||||
user_name: '',
|
||||
extra: data.extra
|
||||
})
|
||||
return { messages: newChat }
|
||||
})
|
||||
},
|
||||
// stream end
|
||||
updateCurrentMessage(wsdata, cover = false) {
|
||||
// console.log( wsdata.chat_id, get().chatId);
|
||||
// if (wsdata.end) {
|
||||
// debugger
|
||||
// }
|
||||
console.log('change updateCurrentMessage');
|
||||
const messages = get().messages
|
||||
const isRunLog = runLogsTypes.includes(wsdata.category);
|
||||
// run log类型存在嵌套情况,使用 extra 匹配 currentMessage; 否则取最近
|
||||
const currentMessageIndex = isRunLog ?
|
||||
messages.findLastIndex((msg) => msg.extra === wsdata.extra)
|
||||
: messages.findLastIndex((msg) => !runLogsTypes.includes(msg.category))
|
||||
const currentMessage = messages[currentMessageIndex]
|
||||
|
||||
const newCurrentMessage = {
|
||||
...currentMessage,
|
||||
...wsdata,
|
||||
id: isRunLog ? wsdata.extra : wsdata.messageId, // 每条消息必唯一
|
||||
message: isRunLog ? JSON.parse(wsdata.message) : currentMessage.message + wsdata.message,
|
||||
thought: currentMessage.thought + (wsdata.thought ? `${wsdata.thought}\n` : ''),
|
||||
files: wsdata.files || null,
|
||||
category: wsdata.category || '',
|
||||
source: wsdata.source
|
||||
}
|
||||
|
||||
messages[currentMessageIndex] = newCurrentMessage
|
||||
// 会话特殊处理,兼容后端的缺陷
|
||||
if (!isRunLog) {
|
||||
// start - end 之间没有内容删除load
|
||||
if (newCurrentMessage.end && !(newCurrentMessage.files.length || newCurrentMessage.thought || newCurrentMessage.message)) {
|
||||
messages.pop()
|
||||
}
|
||||
// 无 messageid 删除
|
||||
// if (newCurrentMessage.end && !newCurrentMessage.id) {
|
||||
// messages.pop()
|
||||
// }
|
||||
// 删除重复消息
|
||||
const prevMessage = messages[currentMessageIndex - 1];
|
||||
if ((prevMessage
|
||||
&& prevMessage.message === newCurrentMessage.message
|
||||
&& prevMessage.thought === newCurrentMessage.thought)
|
||||
|| cover) {
|
||||
const removedMsg = messages.pop()
|
||||
// 使用最后一条的信息作为准确信息
|
||||
Object.keys(prevMessage).forEach((key) => {
|
||||
prevMessage[key] = removedMsg[key]
|
||||
})
|
||||
}
|
||||
}
|
||||
set((state) => ({ messages: [...messages] }))
|
||||
},
|
||||
changeChatId(chatId) {
|
||||
set((state) => ({ chatId }))
|
||||
},
|
||||
startNewRound() {
|
||||
get().insetSeparator('配置已更新')
|
||||
set((state) => ({ showGuideQuestion: true }))
|
||||
},
|
||||
insetSeparator(text) {
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
...bsMsgItem,
|
||||
id: Math.random() * 1000000,
|
||||
category: 'divider',
|
||||
message: text,
|
||||
}]
|
||||
}))
|
||||
},
|
||||
insetSystemMsg(text) {
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
...bsMsgItem,
|
||||
id: Math.random() * 1000000,
|
||||
category: 'guide',
|
||||
thought: text,
|
||||
}]
|
||||
}))
|
||||
},
|
||||
insetBsMsg(text) {
|
||||
set((state) => ({
|
||||
messages: [...state.messages, {
|
||||
...bsMsgItem,
|
||||
id: 0,
|
||||
category: 'guide',
|
||||
thought: '',
|
||||
message: text
|
||||
}]
|
||||
}))
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
const bsMsgItem = {
|
||||
id: Math.random() * 1000000,
|
||||
isSend: false,
|
||||
message: '',
|
||||
chatKey: '',
|
||||
thought: '',
|
||||
category: '',
|
||||
files: [],
|
||||
end: true,
|
||||
user_name: ''
|
||||
}
|
||||
72
src/components/bs-comp/sheets/SkillChatSheet.tsx
Normal file
72
src/components/bs-comp/sheets/SkillChatSheet.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Badge } from "@/components/bs-ui/badge";
|
||||
import { Button } from "@/components/bs-ui/button";
|
||||
import { getChatOnlineApi } from "@/controllers/API/assistant";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { SearchInput } from "../../bs-ui/input";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "../../bs-ui/sheet";
|
||||
import CardComponent from "../cardComponent";
|
||||
import { SkillIcon } from "@/components/bs-icons/skill";
|
||||
import { AssistantIcon } from "@/components/bs-icons/assistant";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SkillChatSheet({ children, onSelect }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [keyword, setKeyword] = useState(' ')
|
||||
const allDataRef = useRef([])
|
||||
|
||||
useEffect(() => {
|
||||
open && getChatOnlineApi().then(res => {
|
||||
allDataRef.current = res
|
||||
setKeyword('')
|
||||
})
|
||||
// setKeyword(' ')
|
||||
}, [open])
|
||||
|
||||
const options = useMemo(() => {
|
||||
return allDataRef.current.filter(el => el.name.toLowerCase().includes(keyword.toLowerCase()))
|
||||
}, [keyword])
|
||||
|
||||
return <Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{children}
|
||||
</SheetTrigger>
|
||||
<SheetContent className="sm:min-w-[966px] bg-gray-100">
|
||||
<div className="flex h-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="w-fit p-6">
|
||||
<SheetTitle>{t('chat.dialogueSelection')}</SheetTitle>
|
||||
<SheetDescription>{t('chat.chooseSkillOrAssistant')}</SheetDescription>
|
||||
<SearchInput value={keyword} placeholder={t('chat.search')} className="my-6" onChange={(e) => setKeyword(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-[696px] bg-[#fff] p-5 pt-12 h-full flex flex-wrap gap-1.5 overflow-y-auto scrollbar-hide content-start">
|
||||
{
|
||||
options.length ? options.map((flow, i) => (
|
||||
<CardComponent key={i}
|
||||
id={i + 1}
|
||||
data={flow}
|
||||
title={flow.name}
|
||||
description={flow.desc}
|
||||
type="sheet"
|
||||
icon={flow.flow_type === 'flow' ? SkillIcon : AssistantIcon}
|
||||
footer={
|
||||
<Badge className={`absolute right-0 bottom-0 rounded-none rounded-br-md ${flow.flow_type === 'flow' && 'bg-gray-950'}`}>
|
||||
{flow.flow_type === 'flow' ? '技能' : '助手'}
|
||||
</Badge>
|
||||
}
|
||||
onClick={() => { onSelect(flow); setOpen(false) }}
|
||||
/>
|
||||
)) : <div className="flex flex-col items-center justify-center pt-40 w-full">
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('build.empty')}</p>
|
||||
<Button className="w-[200px]" onClick={() => navigate('/build/assist')}>{t('build.onlineSA')}</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
};
|
||||
101
src/components/bs-comp/sheets/SkillSheet.tsx
Normal file
101
src/components/bs-comp/sheets/SkillSheet.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { readOnlineFlows } from "../../../controllers/API/flow";
|
||||
import { FlowType } from "../../../types/flow";
|
||||
import { useTable } from "../../../util/hook";
|
||||
import { Button } from "../../bs-ui/button";
|
||||
import { SearchInput } from "../../bs-ui/input";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "../../bs-ui/sheet";
|
||||
import CardComponent from "../cardComponent";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SkillSheet({ select, children, onSelect }) {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const {
|
||||
data: onlineFlows,
|
||||
loading,
|
||||
search,
|
||||
} = useTable<FlowType>({}, (param) =>
|
||||
readOnlineFlows(param.page, param.keyword).then((res) => {
|
||||
return res;
|
||||
})
|
||||
);
|
||||
|
||||
const handleSearch = (e) => {
|
||||
const { value } = e.target;
|
||||
setKeyword(value);
|
||||
search(value);
|
||||
};
|
||||
|
||||
const toCreateFlow = () => {
|
||||
window.open("/build/skills");
|
||||
};
|
||||
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>{children}</SheetTrigger>
|
||||
<SheetContent className="bg-gray-100 sm:min-w-[966px]">
|
||||
<div className="flex h-full" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="w-fit p-6">
|
||||
<SheetTitle>{t("build.addSkill")}</SheetTitle>
|
||||
<SearchInput
|
||||
value={keyword}
|
||||
placeholder={t("build.search")}
|
||||
className="my-6"
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<Button className="w-full" onClick={toCreateFlow}>
|
||||
{t("build.createSkill")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex h-full min-w-[696px] flex-1 flex-wrap content-start gap-1.5 overflow-y-auto bg-[#fff] p-5 pt-12 scrollbar-hide">
|
||||
{onlineFlows[0] ? (
|
||||
onlineFlows.map((flow, i) => (
|
||||
<CardComponent
|
||||
key={i}
|
||||
id={i + 1}
|
||||
data={flow}
|
||||
title={flow.name}
|
||||
description={flow.description}
|
||||
type="sheet"
|
||||
footer={
|
||||
<div className="flex justify-end">
|
||||
{select.some((_) => _.id === flow.id) ? (
|
||||
<Button size="sm" className="h-6" disabled>
|
||||
{t("build.added")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-6"
|
||||
onClick={() => onSelect(flow)}
|
||||
>
|
||||
{t("build.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center justify-center pt-40">
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
{t("build.empty")}
|
||||
</p>
|
||||
<Button className="w-[200px]" onClick={toCreateFlow}>
|
||||
{t("build.createSkill")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
66
src/components/bs-comp/sheets/SkillTempSheet.tsx
Normal file
66
src/components/bs-comp/sheets/SkillTempSheet.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { readTempsDatabase } from "@/controllers/API";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { SearchInput } from "../../bs-ui/input";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetTitle, SheetTrigger } from "../../bs-ui/sheet";
|
||||
import CardComponent from "../cardComponent";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function SkillTempSheet({ children, onSelect }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [keyword, setKeyword] = useState(' ')
|
||||
const allDataRef = useRef([])
|
||||
|
||||
useEffect(() => {
|
||||
readTempsDatabase().then(res => {
|
||||
allDataRef.current = res
|
||||
setKeyword('')
|
||||
})
|
||||
}, [])
|
||||
|
||||
const options = useMemo(() => {
|
||||
return allDataRef.current.filter(el => el.name.toLowerCase().includes(keyword.toLowerCase()))
|
||||
}, [keyword])
|
||||
|
||||
return <Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
{children}
|
||||
</SheetTrigger>
|
||||
<SheetContent className="sm:min-w-[966px] bg-gray-100">
|
||||
<div className="flex h-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="w-fit p-6">
|
||||
<SheetTitle>{t('skills.skillTemplate')}</SheetTitle>
|
||||
<SheetDescription>{t('skills.skillTemplateChoose')}</SheetDescription>
|
||||
<SearchInput value={keyword} placeholder={t('build.search')} className="my-6" onChange={(e) => setKeyword(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-[696px] bg-[#fff] p-5 pt-12 h-full flex flex-wrap gap-1.5 overflow-y-auto scrollbar-hide content-start">
|
||||
<CardComponent
|
||||
id={0}
|
||||
type="sheet"
|
||||
data={null}
|
||||
title={t('skills.customSkills')}
|
||||
description=''
|
||||
onClick={() => navigate('/build/skill')}
|
||||
></CardComponent>
|
||||
{
|
||||
options.map((flow, i) => (
|
||||
<CardComponent key={i}
|
||||
id={i + 1}
|
||||
data={flow}
|
||||
title={flow.name}
|
||||
description={flow.description}
|
||||
type="sheet"
|
||||
footer={null}
|
||||
onClick={() => { onSelect(flow.id); setOpen(false) }}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
};
|
||||
87
src/components/bs-comp/sheets/ToolsSheet.tsx
Normal file
87
src/components/bs-comp/sheets/ToolsSheet.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Accordion } from "@/components/bs-ui/accordion";
|
||||
import { SearchInput } from "@/components/bs-ui/input";
|
||||
import { Sheet, SheetContent, SheetTitle, SheetTrigger } from "@/components/bs-ui/sheet";
|
||||
import { getAssistantToolsApi } from "@/controllers/API/assistant";
|
||||
import ToolItem from "@/pages/SkillPage/components/ToolItem";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PersonIcon, StarFilledIcon } from "@radix-ui/react-icons";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/bs-ui/button";
|
||||
|
||||
export default function ToolsSheet({ select, onSelect, children }) {
|
||||
const { t } = useTranslation()
|
||||
const [type, setType] = useState('default') // default custom
|
||||
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [allData, setAllData] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
getAssistantToolsApi(type).then(res => {
|
||||
setAllData(res)
|
||||
setKeyword('')
|
||||
})
|
||||
}, [type])
|
||||
|
||||
const options = useMemo(() => {
|
||||
return allData.filter(el =>
|
||||
el.name.toLowerCase().includes(keyword.toLowerCase())
|
||||
|| el.description.toLowerCase().includes(keyword.toLowerCase())
|
||||
)
|
||||
}, [keyword, allData])
|
||||
|
||||
return (
|
||||
<Sheet onOpenChange={open => !open && setKeyword('')}>
|
||||
<SheetTrigger asChild>
|
||||
{children}
|
||||
</SheetTrigger>
|
||||
<SheetContent className="w-[1000px] sm:max-w-[1000px] bg-gray-100">
|
||||
<div className="flex h-full" onClick={e => e.stopPropagation()}>
|
||||
<div className="w-fit p-6">
|
||||
<SheetTitle>{t('build.addTool')}</SheetTitle>
|
||||
<SearchInput placeholder={t('build.search')} className="mt-6" onChange={(e) => setKeyword(e.target.value)} />
|
||||
<Button
|
||||
className="mt-4 w-full"
|
||||
onClick={() => window.open("/build/tools")}
|
||||
>
|
||||
{t('create')}{t("tools.createCustomTool")}
|
||||
</Button>
|
||||
<div className="mt-4">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md cursor-pointer hover:bg-muted-foreground/10 transition-all duration-200 ${type === 'default' && 'bg-muted-foreground/10'}`}
|
||||
onClick={() => setType('default')}
|
||||
>
|
||||
<PersonIcon />
|
||||
<span>{t('tools.builtinTools')}</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-md cursor-pointer hover:bg-muted-foreground/10 transition-all duration-200 mt-1 ${type === 'custom' && 'bg-muted-foreground/10'}`}
|
||||
onClick={() => setType('custom')}
|
||||
>
|
||||
<StarFilledIcon />
|
||||
<span>{t('tools.customTools')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 bg-[#fff] p-5 pt-12 h-full overflow-auto scrollbar-hide">
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{
|
||||
options.length ? options.map(el => (
|
||||
<ToolItem
|
||||
key={el.id}
|
||||
type={'add'}
|
||||
select={select}
|
||||
data={el}
|
||||
onSelect={onSelect}
|
||||
></ToolItem>
|
||||
)) : <div className="pt-40 text-center text-sm text-muted-foreground mt-2">
|
||||
{t('build.empty')}
|
||||
</div>
|
||||
}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
|
||||
};
|
||||
Reference in New Issue
Block a user