This commit is contained in:
zhangkai
2024-06-05 14:27:06 +08:00
commit b825dcd4d5
730 changed files with 100244 additions and 0 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

View 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: ''
}

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

View 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>
);
}

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

View 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>
);
};