mirror of
https://github.com/infiniflow/ragflow.git
synced 2025-12-23 06:46:40 +08:00
Fix: Merge main branch (#10377)
### What problem does this PR solve? ### Type of change - [x] Bug Fix (non-breaking change which fixes an issue) --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: jinhai <haijin.chn@gmail.com> Signed-off-by: Jin Hai <haijin.chn@gmail.com> Co-authored-by: Lynn <lynn_inf@hotmail.com> Co-authored-by: chanx <1243304602@qq.com> Co-authored-by: balibabu <cike8899@users.noreply.github.com> Co-authored-by: 纷繁下的无奈 <zhileihuang@126.com> Co-authored-by: huangzl <huangzl@shinemo.com> Co-authored-by: writinwaters <93570324+writinwaters@users.noreply.github.com> Co-authored-by: Wilmer <33392318@qq.com> Co-authored-by: Adrian Weidig <adrianweidig@gmx.net> Co-authored-by: Zhichang Yu <yuzhichang@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Yongteng Lei <yongtengrey@outlook.com> Co-authored-by: Liu An <asiro@qq.com> Co-authored-by: buua436 <66937541+buua436@users.noreply.github.com> Co-authored-by: BadwomanCraZY <511528396@qq.com> Co-authored-by: cucusenok <31804608+cucusenok@users.noreply.github.com> Co-authored-by: Russell Valentine <russ@coldstonelabs.org> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Billy Bao <newyorkupperbay@gmail.com> Co-authored-by: Zhedong Cen <cenzhedong2@126.com> Co-authored-by: TensorNull <129579691+TensorNull@users.noreply.github.com> Co-authored-by: TensorNull <tensor.null@gmail.com> Co-authored-by: Ajay <160579663+aybanda@users.noreply.github.com> Co-authored-by: AB <aj@Ajays-MacBook-Air.local> Co-authored-by: 天海蒼灆 <huangaoqin@tecpie.com> Co-authored-by: He Wang <wanghechn@qq.com> Co-authored-by: Atsushi Hatakeyama <atu729@icloud.com> Co-authored-by: Jin Hai <haijin.chn@gmail.com> Co-authored-by: Mohamed Mathari <155896313+melmathari@users.noreply.github.com> Co-authored-by: Mohamed Mathari <nocodeventure@Mac-mini-van-Mohamed.fritz.box> Co-authored-by: Stephen Hu <stephenhu@seismic.com> Co-authored-by: Shaun Zhang <zhangwfjh@users.noreply.github.com> Co-authored-by: zhimeng123 <60221886+zhimeng123@users.noreply.github.com> Co-authored-by: mxc <mxc@example.com> Co-authored-by: Dominik Novotný <50611433+SgtMarmite@users.noreply.github.com> Co-authored-by: EVGENY M <168018528+rjohny55@users.noreply.github.com> Co-authored-by: mcoder6425 <mcoder64@gmail.com> Co-authored-by: TeslaZY <TeslaZY@outlook.com> Co-authored-by: lemsn <lemsn@msn.com> Co-authored-by: lemsn <lemsn@126.com> Co-authored-by: Adrian Gora <47756404+adagora@users.noreply.github.com> Co-authored-by: Womsxd <45663319+Womsxd@users.noreply.github.com> Co-authored-by: FatMii <39074672+FatMii@users.noreply.github.com>
This commit is contained in:
@ -15,6 +15,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { SharedFrom } from '@/constants/chat';
|
||||
import {
|
||||
@ -32,6 +34,8 @@ import { z } from 'zod';
|
||||
const FormSchema = z.object({
|
||||
visibleAvatar: z.boolean(),
|
||||
locale: z.string(),
|
||||
embedType: z.enum(['fullscreen', 'widget']),
|
||||
enableStreaming: z.boolean(),
|
||||
});
|
||||
|
||||
type IProps = IModalProps<any> & {
|
||||
@ -55,6 +59,8 @@ function EmbedDialog({
|
||||
defaultValues: {
|
||||
visibleAvatar: false,
|
||||
locale: '',
|
||||
embedType: 'fullscreen' as const,
|
||||
enableStreaming: false,
|
||||
},
|
||||
});
|
||||
|
||||
@ -68,20 +74,60 @@ function EmbedDialog({
|
||||
}, []);
|
||||
|
||||
const generateIframeSrc = useCallback(() => {
|
||||
const { visibleAvatar, locale } = values;
|
||||
let src = `${location.origin}${from === SharedFrom.Agent ? Routes.AgentShare : Routes.ChatShare}?shared_id=${token}&from=${from}&auth=${beta}`;
|
||||
const { visibleAvatar, locale, embedType, enableStreaming } = values;
|
||||
const baseRoute =
|
||||
embedType === 'widget'
|
||||
? Routes.ChatWidget
|
||||
: from === SharedFrom.Agent
|
||||
? Routes.AgentShare
|
||||
: Routes.ChatShare;
|
||||
let src = `${location.origin}${baseRoute}?shared_id=${token}&from=${from}&auth=${beta}`;
|
||||
if (visibleAvatar) {
|
||||
src += '&visible_avatar=1';
|
||||
}
|
||||
if (locale) {
|
||||
src += `&locale=${locale}`;
|
||||
}
|
||||
if (enableStreaming) {
|
||||
src += '&streaming=true';
|
||||
}
|
||||
return src;
|
||||
}, [beta, from, token, values]);
|
||||
|
||||
const text = useMemo(() => {
|
||||
const iframeSrc = generateIframeSrc();
|
||||
return `
|
||||
const { embedType } = values;
|
||||
|
||||
if (embedType === 'widget') {
|
||||
const { enableStreaming } = values;
|
||||
const streamingParam = enableStreaming
|
||||
? '&streaming=true'
|
||||
: '&streaming=false';
|
||||
return `
|
||||
~~~ html
|
||||
<iframe src="${iframeSrc}&mode=master${streamingParam}"
|
||||
style="position:fixed;bottom:0;right:0;width:100px;height:100px;border:none;background:transparent;z-index:9999"
|
||||
frameborder="0" allow="microphone;camera"></iframe>
|
||||
<script>
|
||||
window.addEventListener('message',e=>{
|
||||
if(e.origin!=='${location.origin.replace(/:\d+/, ':9222')}')return;
|
||||
if(e.data.type==='CREATE_CHAT_WINDOW'){
|
||||
if(document.getElementById('chat-win'))return;
|
||||
const i=document.createElement('iframe');
|
||||
i.id='chat-win';i.src=e.data.src;
|
||||
i.style.cssText='position:fixed;bottom:104px;right:24px;width:380px;height:500px;border:none;background:transparent;z-index:9998;display:none';
|
||||
i.frameBorder='0';i.allow='microphone;camera';
|
||||
document.body.appendChild(i);
|
||||
}else if(e.data.type==='TOGGLE_CHAT'){
|
||||
const w=document.getElementById('chat-win');
|
||||
if(w)w.style.display=e.data.isOpen?'block':'none';
|
||||
}else if(e.data.type==='SCROLL_PASSTHROUGH')window.scrollBy(0,e.data.deltaY);
|
||||
});
|
||||
</script>
|
||||
~~~
|
||||
`;
|
||||
} else {
|
||||
return `
|
||||
~~~ html
|
||||
<iframe
|
||||
src="${iframeSrc}"
|
||||
@ -91,7 +137,8 @@ function EmbedDialog({
|
||||
</iframe>
|
||||
~~~
|
||||
`;
|
||||
}, [generateIframeSrc]);
|
||||
}
|
||||
}, [generateIframeSrc, values]);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={hideModal}>
|
||||
@ -104,6 +151,36 @@ function EmbedDialog({
|
||||
<section className="w-full overflow-auto space-y-5 text-sm text-text-secondary">
|
||||
<Form {...form}>
|
||||
<form className="space-y-5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="embedType"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Embed Type</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="flex flex-col space-y-2"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="fullscreen" id="fullscreen" />
|
||||
<Label htmlFor="fullscreen" className="text-sm">
|
||||
Fullscreen Chat (Traditional iframe)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="widget" id="widget" />
|
||||
<Label htmlFor="widget" className="text-sm">
|
||||
Floating Widget (Intercom-style)
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="visibleAvatar"
|
||||
@ -120,6 +197,24 @@ function EmbedDialog({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{values.embedType === 'widget' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableStreaming"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Enable Streaming Responses</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="locale"
|
||||
@ -141,27 +236,33 @@ function EmbedDialog({
|
||||
<div>
|
||||
<span>{t('embedCode', { keyPrefix: 'search' })}</span>
|
||||
<HightLightMarkdown>{text}</HightLightMarkdown>
|
||||
<div className="max-h-[350px] overflow-auto">
|
||||
<span>{t('embedCode', { keyPrefix: 'search' })}</span>
|
||||
<div className="max-h-full overflow-y-auto">
|
||||
<HightLightMarkdown>{text}</HightLightMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
<div className=" font-medium mt-4 mb-1">
|
||||
{t(isAgent ? 'flow' : 'chat', { keyPrefix: 'header' })}
|
||||
<span className="ml-1 inline-block">ID</span>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-lg flex justify-between p-2">
|
||||
<span>{token} </span>
|
||||
<CopyToClipboard text={token}></CopyToClipboard>
|
||||
</div>
|
||||
<a
|
||||
className="cursor-pointer text-accent-primary inline-block"
|
||||
href={
|
||||
isAgent
|
||||
? 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-agent'
|
||||
: 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-chat-assistant'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('howUseId', { keyPrefix: isAgent ? 'flow' : 'chat' })}
|
||||
</a>
|
||||
</div>
|
||||
<div className=" font-medium mt-4 mb-1">
|
||||
{t(isAgent ? 'flow' : 'chat', { keyPrefix: 'header' })}
|
||||
<span className="ml-1 inline-block">ID</span>
|
||||
</div>
|
||||
<div className="bg-bg-card rounded-lg flex justify-between p-2">
|
||||
<span>{token} </span>
|
||||
<CopyToClipboard text={token}></CopyToClipboard>
|
||||
</div>
|
||||
<a
|
||||
className="cursor-pointer text-accent-primary inline-block"
|
||||
href={
|
||||
isAgent
|
||||
? 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-agent'
|
||||
: 'https://ragflow.io/docs/dev/http_api_reference#create-session-with-chat-assistant'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{t('howUseId', { keyPrefix: isAgent ? 'flow' : 'chat' })}
|
||||
</a>
|
||||
</section>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
58
web/src/components/floating-chat-widget-markdown.less
Normal file
58
web/src/components/floating-chat-widget-markdown.less
Normal file
@ -0,0 +1,58 @@
|
||||
/* floating-chat-widget-markdown.less */
|
||||
|
||||
.widget-citation-popover {
|
||||
max-width: 90vw;
|
||||
/* Use viewport width for better responsiveness */
|
||||
width: max-content;
|
||||
|
||||
.ant-popover-inner {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ant-popover-inner-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive breakpoints for popover width */
|
||||
@media (min-width: 480px) {
|
||||
.widget-citation-popover {
|
||||
max-width: 360px;
|
||||
}
|
||||
}
|
||||
|
||||
.widget-citation-content {
|
||||
|
||||
p,
|
||||
div,
|
||||
span,
|
||||
button {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.floating-chat-widget {
|
||||
|
||||
/* General styles for markdown content within the widget */
|
||||
p,
|
||||
div,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Enhanced image styles */
|
||||
img,
|
||||
.ant-image,
|
||||
.ant-image-img {
|
||||
max-width: 100% !important;
|
||||
height: auto !important;
|
||||
border-radius: 8px;
|
||||
margin: 8px 0 !important;
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
199
web/src/components/floating-chat-widget-markdown.tsx
Normal file
199
web/src/components/floating-chat-widget-markdown.tsx
Normal file
@ -0,0 +1,199 @@
|
||||
import Image from '@/components/image';
|
||||
import SvgIcon from '@/components/svg-icon';
|
||||
import { useFetchDocumentThumbnailsByIds, useGetDocumentUrl } from '@/hooks/document-hooks';
|
||||
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
|
||||
import { preprocessLaTeX, replaceThinkToSection, showImage } from '@/utils/chat';
|
||||
import { getExtension } from '@/utils/document-util';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Popover, Tooltip } from 'antd';
|
||||
import classNames from 'classnames';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { omit } from 'lodash';
|
||||
import { pipe } from 'lodash/fp';
|
||||
import 'katex/dist/katex.min.css';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Markdown from 'react-markdown';
|
||||
import reactStringReplace from 'react-string-replace';
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter';
|
||||
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import { visitParents } from 'unist-util-visit-parents';
|
||||
import { currentReg, replaceTextByOldReg } from '../pages/next-chats/utils';
|
||||
import styles from './floating-chat-widget-markdown.less';
|
||||
import { useIsDarkTheme } from './theme-provider';
|
||||
|
||||
const getChunkIndex = (match: string) => Number(match.replace(/\[|\]/g, ''));
|
||||
|
||||
const FloatingChatWidgetMarkdown = ({
|
||||
reference,
|
||||
clickDocumentButton,
|
||||
content,
|
||||
}: {
|
||||
content: string;
|
||||
loading: boolean;
|
||||
reference: IReference;
|
||||
clickDocumentButton?: (documentId: string, chunk: IReferenceChunk) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { setDocumentIds, data: fileThumbnails } = useFetchDocumentThumbnailsByIds();
|
||||
const getDocumentUrl = useGetDocumentUrl();
|
||||
const isDarkTheme = useIsDarkTheme();
|
||||
|
||||
const contentWithCursor = useMemo(() => {
|
||||
let text = content === '' ? t('chat.searching') : content;
|
||||
const nextText = replaceTextByOldReg(text);
|
||||
return pipe(replaceThinkToSection, preprocessLaTeX)(nextText);
|
||||
}, [content, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const docAggs = reference?.doc_aggs;
|
||||
const docList = Array.isArray(docAggs) ? docAggs : Object.values(docAggs ?? {});
|
||||
setDocumentIds(docList.map((x: any) => x.doc_id).filter(Boolean));
|
||||
}, [reference, setDocumentIds]);
|
||||
|
||||
const handleDocumentButtonClick = useCallback((documentId: string, chunk: IReferenceChunk, isPdf: boolean, documentUrl?: string) => () => {
|
||||
if (!documentId) return;
|
||||
if (!isPdf && documentUrl) {
|
||||
window.open(documentUrl, '_blank');
|
||||
} else if (clickDocumentButton) {
|
||||
clickDocumentButton(documentId, chunk);
|
||||
}
|
||||
}, [clickDocumentButton]);
|
||||
|
||||
const rehypeWrapReference = () => (tree: any) => {
|
||||
visitParents(tree, 'text', (node, ancestors) => {
|
||||
const latestAncestor = ancestors[ancestors.length - 1];
|
||||
if (latestAncestor.tagName !== 'custom-typography' && latestAncestor.tagName !== 'code') {
|
||||
node.type = 'element';
|
||||
node.tagName = 'custom-typography';
|
||||
node.properties = {};
|
||||
node.children = [{ type: 'text', value: node.value }];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getReferenceInfo = useCallback((chunkIndex: number) => {
|
||||
const chunkItem = reference?.chunks?.[chunkIndex];
|
||||
if (!chunkItem) return null;
|
||||
const docAggsArray = Array.isArray(reference?.doc_aggs) ? reference.doc_aggs : Object.values(reference?.doc_aggs ?? {});
|
||||
const document = docAggsArray.find((x: any) => x?.doc_id === chunkItem?.document_id) as any;
|
||||
const documentId = document?.doc_id;
|
||||
const documentUrl = document?.url ?? (documentId ? getDocumentUrl(documentId) : undefined);
|
||||
const fileThumbnail = documentId ? fileThumbnails[documentId] : '';
|
||||
const fileExtension = documentId ? getExtension(document?.doc_name ?? '') : '';
|
||||
return { documentUrl, fileThumbnail, fileExtension, imageId: chunkItem.image_id, chunkItem, documentId, document };
|
||||
}, [fileThumbnails, reference, getDocumentUrl]);
|
||||
|
||||
const getPopoverContent = useCallback((chunkIndex: number) => {
|
||||
const info = getReferenceInfo(chunkIndex);
|
||||
|
||||
if (!info) {
|
||||
return <div className="p-2 text-xs text-red-500">Error: Missing document information.</div>;
|
||||
}
|
||||
|
||||
const { documentUrl, fileThumbnail, fileExtension, imageId, chunkItem, documentId, document } = info;
|
||||
|
||||
return (
|
||||
<div key={`popover-content-${chunkItem.id}`} className="flex gap-2 widget-citation-content">
|
||||
{imageId && (
|
||||
<Popover placement="left" content={<Image id={imageId} className="max-w-[80vw] max-h-[60vh] rounded" />}>
|
||||
<Image id={imageId} className="w-24 h-24 object-contain rounded m-1 cursor-pointer" />
|
||||
</Popover>
|
||||
)}
|
||||
<div className="space-y-2 flex-1 min-w-0">
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(chunkItem?.content ?? '') }}
|
||||
className="max-h-[250px] overflow-y-auto text-xs leading-relaxed p-2 bg-gray-50 dark:bg-gray-800 rounded prose-sm"
|
||||
></div>
|
||||
{documentId && (
|
||||
<Flex gap={'small'} align="center">
|
||||
{fileThumbnail ? (
|
||||
<img src={fileThumbnail} alt={document?.doc_name} className="w-6 h-6 rounded" />
|
||||
) : (
|
||||
<SvgIcon name={`file-icon/${fileExtension}`} width={20} />
|
||||
)}
|
||||
<Tooltip title={!documentUrl && fileExtension !== 'pdf' ? 'Document link unavailable' : document.doc_name}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="p-0 text-xs break-words h-auto text-left flex-1"
|
||||
onClick={handleDocumentButtonClick(documentId, chunkItem, fileExtension === 'pdf', documentUrl)}
|
||||
disabled={!documentUrl && fileExtension !== 'pdf'}
|
||||
style={{ whiteSpace: 'normal' }}
|
||||
>
|
||||
<span className="truncate">{document?.doc_name ?? 'Unnamed Document'}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, [getReferenceInfo, handleDocumentButtonClick]);
|
||||
|
||||
const renderReference = useCallback((text: string) => {
|
||||
return reactStringReplace(text, currentReg, (match, i) => {
|
||||
const chunkIndex = getChunkIndex(match);
|
||||
const info = getReferenceInfo(chunkIndex);
|
||||
|
||||
if (!info) {
|
||||
return <Tooltip key={`err-tooltip-${i}`} title="Reference unavailable"><InfoCircleOutlined className={styles.referenceIcon} /></Tooltip>;
|
||||
}
|
||||
|
||||
const { imageId, chunkItem, documentId, fileExtension, documentUrl } = info;
|
||||
|
||||
if (showImage(chunkItem?.doc_type)) {
|
||||
return <Image key={`img-${i}`} id={imageId} className="block object-contain max-w-full max-h-48 rounded my-2 cursor-pointer" onClick={handleDocumentButtonClick(documentId, chunkItem, fileExtension === 'pdf', documentUrl)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
content={getPopoverContent(chunkIndex)}
|
||||
key={`popover-${i}`}
|
||||
>
|
||||
<InfoCircleOutlined className={styles.referenceIcon} />
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
}, [getPopoverContent, getReferenceInfo, handleDocumentButtonClick]);
|
||||
|
||||
return (
|
||||
<div className="floating-chat-widget">
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
className="text-sm leading-relaxed space-y-2 prose-sm max-w-full"
|
||||
components={{
|
||||
'custom-typography': ({ children }: { children: string }) => renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, node, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...omit(rest, 'inline')}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code {...rest} className={classNames(className, 'text-wrap text-xs bg-gray-200 dark:bg-gray-700 px-1 py-0.5 rounded')}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingChatWidgetMarkdown;
|
||||
703
web/src/components/floating-chat-widget.tsx
Normal file
703
web/src/components/floating-chat-widget.tsx
Normal file
@ -0,0 +1,703 @@
|
||||
import { MessageType, SharedFrom } from '@/constants/chat';
|
||||
import { useFetchNextConversationSSE } from '@/hooks/chat-hooks';
|
||||
import { useFetchFlowSSE } from '@/hooks/flow-hooks';
|
||||
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
|
||||
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
|
||||
import i18n from '@/locales/config';
|
||||
import { MessageCircle, Minimize2, Send, X } from 'lucide-react';
|
||||
import PdfDrawer from '@/components/pdf-drawer';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
useGetSharedChatSearchParams,
|
||||
useSendSharedMessage,
|
||||
} from '../pages/next-chats/hooks/use-send-shared-message';
|
||||
import FloatingChatWidgetMarkdown from './floating-chat-widget-markdown';
|
||||
|
||||
const FloatingChatWidget = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
|
||||
const [displayMessages, setDisplayMessages] = useState<any[]>([]);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
sharedId: conversationId,
|
||||
from,
|
||||
locale,
|
||||
visibleAvatar,
|
||||
} = useGetSharedChatSearchParams();
|
||||
|
||||
// Check if we're in button-only mode or window-only mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get('mode') || 'full'; // 'button', 'window', or 'full'
|
||||
const enableStreaming = urlParams.get('streaming') === 'true'; // Only enable if explicitly set to true
|
||||
|
||||
const {
|
||||
handlePressEnter,
|
||||
handleInputChange,
|
||||
value: hookValue,
|
||||
sendLoading,
|
||||
derivedMessages,
|
||||
hasError,
|
||||
} = useSendSharedMessage();
|
||||
|
||||
// Sync our local input with the hook's value when needed
|
||||
useEffect(() => {
|
||||
if (hookValue && hookValue !== inputValue) {
|
||||
setInputValue(hookValue);
|
||||
}
|
||||
}, [hookValue, inputValue]);
|
||||
|
||||
const { data: chatInfo } = useFetchExternalChatInfo();
|
||||
|
||||
const useFetchAvatar = useMemo(() => {
|
||||
return from === SharedFrom.Agent
|
||||
? useFetchFlowSSE
|
||||
: useFetchNextConversationSSE;
|
||||
}, [from]);
|
||||
|
||||
const { data: avatarData } = useFetchAvatar();
|
||||
|
||||
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
|
||||
useClickDrawer();
|
||||
|
||||
// PDF drawer state tracking
|
||||
useEffect(() => {
|
||||
// Drawer state management
|
||||
}, [visible, documentId, selectedChunk]);
|
||||
|
||||
// Play sound when opening
|
||||
const playNotificationSound = useCallback(() => {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 800;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 0.3,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.3);
|
||||
} catch (error) {
|
||||
// Silent fail if audio not supported
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Play sound for AI responses (Intercom-style)
|
||||
const playResponseSound = useCallback(() => {
|
||||
try {
|
||||
const audioContext = new (window.AudioContext ||
|
||||
(window as any).webkitAudioContext)();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.frequency.value = 600;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(
|
||||
0.01,
|
||||
audioContext.currentTime + 0.2,
|
||||
);
|
||||
|
||||
oscillator.start(audioContext.currentTime);
|
||||
oscillator.stop(audioContext.currentTime + 0.2);
|
||||
} catch (error) {
|
||||
// Silent fail if audio not supported
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set loaded state and locale
|
||||
useEffect(() => {
|
||||
// Set component as loaded after a brief moment to prevent flash
|
||||
const timer = setTimeout(() => {
|
||||
setIsLoaded(true);
|
||||
// Tell parent window that we're ready to be shown
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'WIDGET_READY',
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}, 50);
|
||||
|
||||
if (locale && i18n.language !== locale) {
|
||||
i18n.changeLanguage(locale);
|
||||
}
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [locale]);
|
||||
|
||||
// Handle message display based on streaming preference
|
||||
useEffect(() => {
|
||||
if (!derivedMessages) {
|
||||
setDisplayMessages([]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (enableStreaming) {
|
||||
// Show messages as they stream
|
||||
setDisplayMessages(derivedMessages);
|
||||
} else {
|
||||
// Only show complete messages (non-streaming mode)
|
||||
const completeMessages = derivedMessages.filter((msg, index) => {
|
||||
// Always show user messages immediately
|
||||
if (msg.role === MessageType.User) return true;
|
||||
|
||||
// For AI messages, only show when response is complete (not loading)
|
||||
if (msg.role === MessageType.Assistant) {
|
||||
return !sendLoading || index < derivedMessages.length - 1;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
setDisplayMessages(completeMessages);
|
||||
}
|
||||
}, [derivedMessages, enableStreaming, sendLoading]);
|
||||
|
||||
// Auto-scroll to bottom when display messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [displayMessages]);
|
||||
|
||||
// Play sound only when AI response is complete (not streaming chunks)
|
||||
useEffect(() => {
|
||||
if (derivedMessages && derivedMessages.length > 0 && !sendLoading) {
|
||||
const lastMessage = derivedMessages[derivedMessages.length - 1];
|
||||
if (
|
||||
lastMessage.role === MessageType.Assistant &&
|
||||
lastMessage.id !== lastResponseId &&
|
||||
derivedMessages.length > 1
|
||||
) {
|
||||
setLastResponseId(lastMessage.id || '');
|
||||
playResponseSound();
|
||||
}
|
||||
}
|
||||
}, [derivedMessages, sendLoading, lastResponseId, playResponseSound]);
|
||||
|
||||
const toggleChat = useCallback(() => {
|
||||
if (mode === 'button') {
|
||||
// In button mode, communicate with parent window to show/hide chat window
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'TOGGLE_CHAT',
|
||||
isOpen: !isOpen,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
setIsOpen(!isOpen);
|
||||
if (!isOpen) {
|
||||
playNotificationSound();
|
||||
}
|
||||
} else {
|
||||
// In full mode, handle locally
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
setIsMinimized(false);
|
||||
playNotificationSound();
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
setIsMinimized(false);
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, playNotificationSound]);
|
||||
|
||||
const minimizeChat = useCallback(() => {
|
||||
setIsMinimized(true);
|
||||
}, []);
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
if (!inputValue.trim() || sendLoading) return;
|
||||
|
||||
// Update the hook's internal state first
|
||||
const syntheticEvent = {
|
||||
target: { value: inputValue },
|
||||
currentTarget: { value: inputValue },
|
||||
preventDefault: () => { },
|
||||
} as any;
|
||||
|
||||
handleInputChange(syntheticEvent);
|
||||
|
||||
// Wait for state to update, then send
|
||||
setTimeout(() => {
|
||||
handlePressEnter([]);
|
||||
// Clear our local input after sending
|
||||
setInputValue('');
|
||||
}, 50);
|
||||
}, [inputValue, sendLoading, handleInputChange, handlePressEnter]);
|
||||
|
||||
const handleKeyPress = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
},
|
||||
[handleSendMessage],
|
||||
);
|
||||
|
||||
if (!conversationId) {
|
||||
return (
|
||||
<div className="fixed bottom-5 right-5 z-50">
|
||||
<div className="bg-red-500 text-white p-4 rounded-lg shadow-lg">
|
||||
Error: No conversation ID provided
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the blocking return - we'll handle visibility with CSS instead
|
||||
|
||||
const messageCount = displayMessages?.length || 0;
|
||||
|
||||
// Render different content based on mode
|
||||
if (mode === 'master') {
|
||||
// Master mode - handles everything and creates second iframe dynamically
|
||||
useEffect(() => {
|
||||
// Create the chat window iframe dynamically when needed
|
||||
const createChatWindow = () => {
|
||||
// Check if iframe already exists in parent document
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'CREATE_CHAT_WINDOW',
|
||||
src: window.location.href.replace('mode=master', 'mode=window'),
|
||||
},
|
||||
'*',
|
||||
);
|
||||
};
|
||||
|
||||
createChatWindow();
|
||||
|
||||
// Listen for our own toggle events to show/hide the dynamic iframe
|
||||
const handleToggle = (e: MessageEvent) => {
|
||||
if (e.source === window) return; // Ignore our own messages
|
||||
|
||||
const chatWindow = document.getElementById(
|
||||
'dynamic-chat-window',
|
||||
) as HTMLIFrameElement;
|
||||
if (chatWindow && e.data.type === 'TOGGLE_CHAT') {
|
||||
chatWindow.style.display = e.data.isOpen ? 'block' : 'none';
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleToggle);
|
||||
return () => window.removeEventListener('message', handleToggle);
|
||||
}, []);
|
||||
|
||||
// Show just the button in master mode
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
const newIsOpen = !isOpen;
|
||||
setIsOpen(newIsOpen);
|
||||
if (newIsOpen) playNotificationSound();
|
||||
|
||||
// Tell the parent to show/hide the dynamic iframe
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'TOGGLE_CHAT',
|
||||
isOpen: newIsOpen,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}}
|
||||
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Unread Badge */}
|
||||
{!isOpen && messageCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
||||
{messageCount > 9 ? '9+' : messageCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'button') {
|
||||
// Only render the floating button
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Unread Badge */}
|
||||
{!isOpen && messageCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
||||
{messageCount > 9 ? '9+' : messageCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === 'window') {
|
||||
// Only render the chat window (always open)
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`fixed top-0 left-0 z-50 bg-blue-600 rounded-2xl transition-all duration-300 ease-out h-[500px] w-[380px] overflow-hidden ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-t-2xl">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
||||
<MessageCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">
|
||||
{chatInfo?.title || 'Chat Support'}
|
||||
</h3>
|
||||
<p className="text-xs text-blue-100">
|
||||
We typically reply instantly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages and Input */}
|
||||
<div
|
||||
className="flex flex-col h-[436px] bg-white"
|
||||
style={{ borderRadius: '0 0 16px 16px' }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const isAtTop = element.scrollTop === 0;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - 1;
|
||||
|
||||
// Allow scroll to pass through to parent when at boundaries
|
||||
if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) {
|
||||
e.preventDefault();
|
||||
// Let the parent handle the scroll
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'SCROLL_PASSTHROUGH',
|
||||
deltaY: e.deltaY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayMessages?.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[280px] px-4 py-2 rounded-2xl ${message.role === MessageType.User
|
||||
? 'bg-blue-600 text-white rounded-br-md'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
{message.role === MessageType.User ? (
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
) : (
|
||||
<FloatingChatWidgetMarkdown
|
||||
loading={false}
|
||||
content={message.content}
|
||||
reference={message.reference || { doc_aggs: [], chunks: [], total: 0 }}
|
||||
clickDocumentButton={clickDocumentButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Clean Typing Indicator */}
|
||||
{sendLoading && !enableStreaming && (
|
||||
<div className="flex justify-start pl-4">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-blue-500 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-end space-x-3">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
handleInputChange(e);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{ minHeight: '44px', maxHeight: '120px' }}
|
||||
disabled={hasError || sendLoading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue.trim() || sendLoading}
|
||||
className="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PdfDrawer
|
||||
visible={visible}
|
||||
hideModal={hideModal}
|
||||
documentId={documentId}
|
||||
chunk={selectedChunk}
|
||||
width={'100vw'}
|
||||
height={'100vh'}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
} // Full mode - render everything together (original behavior)
|
||||
return (
|
||||
<div
|
||||
className={`transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
>
|
||||
{/* Chat Widget Container */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed bottom-24 right-6 z-50 bg-blue-600 rounded-2xl transition-all duration-300 ease-out ${isMinimized ? 'h-16' : 'h-[500px]'
|
||||
} w-[380px] overflow-hidden`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white rounded-t-2xl">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
||||
<MessageCircle size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-sm">
|
||||
{chatInfo?.title || 'Chat Support'}
|
||||
</h3>
|
||||
<p className="text-xs text-blue-100">
|
||||
We typically reply instantly
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<button
|
||||
onClick={minimizeChat}
|
||||
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container */}
|
||||
{!isMinimized && (
|
||||
<div
|
||||
className="flex flex-col h-[436px] bg-white"
|
||||
style={{ borderRadius: '0 0 16px 16px' }}
|
||||
>
|
||||
<div
|
||||
className="flex-1 overflow-y-auto p-4 space-y-4"
|
||||
onWheel={(e) => {
|
||||
const element = e.currentTarget;
|
||||
const isAtTop = element.scrollTop === 0;
|
||||
const isAtBottom =
|
||||
element.scrollTop + element.clientHeight >=
|
||||
element.scrollHeight - 1;
|
||||
|
||||
// Allow scroll to pass through to parent when at boundaries
|
||||
if (
|
||||
(isAtTop && e.deltaY < 0) ||
|
||||
(isAtBottom && e.deltaY > 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
// Let the parent handle the scroll
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'SCROLL_PASSTHROUGH',
|
||||
deltaY: e.deltaY,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{displayMessages?.map((message, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[280px] px-4 py-2 rounded-2xl ${message.role === MessageType.User
|
||||
? 'bg-blue-600 text-white rounded-br-md'
|
||||
: 'bg-gray-100 text-gray-800 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
{message.role === MessageType.User ? (
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{message.content}
|
||||
</p>
|
||||
) : (
|
||||
<FloatingChatWidgetMarkdown
|
||||
loading={false}
|
||||
content={message.content}
|
||||
reference={message.reference || { doc_aggs: [], chunks: [], total: 0 }}
|
||||
clickDocumentButton={clickDocumentButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Typing Indicator */}
|
||||
{sendLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.1s' }}
|
||||
></div>
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
||||
style={{ animationDelay: '0.2s' }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-end space-x-3">
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setInputValue(newValue);
|
||||
// Also update the hook's state
|
||||
handleInputChange(e);
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type your message..."
|
||||
rows={1}
|
||||
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
style={{ minHeight: '44px', maxHeight: '120px' }}
|
||||
disabled={hasError || sendLoading}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
disabled={!inputValue.trim() || sendLoading}
|
||||
className="p-3 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Button */}
|
||||
<div className="fixed bottom-6 right-6 z-50">
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className={`w-14 h-14 bg-blue-600 hover:bg-blue-700 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Unread Badge */}
|
||||
{!isOpen && messageCount > 0 && (
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
||||
{messageCount > 9 ? '9+' : messageCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<PdfDrawer
|
||||
visible={visible}
|
||||
hideModal={hideModal}
|
||||
documentId={documentId}
|
||||
chunk={selectedChunk}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FloatingChatWidget;
|
||||
@ -17,7 +17,7 @@ export function MaxTokenNumberFormField({ max = 2048, initialValue }: IProps) {
|
||||
tooltip={t('chunkTokenNumberTip')}
|
||||
max={max}
|
||||
defaultValue={initialValue ?? 0}
|
||||
layout={FormLayout.Horizontal}
|
||||
layout={FormLayout.Vertical}
|
||||
></SliderInputFormField>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import DocumentPreviewer from '../pdf-previewer';
|
||||
interface IProps extends IModalProps<any> {
|
||||
documentId: string;
|
||||
chunk: IChunk | IReferenceChunk;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
export const PdfDrawer = ({
|
||||
@ -14,13 +16,16 @@ export const PdfDrawer = ({
|
||||
hideModal,
|
||||
documentId,
|
||||
chunk,
|
||||
width = '50vw',
|
||||
height,
|
||||
}: IProps) => {
|
||||
return (
|
||||
<Drawer
|
||||
title="Document Previewer"
|
||||
onClose={hideModal}
|
||||
open={visible}
|
||||
width={'50vw'}
|
||||
width={width}
|
||||
height={height}
|
||||
>
|
||||
<DocumentPreviewer
|
||||
documentId={documentId}
|
||||
@ -31,4 +36,4 @@ export const PdfDrawer = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default PdfDrawer;
|
||||
export default PdfDrawer;
|
||||
@ -1,6 +1,6 @@
|
||||
import { FormLayout } from '@/constants/form';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { SingleFormSlider } from './ui/dual-range-slider';
|
||||
import {
|
||||
@ -40,7 +40,7 @@ export function SliderInputFormField({
|
||||
}: SliderInputFormFieldProps) {
|
||||
const form = useFormContext();
|
||||
|
||||
const isHorizontal = layout === FormLayout.Horizontal;
|
||||
const isHorizontal = useMemo(() => layout === FormLayout.Vertical, [layout]);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
|
||||
@ -43,6 +43,7 @@ export const LanguageList = [
|
||||
'English',
|
||||
'Chinese',
|
||||
'Traditional Chinese',
|
||||
'Russian',
|
||||
'Indonesia',
|
||||
'Spanish',
|
||||
'Vietnamese',
|
||||
@ -55,6 +56,7 @@ export const LanguageMap = {
|
||||
English: 'English',
|
||||
Chinese: '简体中文',
|
||||
'Traditional Chinese': '繁體中文',
|
||||
Russian: 'Русский',
|
||||
Indonesia: 'Indonesia',
|
||||
Spanish: 'Español',
|
||||
Vietnamese: 'Tiếng việt',
|
||||
@ -68,6 +70,7 @@ export enum LanguageAbbreviation {
|
||||
En = 'en',
|
||||
Zh = 'zh',
|
||||
ZhTraditional = 'zh-TRADITIONAL',
|
||||
Ru = 'ru',
|
||||
Id = 'id',
|
||||
Ja = 'ja',
|
||||
Es = 'es',
|
||||
@ -81,6 +84,7 @@ export const LanguageAbbreviationMap = {
|
||||
[LanguageAbbreviation.En]: 'English',
|
||||
[LanguageAbbreviation.Zh]: '简体中文',
|
||||
[LanguageAbbreviation.ZhTraditional]: '繁體中文',
|
||||
[LanguageAbbreviation.Ru]: 'Русский',
|
||||
[LanguageAbbreviation.Id]: 'Indonesia',
|
||||
[LanguageAbbreviation.Es]: 'Español',
|
||||
[LanguageAbbreviation.Vi]: 'Tiếng việt',
|
||||
@ -94,6 +98,7 @@ export const LanguageTranslationMap = {
|
||||
English: 'en',
|
||||
Chinese: 'zh',
|
||||
'Traditional Chinese': 'zh-TRADITIONAL',
|
||||
Russian: 'ru',
|
||||
Indonesia: 'id',
|
||||
Spanish: 'es',
|
||||
Vietnamese: 'vi',
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import { ITestRetrievalRequestBody } from '@/interfaces/request/knowledge';
|
||||
import i18n from '@/locales/config';
|
||||
import kbService, {
|
||||
deleteKnowledgeGraph,
|
||||
getKnowledgeGraph,
|
||||
listDataset,
|
||||
} from '@/services/knowledge-service';
|
||||
@ -306,6 +307,31 @@ export function useFetchKnowledgeMetadata(kbIds: string[] = []) {
|
||||
return { data, loading };
|
||||
}
|
||||
|
||||
export const useRemoveKnowledgeGraph = () => {
|
||||
const knowledgeBaseId = useKnowledgeBaseId();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
data,
|
||||
isPending: loading,
|
||||
mutateAsync,
|
||||
} = useMutation({
|
||||
mutationKey: [KnowledgeApiAction.RemoveKnowledgeGraph],
|
||||
mutationFn: async () => {
|
||||
const { data } = await deleteKnowledgeGraph(knowledgeBaseId);
|
||||
if (data.code === 0) {
|
||||
message.success(i18n.t(`message.deleted`));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['fetchKnowledgeGraph'],
|
||||
});
|
||||
}
|
||||
return data?.code;
|
||||
},
|
||||
});
|
||||
|
||||
return { data, loading, removeKnowledgeGraph: mutateAsync };
|
||||
};
|
||||
|
||||
export const useFetchKnowledgeList = (
|
||||
shouldFilterListWithoutDocument: boolean = false,
|
||||
): {
|
||||
|
||||
@ -55,7 +55,7 @@ export const translationTable = createTranslationTable(
|
||||
[
|
||||
'English',
|
||||
'Vietnamese',
|
||||
'Rus',
|
||||
'ru',
|
||||
'Spanish',
|
||||
'zh',
|
||||
'zh-TRADITIONAL',
|
||||
|
||||
@ -624,6 +624,10 @@ export default {
|
||||
baseUrl: 'Basis-URL',
|
||||
baseUrlTip:
|
||||
'Wenn Ihr API-Schlüssel von OpenAI stammt, ignorieren Sie dies. Andere Zwischenanbieter geben diese Basis-URL mit dem API-Schlüssel an.',
|
||||
tongyiBaseUrlTip:
|
||||
'Für chinesische Benutzer ist keine Eingabe erforderlich oder verwenden Sie https://dashscope.aliyuncs.com/compatible-mode/v1. Für internationale Benutzer verwenden Sie https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder:
|
||||
'(Nur für internationale Benutzer, bitte Hinweis beachten)',
|
||||
modify: 'Ändern',
|
||||
systemModelSettings: 'Standardmodelle festlegen',
|
||||
chatModel: 'Chat-Modell',
|
||||
|
||||
@ -726,6 +726,9 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
|
||||
baseUrl: 'Base-Url',
|
||||
baseUrlTip:
|
||||
'If your API key is from OpenAI, just ignore it. Any other intermediate providers will give this base url with the API key.',
|
||||
tongyiBaseUrlTip:
|
||||
'For Chinese users, no need to fill in or use https://dashscope.aliyuncs.com/compatible-mode/v1. For international users, use https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder: '(International users only, please see tip)',
|
||||
modify: 'Modify',
|
||||
systemModelSettings: 'Set default models',
|
||||
chatModel: 'Chat model',
|
||||
@ -997,14 +1000,14 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
|
||||
addTools: 'Add Tools',
|
||||
sysPromptDefultValue: `
|
||||
<role>
|
||||
You are a helpful assistant, an AI assistant specialized in problem-solving for the user.
|
||||
You are a helpful assistant, an AI assistant specialized in problem-solving for the user.
|
||||
If a specific domain is provided, adapt your expertise to that domain; otherwise, operate as a generalist.
|
||||
</role>
|
||||
<instructions>
|
||||
1. Understand the user’s request.
|
||||
2. Decompose it into logical subtasks.
|
||||
3. Execute each subtask step by step, reasoning transparently.
|
||||
4. Validate accuracy and consistency.
|
||||
1. Understand the user’s request.
|
||||
2. Decompose it into logical subtasks.
|
||||
3. Execute each subtask step by step, reasoning transparently.
|
||||
4. Validate accuracy and consistency.
|
||||
5. Summarize the final result clearly.
|
||||
</instructions>`,
|
||||
singleLineText: 'Single-line text',
|
||||
|
||||
@ -340,6 +340,10 @@ export default {
|
||||
baseUrl: 'URL base',
|
||||
baseUrlTip:
|
||||
'Si tu clave API es de OpenAI, ignora esto. Cualquier otro proveedor intermedio proporcionará esta URL base junto con la clave API.',
|
||||
tongyiBaseUrlTip:
|
||||
'Para usuarios chinos, no es necesario rellenar o usar https://dashscope.aliyuncs.com/compatible-mode/v1. Para usuarios internacionales, usar https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder:
|
||||
'(Solo para usuarios internacionales, por favor ver consejo)',
|
||||
modify: 'Modificar',
|
||||
systemModelSettings: 'Establecer modelos predeterminados',
|
||||
chatModel: 'Modelo de chat',
|
||||
|
||||
@ -522,6 +522,10 @@ export default {
|
||||
baseUrl: 'URL de base',
|
||||
baseUrlTip:
|
||||
"Si votre clé API provient d'OpenAI, ignorez ceci. Tout autre fournisseur intermédiaire fournira cette URL de base avec la clé API.",
|
||||
tongyiBaseUrlTip:
|
||||
'Pour les utilisateurs chinois, pas besoin de remplir ou utiliser https://dashscope.aliyuncs.com/compatible-mode/v1. Pour les utilisateurs internationaux, utilisez https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder:
|
||||
"(Utilisateurs internationaux uniquement, veuillez consulter l'astuce)",
|
||||
modify: 'Modifier',
|
||||
systemModelSettings: 'Définir les modèles par défaut',
|
||||
chatModel: 'Modèle de chat',
|
||||
@ -783,7 +787,7 @@ export default {
|
||||
'Un composant qui recherche sur duckduckgo.com, vous permettant de spécifier le nombre de résultats avec TopN. Il complète les bases de connaissances existantes.',
|
||||
searXNG: 'SearXNG',
|
||||
searXNGDescription:
|
||||
'Un composant qui effectue des recherches via la URL de l\'instance de SearXNG que vous fournissez. Spécifiez TopN et l\'URL de l\'instance.',
|
||||
"Un composant qui effectue des recherches via la URL de l'instance de SearXNG que vous fournissez. Spécifiez TopN et l'URL de l'instance.",
|
||||
channel: 'Canal',
|
||||
channelTip:
|
||||
"Effectuer une recherche de texte ou d'actualités sur l'entrée du composant",
|
||||
|
||||
@ -512,6 +512,10 @@ export default {
|
||||
baseUrl: 'Base-Url',
|
||||
baseUrlTip:
|
||||
'Jika kunci API Anda berasal dari OpenAI, abaikan saja. Penyedia perantara lainnya akan memberikan base url ini dengan kunci API.',
|
||||
tongyiBaseUrlTip:
|
||||
'Untuk pengguna Tiongkok, tidak perlu diisi atau gunakan https://dashscope.aliyuncs.com/compatible-mode/v1. Untuk pengguna internasional, gunakan https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder:
|
||||
'(Hanya untuk pengguna internasional, silakan lihat tip)',
|
||||
modify: 'Ubah',
|
||||
systemModelSettings: 'Tetapkan model default',
|
||||
chatModel: 'Model Obrolan',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
export default {
|
||||
translation: {
|
||||
common: {
|
||||
selectPlaceholder: '選択してください',
|
||||
delete: '削除',
|
||||
deleteModalTitle: 'この項目を削除してよろしいですか?',
|
||||
ok: 'はい',
|
||||
@ -174,6 +175,7 @@ export default {
|
||||
'ナレッジベースの設定、特にチャンク方法をここで更新してください。',
|
||||
name: 'ナレッジベース名',
|
||||
photo: 'ナレッジベース写真',
|
||||
photoTip: '4MBのファイルをアップロードできます',
|
||||
description: '説明',
|
||||
language: '言語',
|
||||
languageMessage: '言語を入力してください',
|
||||
@ -332,6 +334,12 @@ export default {
|
||||
questionTip: `質問が指定されている場合、チャンクの埋め込みはそれらに基づきます。`,
|
||||
},
|
||||
chat: {
|
||||
messagePlaceholder: 'メッセージを入力してください...',
|
||||
exit: '終了',
|
||||
multipleModels: '複数モデル',
|
||||
applyModelConfigs: 'モデル設定を適用',
|
||||
conversations: '会話一覧',
|
||||
chatApps: 'チャットアプリ',
|
||||
newConversation: '新しい会話',
|
||||
createAssistant: 'アシスタントを作成',
|
||||
assistantSetting: 'アシスタント設定',
|
||||
@ -351,6 +359,7 @@ export default {
|
||||
language: '言語',
|
||||
emptyResponse: '空の応答',
|
||||
emptyResponseTip: `ナレッジベースに該当する内容がない場合、この応答が使用されます。`,
|
||||
emptyResponseMessage: `ナレッジベースから関連する情報が取得できなかった場合に発動します。ナレッジベースが選択されていない場合、このフィールドをクリアしてください。`,
|
||||
setAnOpener: 'オープニングメッセージを設定',
|
||||
setAnOpenerInitial: `こんにちは! 私はあなたのアシスタントです。何をお手伝いしましょうか?`,
|
||||
setAnOpenerTip: 'お客様をどのように歓迎しますか?',
|
||||
@ -377,10 +386,14 @@ export default {
|
||||
model: 'モデル',
|
||||
modelTip: '大規模言語チャットモデル',
|
||||
modelMessage: '選択してください!',
|
||||
modelEnabledTools: '有効化されたツール',
|
||||
modelEnabledToolsTip:
|
||||
'モデルで使用するツールを1つ以上選択してください。ツール呼び出しをサポートしていないモデルでは効果がありません。',
|
||||
freedom: '自由度',
|
||||
improvise: '自由に',
|
||||
precise: '正確に',
|
||||
balance: 'バランス',
|
||||
custom: 'カスタム',
|
||||
freedomTip: `'正確に'は、LLMが慎重に質問に答えることを意味します。'自由に'は、LLMが多く話し、自由に答えることを望むことを意味します。'バランス'は、慎重さと自由さの間のバランスを取ることを意味します。`,
|
||||
temperature: '温度',
|
||||
temperatureMessage: '温度は必須です',
|
||||
@ -434,6 +447,7 @@ export default {
|
||||
partialTitle: '部分埋め込み',
|
||||
extensionTitle: 'Chrome拡張機能',
|
||||
tokenError: 'まずAPIトークンを作成してください!',
|
||||
betaError: 'システム設定ページからRAGFlow APIキーを取得してください。',
|
||||
searching: '検索中...',
|
||||
parsing: '解析中',
|
||||
uploading: 'アップロード中',
|
||||
@ -450,6 +464,38 @@ export default {
|
||||
'マルチラウンドの会話では、ナレッジベースへのクエリが最適化されます。大規模モデルが呼び出され、追加のトークンが消費されます。',
|
||||
howUseId: 'チャットIDの使い方?',
|
||||
description: 'アシスタントの説明',
|
||||
descriptionPlaceholder: '例: 履歴書用のチャットアシスタント',
|
||||
useKnowledgeGraph: 'ナレッジグラフを使用',
|
||||
useKnowledgeGraphTip:
|
||||
'ナレッジグラフを利用してエンティティや関係をまたいだ検索を行います。マルチホップ質問応答が可能になりますが、検索時間が大幅に増加します。',
|
||||
keyword: 'キーワード解析',
|
||||
keywordTip: `LLMでユーザーの質問を解析し、重要キーワードを抽出して関連性計算で強調します。長文クエリに有効ですが応答速度が遅くなります。`,
|
||||
languageTip:
|
||||
'選択した言語で文をリライトできます。未選択の場合は直近の質問の言語が使われます。',
|
||||
avatarHidden: 'アバターを非表示',
|
||||
locale: 'ロケール',
|
||||
selectLanguage: '言語を選択',
|
||||
reasoning: '推論',
|
||||
reasoningTip: `Deepseek-R1 や OpenAI o1 のように、推論ワークフローを有効にできます。ステップごとの論理展開で複雑な質問への正確さが向上します。`,
|
||||
tavilyApiKeyTip:
|
||||
'ここにTavilyのAPIキーを設定すると、ナレッジベース検索に加えてウェブ検索も利用できます。',
|
||||
tavilyApiKeyMessage: 'Tavily APIキーを入力してください',
|
||||
tavilyApiKeyHelp: '取得方法はこちら',
|
||||
crossLanguage: 'クロス言語検索',
|
||||
crossLanguageTip: `1つ以上の言語を選択すると、その言語でも検索します。未選択の場合は元の言語で検索します。`,
|
||||
createChat: 'チャットを作成',
|
||||
metadata: 'メタデータ',
|
||||
metadataTip:
|
||||
'メタデータ(タグ、カテゴリ、アクセス権限など)を使って、関連情報の検索を制御・絞り込みます。',
|
||||
conditions: '条件',
|
||||
addCondition: '条件を追加',
|
||||
meta: {
|
||||
disabled: '無効',
|
||||
automatic: '自動',
|
||||
manual: '手動',
|
||||
},
|
||||
cancel: 'キャンセル',
|
||||
chatSetting: 'チャット設定',
|
||||
},
|
||||
setting: {
|
||||
profile: 'プロファイル',
|
||||
@ -508,6 +554,9 @@ export default {
|
||||
baseUrl: 'ベースURL',
|
||||
baseUrlTip:
|
||||
'APIキーがOpenAIからのものであれば無視してください。他の中間プロバイダーはAPIキーと共にこのベースURLを提供します。',
|
||||
tongyiBaseUrlTip:
|
||||
'中国ユーザーの場合、記入不要または https://dashscope.aliyuncs.com/compatible-mode/v1 を使用してください。国際ユーザーは https://dashscope-intl.aliyuncs.com/compatible-mode/v1 を使用してください',
|
||||
tongyiBaseUrlPlaceholder: '(国際ユーザーのみ、ヒントをご覧ください)',
|
||||
modify: '変更',
|
||||
systemModelSettings: 'デフォルトモデルを設定する',
|
||||
chatModel: 'チャットモデル',
|
||||
|
||||
@ -504,6 +504,10 @@ export default {
|
||||
baseUrl: 'URL Base',
|
||||
baseUrlTip:
|
||||
'Se sua chave da API for do OpenAI, ignore isso. Outros provedores intermediários fornecerão essa URL base com a chave da API.',
|
||||
tongyiBaseUrlTip:
|
||||
'Para usuários chineses, não é necessário preencher ou usar https://dashscope.aliyuncs.com/compatible-mode/v1. Para usuários internacionais, use https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder:
|
||||
'(Apenas para usuários internacionais, consulte a dica)',
|
||||
modify: 'Modificar',
|
||||
systemModelSettings: 'Definir modelos padrão',
|
||||
chatModel: 'Modelo de chat',
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
export default {
|
||||
translation: {
|
||||
common: {
|
||||
noResults: 'Нет результатов.',
|
||||
selectPlaceholder: 'выберите значение',
|
||||
selectAll: 'Выбрать все',
|
||||
delete: 'Удалить',
|
||||
deleteModalTitle: 'Вы уверены, что хотите удалить этот элемент?',
|
||||
ok: 'Да',
|
||||
cancel: 'Нет',
|
||||
no: 'Нет',
|
||||
total: 'Всего',
|
||||
rename: 'Переименовать',
|
||||
name: 'Название',
|
||||
@ -34,10 +38,15 @@ export default {
|
||||
pleaseSelect: 'Выберите',
|
||||
pleaseInput: 'Введите',
|
||||
submit: 'Отправить',
|
||||
clear: 'Очистить',
|
||||
embedIntoSite: 'Встроить на веб-страницу',
|
||||
previousPage: 'Назад',
|
||||
nextPage: 'Вперед',
|
||||
add: 'Добавить',
|
||||
remove: 'Удалить',
|
||||
search: 'Поиск',
|
||||
noDataFound: 'Данные не найдены.',
|
||||
noData: 'Нет данных',
|
||||
promptPlaceholder: `Введите текст или используйте / для быстрой вставки переменных.`,
|
||||
mcp: {
|
||||
namePlaceholder: 'Мой MCP сервер',
|
||||
@ -80,6 +89,7 @@ export default {
|
||||
flow: 'Агент',
|
||||
search: 'Поиск',
|
||||
welcome: 'Добро пожаловать в',
|
||||
dataset: 'Набор данных',
|
||||
},
|
||||
knowledgeList: {
|
||||
welcome: 'С возвращением',
|
||||
@ -92,6 +102,38 @@ export default {
|
||||
noMoreData: `Это всё. Больше ничего нет.`,
|
||||
},
|
||||
knowledgeDetails: {
|
||||
generateKnowledgeGraph:
|
||||
'Это извлечет сущности и связи из всех ваших документов в этом наборе данных. Процесс может занять некоторое время.',
|
||||
generateRaptor:
|
||||
'Это извлечет сущности и связи из всех ваших документов в этом наборе данных. Процесс может занять некоторое время.',
|
||||
generate: 'Сгенерировать',
|
||||
raptor: 'RAPTOR',
|
||||
knowledgeGraph: 'Граф знаний',
|
||||
processingType: 'Тип обработки',
|
||||
dataPipeline: 'Пайплайн данных',
|
||||
operations: 'Операции',
|
||||
status: 'Статус',
|
||||
task: 'Задача',
|
||||
startDate: 'Дата начала',
|
||||
source: 'Источник',
|
||||
fileName: 'Имя файла',
|
||||
datasetLogs: 'Логи набора данных',
|
||||
fileLogs: 'Логи файлов',
|
||||
overview: 'Обзор',
|
||||
success: 'Успешно',
|
||||
failed: 'Ошибка',
|
||||
completed: 'Завершено',
|
||||
processLog: 'Лог процесса',
|
||||
created: 'Создано',
|
||||
learnMore: 'Узнать больше',
|
||||
general: 'Общие',
|
||||
chunkMethodTab: 'Метод фрагментации',
|
||||
testResults: 'Результаты тестирования',
|
||||
testSetting: 'Настройки тестирования',
|
||||
retrievalTesting: 'Тестирование поиска',
|
||||
retrievalTestingDescription:
|
||||
'Проведите тест поиска, чтобы проверить, может ли RAGFlow находить нужный контент для LLM.',
|
||||
Parse: 'Обработать',
|
||||
dataset: 'Набор данных',
|
||||
testing: 'Тестирование поиска',
|
||||
files: 'файлы',
|
||||
@ -119,6 +161,10 @@ export default {
|
||||
processBeginAt: 'Начато в',
|
||||
processDuration: 'Длительность',
|
||||
progressMsg: 'Прогресс',
|
||||
noTestResultsForRuned:
|
||||
'Релевантные результаты не найдены. Попробуйте изменить запрос или параметры.',
|
||||
noTestResultsForNotRuned:
|
||||
'Тест еще не проводился. Результаты появятся здесь.',
|
||||
testingDescription:
|
||||
'Проведите тест поиска, чтобы проверить, может ли RAGFlow находить нужный контент для LLM. Если вы изменили настройки по умолчанию (например, вес сходства ключевых слов или порог сходства), имейте в виду, что эти изменения не сохранятся автоматически. Вы должны применить их в настройках чат-ассистента или компонента поиска.',
|
||||
similarityThreshold: 'Порог сходства',
|
||||
@ -127,6 +173,9 @@ export default {
|
||||
vectorSimilarityWeight: 'Вес сходства ключевых слов',
|
||||
vectorSimilarityWeightTip:
|
||||
'Устанавливает вес сходства ключевых слов в общей оценке сходства. Сумма весов должна быть равна 1.0.',
|
||||
keywordSimilarityWeight: 'Вес сходства ключевых слов',
|
||||
keywordSimilarityWeightTip:
|
||||
'Устанавливает вес сходства ключевых слов в общей оценке сходства. Сумма весов должна быть равна 1.0.',
|
||||
testText: 'Тестовый текст',
|
||||
testTextPlaceholder: 'Введите ваш вопрос здесь!',
|
||||
testingLabel: 'Тестирование',
|
||||
@ -168,6 +217,7 @@ export default {
|
||||
chunk: 'Фрагмент',
|
||||
bulk: 'Пакетно',
|
||||
cancel: 'Отмена',
|
||||
close: 'Закрыть',
|
||||
rerankModel: 'Модель реранкинга',
|
||||
rerankPlaceholder: 'Выберите',
|
||||
rerankTip: `Опционально. Если оставить пустым, RAGFlow будет использовать комбинацию сходства ключевых слов и векторов. Выбор модели реранкинга заменит векторное сходство на оценку реранкинга.`,
|
||||
@ -211,6 +261,16 @@ export default {
|
||||
reRankModelWaring: 'Модель реранкинга требует много времени.',
|
||||
},
|
||||
knowledgeConfiguration: {
|
||||
enableAutoGenerate: 'Включить авто-генерацию',
|
||||
teamPlaceholder: 'Выберите команду.',
|
||||
dataFlowPlaceholder: 'Выберите поток данных.',
|
||||
buildItFromScratch: 'Создать с нуля',
|
||||
useRAPTORToEnhanceRetrieval: 'Использовать RAPTOR для улучшения поиска',
|
||||
extractKnowledgeGraph: 'Извлечь граф знаний',
|
||||
dataFlow: 'Поток данных',
|
||||
parseType: 'Тип обработки',
|
||||
manualSetup: 'Ручная настройка',
|
||||
builtIn: 'Встроенный',
|
||||
titleDescription:
|
||||
'Обновите конфигурацию базы знаний, особенно метод фрагментации.',
|
||||
name: 'Название базы знаний',
|
||||
@ -397,6 +457,12 @@ export default {
|
||||
delete: 'Удалить',
|
||||
},
|
||||
chat: {
|
||||
messagePlaceholder: 'Введите ваше сообщение здесь...',
|
||||
exit: 'Выйти',
|
||||
multipleModels: 'Несколько моделей',
|
||||
applyModelConfigs: 'Применить настройки моделей',
|
||||
conversations: 'Диалоги',
|
||||
chatApps: 'Чат-приложения',
|
||||
newConversation: 'Новый диалог',
|
||||
createAssistant: 'Создать ассистента',
|
||||
assistantSetting: 'Настройки ассистента',
|
||||
@ -450,6 +516,7 @@ export default {
|
||||
improvise: 'Импровизация',
|
||||
precise: 'Точность',
|
||||
balance: 'Баланс',
|
||||
custom: 'Пользовательский',
|
||||
freedomTip: `Сокращенная настройка 'Температуры', 'Top P', 'Штрафа за присутствие' и 'Штрафа за частоту'.`,
|
||||
temperature: 'Температура',
|
||||
temperatureMessage: 'Требуется температура',
|
||||
@ -537,6 +604,18 @@ export default {
|
||||
crossLanguage: 'Межъязыковый поиск',
|
||||
crossLanguageTip: `Выберите один или несколько языков для межъязыкового поиска.`,
|
||||
createChat: 'Создать чат',
|
||||
metadata: 'Метаданные',
|
||||
metadataTip:
|
||||
'Фильтрация метаданных - это процесс использования атрибутов метаданных для уточнения и контроля поиска релевантной информации.',
|
||||
conditions: 'Условия',
|
||||
addCondition: 'Добавить условие',
|
||||
meta: {
|
||||
disabled: 'Отключено',
|
||||
automatic: 'Автоматически',
|
||||
manual: 'Вручную',
|
||||
},
|
||||
cancel: 'Отмена',
|
||||
chatSetting: 'Настройки чата',
|
||||
},
|
||||
setting: {
|
||||
profile: 'Профиль',
|
||||
@ -592,6 +671,10 @@ export default {
|
||||
baseUrl: 'Базовый URL',
|
||||
baseUrlTip:
|
||||
'Если ваш API ключ от OpenAI, оставьте пустым. Другие провайдеры предоставляют базовый URL с API ключом.',
|
||||
tongyiBaseUrlTip:
|
||||
'Для китайских пользователей не нужно заполнять, используйте https://dashscope.aliyuncs.com/compatible-mode/v1. Для международных пользователей используйте https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder:
|
||||
'(Только для международных пользователей, см. подсказку)',
|
||||
modify: 'Изменить',
|
||||
systemModelSettings: 'Установить модели по умолчанию',
|
||||
chatModel: 'Модель чата',
|
||||
@ -772,6 +855,7 @@ export default {
|
||||
hint: 'Подсказка',
|
||||
},
|
||||
fileManager: {
|
||||
files: 'Файлы',
|
||||
name: 'Название',
|
||||
uploadDate: 'Дата загрузки',
|
||||
knowledgeBase: 'База знаний',
|
||||
@ -793,13 +877,97 @@ export default {
|
||||
fileError: 'Ошибка файла',
|
||||
uploadLimit: 'Каждый файл ≤10MB, всего файлов ≤128.',
|
||||
destinationFolder: 'Целевая папка',
|
||||
pleaseUploadAtLeastOneFile: 'Пожалуйста, загрузите хотя бы один файл',
|
||||
},
|
||||
flow: {
|
||||
recommended: 'Рекомендуемые',
|
||||
customerSupport: 'Поддержка клиентов',
|
||||
marketing: 'Маркетинг',
|
||||
consumerApp: 'Потребительские приложения',
|
||||
other: 'Другое',
|
||||
agents: 'Агенты',
|
||||
days: 'Дни',
|
||||
beginInput: 'Входные параметры',
|
||||
ref: 'Переменная',
|
||||
stockCode: 'Код акции',
|
||||
apiKeyPlaceholder:
|
||||
'YOUR_API_KEY (получить с https://serpapi.com/manage-api-key)',
|
||||
flowStart: 'Начать',
|
||||
flowNum: 'Номер',
|
||||
test: 'Тест',
|
||||
extractDepth: 'Глубина извлечения',
|
||||
format: 'Формат',
|
||||
basic: 'базовый',
|
||||
advanced: 'продвинутый',
|
||||
general: 'общий',
|
||||
searchDepth: 'Глубина поиска',
|
||||
tavilyTopic: 'Тема Tavily',
|
||||
maxResults: 'Макс. результатов',
|
||||
includeAnswer: 'Включать ответ',
|
||||
includeRawContent: 'Включать исходный контент',
|
||||
includeImages: 'Включать изображения',
|
||||
includeImageDescriptions: 'Включать описания изображений',
|
||||
includeDomains: 'Включать домены',
|
||||
ExcludeDomains: 'Исключать домены',
|
||||
Days: 'Дни',
|
||||
comma: 'Запятая',
|
||||
semicolon: 'Точка с запятой',
|
||||
period: 'Точка',
|
||||
lineBreak: 'Перенос строки',
|
||||
tab: 'Табуляция',
|
||||
space: 'Пробел',
|
||||
delimiters: 'Разделители',
|
||||
merge: 'Объединить',
|
||||
split: 'Разделить',
|
||||
script: 'Скрипт',
|
||||
iterationItemDescription:
|
||||
'Представляет текущий элемент в итерации, который можно использовать в последующих шагах.',
|
||||
guidingQuestion: 'Направляющий вопрос',
|
||||
onFailure: 'При ошибке',
|
||||
userPromptDefaultValue: 'Это заказ, который нужно отправить агенту.',
|
||||
search: 'Поиск',
|
||||
communication: 'Коммуникация',
|
||||
developer: 'Разработчик',
|
||||
typeCommandOrsearch: 'Введите команду или поиск...',
|
||||
builtIn: 'Встроенный',
|
||||
ExceptionDefaultValue: 'Значение по умолчанию при исключении',
|
||||
exceptionMethod: 'Метод обработки исключений',
|
||||
maxRounds: 'Макс. раундов рефлексии',
|
||||
delayEfterError: 'Задержка после ошибки',
|
||||
maxRetries: 'Макс. попыток',
|
||||
advancedSettings: 'Расширенные настройки',
|
||||
addTools: 'Добавить инструменты',
|
||||
sysPromptDefultValue: `
|
||||
<role>
|
||||
Вы полезный помощник, ИИ-ассистент, специализирующийся на решении проблем пользователя.
|
||||
Если указана конкретная область, адаптируйте вашу экспертизу к этой области; в противном случае действуйте как универсальный специалист.
|
||||
</role>
|
||||
<instructions>
|
||||
1. Поймите запрос пользователя.
|
||||
2. Разбейте его на логические подзадачи.
|
||||
3. Выполните каждую подзадачу шаг за шагом, прозрачно рассуждая.
|
||||
4. Проверьте точность и согласованность.
|
||||
5. Четко обобщите окончательный результат.
|
||||
</instructions>`,
|
||||
singleLineText: 'Однострочный текст',
|
||||
multimodalModels: 'Мультимодальные модели',
|
||||
textOnlyModels: 'Только текстовые модели',
|
||||
allModels: 'Все модели',
|
||||
codeExecDescription:
|
||||
'Напишите свою пользовательскую логику на Python или Javascript.',
|
||||
stringTransformDescription:
|
||||
'Изменяет текстовое содержимое. В настоящее время поддерживает: разделение или объединение текста.',
|
||||
foundation: 'Основа',
|
||||
tools: 'Инструменты',
|
||||
dataManipulation: 'Манипуляция данными',
|
||||
flow: 'Поток',
|
||||
dialog: 'Диалог',
|
||||
cite: 'Источник',
|
||||
citeTip: 'Источник информации',
|
||||
name: 'Название',
|
||||
nameMessage: 'Введите название',
|
||||
description: 'Описание',
|
||||
descriptionMessage: 'Это агент для конкретной задачи.',
|
||||
examples: 'Примеры',
|
||||
to: 'Кому',
|
||||
msg: 'Сообщения',
|
||||
@ -1217,6 +1385,7 @@ export default {
|
||||
variableSettings: 'Настройки переменных',
|
||||
globalVariables: 'Глобальные переменные',
|
||||
systemPrompt: 'Системный промпт',
|
||||
userPrompt: 'Пользовательский промпт',
|
||||
addCategory: 'Добавить категорию',
|
||||
categoryName: 'Название категории',
|
||||
nextStep: 'Следующий шаг',
|
||||
@ -1280,10 +1449,15 @@ export default {
|
||||
openingCopy: 'Приветственное сообщение',
|
||||
openingSwitchTip: 'Пользователи увидят это приветствие в начале.',
|
||||
modeTip: 'Режим определяет, как запускается рабочий процесс.',
|
||||
mode: 'Режим',
|
||||
conversational: 'диалоговый',
|
||||
task: 'задача',
|
||||
beginInputTip:
|
||||
'Определите входные параметры для доступа в последующих процессах.',
|
||||
query: 'Переменные запроса',
|
||||
queryTip: 'Выберите переменную, которую хотите использовать',
|
||||
agent: 'Агент',
|
||||
addAgent: 'Добавить агента',
|
||||
agentDescription:
|
||||
'Создает агентов с рассуждениями, использованием инструментов и многопользовательским взаимодействием.',
|
||||
maxRecords: 'Макс. записей',
|
||||
@ -1337,6 +1511,10 @@ export default {
|
||||
},
|
||||
goto: 'Ветка неудачи',
|
||||
comment: 'Значение по умолчанию',
|
||||
sqlStatement: 'SQL запрос',
|
||||
sqlStatementTip:
|
||||
'Напишите ваш SQL запрос здесь. Вы можете использовать переменные, чистый SQL или комбинировать оба метода с использованием синтаксиса переменных.',
|
||||
frameworkPrompts: 'Фреймворк',
|
||||
},
|
||||
llmTools: {
|
||||
bad_calculator: {
|
||||
@ -1362,7 +1540,60 @@ export default {
|
||||
editMCP: 'Редактировать MCP',
|
||||
},
|
||||
search: {
|
||||
searchApps: 'Поисковые приложения',
|
||||
createSearch: 'Создать поиск',
|
||||
searchGreeting: 'Чем я могу помочь вам сегодня?',
|
||||
profile: 'Скрыть профиль',
|
||||
locale: 'Локаль',
|
||||
embedCode: 'Код для вставки',
|
||||
id: 'ID',
|
||||
copySuccess: 'Успешно скопировано',
|
||||
welcomeBack: 'С возвращением',
|
||||
searchSettings: 'Настройки поиска',
|
||||
name: 'Название',
|
||||
avatar: 'Аватар',
|
||||
description: 'Описание',
|
||||
datasets: 'Наборы данных',
|
||||
rerankModel: 'Модель реранкинга',
|
||||
AISummary: 'AI-резюме',
|
||||
enableWebSearch: 'Включить веб-поиск',
|
||||
enableRelatedSearch: 'Включить связанный поиск',
|
||||
showQueryMindmap: 'Показать ментальную карту запроса',
|
||||
embedApp: 'Встроить приложение',
|
||||
relatedSearch: 'Связанный поиск',
|
||||
descriptionValue: 'Вы умный ассистент.',
|
||||
okText: 'Сохранить',
|
||||
cancelText: 'Отмена',
|
||||
chooseDataset: 'Сначала выберите набор данных',
|
||||
},
|
||||
language: {
|
||||
english: 'Английский',
|
||||
chinese: 'Китайский',
|
||||
spanish: 'Испанский',
|
||||
french: 'Французский',
|
||||
german: 'Немецкий',
|
||||
japanese: 'Японский',
|
||||
korean: 'Корейский',
|
||||
vietnamese: 'Вьетнамский',
|
||||
russian: 'Русский',
|
||||
},
|
||||
pagination: {
|
||||
total: 'Всего {{total}}',
|
||||
page: '{{page}} /Страница',
|
||||
},
|
||||
dataflowParser: {
|
||||
parseSummary: 'Резюме обработки',
|
||||
parseSummaryTip: 'Обработчик: deepdoc',
|
||||
rerunFromCurrentStep: 'Перезапустить с текущего шага',
|
||||
rerunFromCurrentStepTip: 'Обнаружены изменения. Нажмите для перезапуска.',
|
||||
},
|
||||
dataflow: {
|
||||
parser: 'Обработчик',
|
||||
parserDescription: 'Обработчик',
|
||||
chunker: 'Фрагментатор',
|
||||
chunkerDescription: 'Фрагментатор',
|
||||
tokenizer: 'Токенизатор',
|
||||
tokenizerDescription: 'Токенизатор',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -593,6 +593,9 @@ export default {
|
||||
baseUrl: 'base-url',
|
||||
baseUrlTip:
|
||||
'如果您的 API 密鑰來自 OpenAI,請忽略它。任何其他中間提供商都會提供帶有 API 密鑰的基本 URL。',
|
||||
tongyiBaseUrlTip:
|
||||
'中國用戶無需填寫或使用 https://dashscope.aliyuncs.com/compatible-mode/v1。國際用戶請使用 https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
tongyiBaseUrlPlaceholder: '(僅國際用戶,請參閱提示)',
|
||||
modify: '修改',
|
||||
systemModelSettings: '設定預設模型',
|
||||
chatModel: '聊天模型',
|
||||
|
||||
@ -626,7 +626,7 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于
|
||||
answerTitle: '智能回答',
|
||||
multiTurn: '多轮对话优化',
|
||||
multiTurnTip:
|
||||
'在多轮对话的中,对去知识库查询的问题进行优化。会调用大模型额外消耗token。',
|
||||
'在多轮对话时,对查询问题根据上下文进行优化。会调用大模型额外消耗 token。',
|
||||
howUseId: '如何使用聊天ID?',
|
||||
description: '助理描述',
|
||||
descriptionPlaceholder:
|
||||
@ -714,6 +714,9 @@ General:实体和关系提取提示来自 GitHub - microsoft/graphrag:基于
|
||||
baseUrl: 'Base-Url',
|
||||
baseUrlTip:
|
||||
'如果您的 API 密钥来自 OpenAI,请忽略它。 任何其他中间提供商都会提供带有 API 密钥的基本 URL。',
|
||||
tongyiBaseUrlTip:
|
||||
'对于中国用户,不需要填写或使用 https://dashscope.aliyuncs.com/compatible-mode/v1。对于国际用户,使用 https://dashscope-intl.aliyuncs.com/compatible-mode/v1。',
|
||||
tongyiBaseUrlPlaceholder: '(仅国际用户需要)',
|
||||
modify: '修改',
|
||||
systemModelSettings: '设置默认模型',
|
||||
chatModel: '聊天模型',
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
import { useSetModalState } from '@/hooks/common-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Connection,
|
||||
ConnectionMode,
|
||||
ControlButton,
|
||||
Controls,
|
||||
@ -17,7 +16,7 @@ import {
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { NotebookPen } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChatSheet } from '../chat/chat-sheet';
|
||||
import { AgentBackground } from '../components/background';
|
||||
@ -37,7 +36,10 @@ import {
|
||||
import { useAddNode } from '../hooks/use-add-node';
|
||||
import { useBeforeDelete } from '../hooks/use-before-delete';
|
||||
import { useCacheChatLog } from '../hooks/use-cache-chat-log';
|
||||
import { useConnectionDrag } from '../hooks/use-connection-drag';
|
||||
import { useDropdownPosition } from '../hooks/use-dropdown-position';
|
||||
import { useMoveNote } from '../hooks/use-move-note';
|
||||
import { usePlaceholderManager } from '../hooks/use-placeholder-manager';
|
||||
import { useDropdownManager } from './context';
|
||||
|
||||
import Spotlight from '@/components/spotlight';
|
||||
@ -62,6 +64,7 @@ import { KeywordNode } from './node/keyword-node';
|
||||
import { LogicNode } from './node/logic-node';
|
||||
import { MessageNode } from './node/message-node';
|
||||
import NoteNode from './node/note-node';
|
||||
import { PlaceholderNode } from './node/placeholder-node';
|
||||
import { RelevantNode } from './node/relevant-node';
|
||||
import { RetrievalNode } from './node/retrieval-node';
|
||||
import { RewriteNode } from './node/rewrite-node';
|
||||
@ -73,6 +76,7 @@ export const nodeTypes: NodeTypes = {
|
||||
ragNode: RagNode,
|
||||
categorizeNode: CategorizeNode,
|
||||
beginNode: BeginNode,
|
||||
placeholderNode: PlaceholderNode,
|
||||
relevantNode: RelevantNode,
|
||||
logicNode: LogicNode,
|
||||
noteNode: NoteNode,
|
||||
@ -176,19 +180,36 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
||||
const { visible, hideModal, showModal } = useSetModalState();
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const isConnectedRef = useRef(false);
|
||||
const connectionStartRef = useRef<{
|
||||
nodeId: string;
|
||||
handleId: string;
|
||||
} | null>(null);
|
||||
const { clearActiveDropdown } = useDropdownManager();
|
||||
|
||||
const preventCloseRef = useRef(false);
|
||||
const { removePlaceholderNode, onNodeCreated, setCreatedPlaceholderRef } =
|
||||
usePlaceholderManager(reactFlowInstance);
|
||||
|
||||
const { setActiveDropdown, clearActiveDropdown } = useDropdownManager();
|
||||
const { calculateDropdownPosition } = useDropdownPosition(reactFlowInstance);
|
||||
|
||||
const {
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
handleConnect,
|
||||
getConnectionStartContext,
|
||||
shouldPreventClose,
|
||||
onMove,
|
||||
} = useConnectionDrag(
|
||||
reactFlowInstance,
|
||||
originalOnConnect,
|
||||
showModal,
|
||||
hideModal,
|
||||
setDropdownPosition,
|
||||
setCreatedPlaceholderRef,
|
||||
calculateDropdownPosition,
|
||||
removePlaceholderNode,
|
||||
clearActiveDropdown,
|
||||
);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
hideFormDrawer();
|
||||
if (visible && !preventCloseRef.current) {
|
||||
if (visible && !shouldPreventClose()) {
|
||||
removePlaceholderNode();
|
||||
hideModal();
|
||||
clearActiveDropdown();
|
||||
}
|
||||
@ -199,55 +220,16 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
||||
}, [
|
||||
hideFormDrawer,
|
||||
visible,
|
||||
shouldPreventClose,
|
||||
hideModal,
|
||||
imgVisible,
|
||||
addNoteNode,
|
||||
mouse,
|
||||
hideImage,
|
||||
clearActiveDropdown,
|
||||
removePlaceholderNode,
|
||||
]);
|
||||
|
||||
const onConnect = (connection: Connection) => {
|
||||
originalOnConnect(connection);
|
||||
isConnectedRef.current = true;
|
||||
};
|
||||
|
||||
const OnConnectStart = (event: any, params: any) => {
|
||||
isConnectedRef.current = false;
|
||||
|
||||
if (params && params.nodeId && params.handleId) {
|
||||
connectionStartRef.current = {
|
||||
nodeId: params.nodeId,
|
||||
handleId: params.handleId,
|
||||
};
|
||||
} else {
|
||||
connectionStartRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const OnConnectEnd = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
// Clicking Handle will also trigger OnConnectEnd.
|
||||
// To solve the problem that the operator on the right side added by clicking Handle will overlap with the original operator, this event is blocked here.
|
||||
// TODO: However, a better way is to add both operators in the same way as OnConnectEnd.
|
||||
if (target?.classList.contains('react-flow__handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('clientX' in event && 'clientY' in event) {
|
||||
const { clientX, clientY } = event;
|
||||
setDropdownPosition({ x: clientX, y: clientY });
|
||||
if (!isConnectedRef.current) {
|
||||
setActiveDropdown('drag');
|
||||
showModal();
|
||||
preventCloseRef.current = true;
|
||||
setTimeout(() => {
|
||||
preventCloseRef.current = false;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.canvasWrapper}>
|
||||
<svg
|
||||
@ -278,12 +260,13 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
||||
edges={edges}
|
||||
onEdgesChange={onEdgesChange}
|
||||
fitView
|
||||
onConnect={onConnect}
|
||||
onConnect={handleConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onDrop={onDrop}
|
||||
onConnectStart={OnConnectStart}
|
||||
onConnectEnd={OnConnectEnd}
|
||||
onConnectStart={onConnectStart}
|
||||
onConnectEnd={onConnectEnd}
|
||||
onMove={onMove}
|
||||
onDragOver={onDragOver}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
@ -324,20 +307,24 @@ function AgentCanvas({ drawerVisible, hideDrawer }: IProps) {
|
||||
</ReactFlow>
|
||||
{visible && (
|
||||
<HandleContext.Provider
|
||||
value={{
|
||||
nodeId: connectionStartRef.current?.nodeId || '',
|
||||
id: connectionStartRef.current?.handleId || '',
|
||||
type: 'source',
|
||||
position: Position.Right,
|
||||
isFromConnectionDrag: true,
|
||||
}}
|
||||
value={
|
||||
getConnectionStartContext() || {
|
||||
nodeId: '',
|
||||
id: '',
|
||||
type: 'source',
|
||||
position: Position.Right,
|
||||
isFromConnectionDrag: true,
|
||||
}
|
||||
}
|
||||
>
|
||||
<InnerNextStepDropdown
|
||||
hideModal={() => {
|
||||
removePlaceholderNode();
|
||||
hideModal();
|
||||
clearActiveDropdown();
|
||||
}}
|
||||
position={dropdownPosition}
|
||||
onNodeCreated={onNodeCreated}
|
||||
>
|
||||
<span></span>
|
||||
</InnerNextStepDropdown>
|
||||
|
||||
47
web/src/pages/agent/canvas/node/placeholder-node.tsx
Normal file
47
web/src/pages/agent/canvas/node/placeholder-node.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { NodeProps, Position } from '@xyflow/react';
|
||||
import { Skeleton } from 'antd';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NodeHandleId, Operator } from '../../constant';
|
||||
import OperatorIcon from '../../operator-icon';
|
||||
import { CommonHandle } from './handle';
|
||||
import { LeftHandleStyle } from './handle-icon';
|
||||
import styles from './index.less';
|
||||
import { NodeWrapper } from './node-wrapper';
|
||||
|
||||
function InnerPlaceholderNode({ data, id, selected }: NodeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<NodeWrapper selected={selected}>
|
||||
<CommonHandle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
isConnectable
|
||||
style={LeftHandleStyle}
|
||||
nodeId={id}
|
||||
id={NodeHandleId.End}
|
||||
></CommonHandle>
|
||||
|
||||
<section className="flex items-center gap-2">
|
||||
<OperatorIcon name={data.label as Operator}></OperatorIcon>
|
||||
<div className="truncate text-center font-semibold text-sm">
|
||||
{t(`flow.placeholder`, 'Placeholder')}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={cn(styles.generateParameters, 'flex gap-2 flex-col mt-2')}
|
||||
>
|
||||
<Skeleton active paragraph={{ rows: 2 }} title={false} />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton.Button active size="small" />
|
||||
<Skeleton.Button active size="small" />
|
||||
</div>
|
||||
</section>
|
||||
</NodeWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export const PlaceholderNode = memo(InnerPlaceholderNode);
|
||||
@ -86,6 +86,7 @@ export enum Operator {
|
||||
UserFillUp = 'UserFillUp',
|
||||
StringTransform = 'StringTransform',
|
||||
SearXNG = 'SearXNG',
|
||||
Placeholder = 'Placeholder',
|
||||
}
|
||||
|
||||
export const SwitchLogicOperatorOptions = ['and', 'or'];
|
||||
@ -259,6 +260,10 @@ export const initialRetrievalValues = {
|
||||
type: 'string',
|
||||
value: '',
|
||||
},
|
||||
json: {
|
||||
type: 'Array<Object>',
|
||||
value: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -754,6 +759,11 @@ export const initialTavilyExtractValues = {
|
||||
},
|
||||
};
|
||||
|
||||
export const initialPlaceholderValues = {
|
||||
// Placeholder node doesn't need any specific form values
|
||||
// It's just a visual placeholder
|
||||
};
|
||||
|
||||
export const CategorizeAnchorPointPositions = [
|
||||
{ top: 1, right: 34 },
|
||||
{ top: 8, right: 18 },
|
||||
@ -874,6 +884,7 @@ export const NodeMap = {
|
||||
[Operator.UserFillUp]: 'ragNode',
|
||||
[Operator.StringTransform]: 'ragNode',
|
||||
[Operator.TavilyExtract]: 'ragNode',
|
||||
[Operator.Placeholder]: 'placeholderNode',
|
||||
};
|
||||
|
||||
export enum BeginQueryType {
|
||||
@ -924,3 +935,12 @@ export enum AgentExceptionMethod {
|
||||
Comment = 'comment',
|
||||
Goto = 'goto',
|
||||
}
|
||||
|
||||
export const PLACEHOLDER_NODE_WIDTH = 200;
|
||||
export const PLACEHOLDER_NODE_HEIGHT = 60;
|
||||
export const DROPDOWN_SPACING = 25;
|
||||
export const DROPDOWN_ADDITIONAL_OFFSET = 50;
|
||||
export const HALF_PLACEHOLDER_NODE_WIDTH = PLACEHOLDER_NODE_WIDTH / 2;
|
||||
export const HALF_PLACEHOLDER_NODE_HEIGHT =
|
||||
PLACEHOLDER_NODE_HEIGHT + DROPDOWN_SPACING + DROPDOWN_ADDITIONAL_OFFSET;
|
||||
export const PREVENT_CLOSE_DELAY = 300;
|
||||
|
||||
@ -83,6 +83,10 @@ function RetrievalForm({ node }: INextOperatorForm) {
|
||||
title: 'formalized_content',
|
||||
type: initialRetrievalValues.outputs.formalized_content.type,
|
||||
},
|
||||
{
|
||||
title: 'json',
|
||||
type: initialRetrievalValues.outputs.json.type,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
|
||||
|
||||
@ -336,6 +336,7 @@ export function useAddNode(reactFlowInstance?: ReactFlowInstance<any, any>) {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
draggable: type === Operator.Placeholder ? false : undefined,
|
||||
data: {
|
||||
label: `${type}`,
|
||||
name: generateNodeNamesWithIncreasingIndex(
|
||||
|
||||
200
web/src/pages/agent/hooks/use-connection-drag.ts
Normal file
200
web/src/pages/agent/hooks/use-connection-drag.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { Connection, Position } from '@xyflow/react';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useDropdownManager } from '../canvas/context';
|
||||
import { Operator, PREVENT_CLOSE_DELAY } from '../constant';
|
||||
import { useAddNode } from './use-add-node';
|
||||
|
||||
interface ConnectionStartParams {
|
||||
nodeId: string;
|
||||
handleId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection drag management Hook
|
||||
* Responsible for handling connection drag start and end logic
|
||||
*/
|
||||
export const useConnectionDrag = (
|
||||
reactFlowInstance: any,
|
||||
onConnect: (connection: Connection) => void,
|
||||
showModal: () => void,
|
||||
hideModal: () => void,
|
||||
setDropdownPosition: (position: { x: number; y: number }) => void,
|
||||
setCreatedPlaceholderRef: (nodeId: string | null) => void,
|
||||
calculateDropdownPosition: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => { x: number; y: number },
|
||||
removePlaceholderNode: () => void,
|
||||
clearActiveDropdown: () => void,
|
||||
) => {
|
||||
// Reference for whether connection is established
|
||||
const isConnectedRef = useRef(false);
|
||||
// Reference for connection start parameters
|
||||
const connectionStartRef = useRef<ConnectionStartParams | null>(null);
|
||||
// Reference to prevent immediate close
|
||||
const preventCloseRef = useRef(false);
|
||||
// Reference to track mouse position for click detection
|
||||
const mouseStartPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const { addCanvasNode } = useAddNode(reactFlowInstance);
|
||||
const { setActiveDropdown } = useDropdownManager();
|
||||
|
||||
/**
|
||||
* Connection start handler function
|
||||
*/
|
||||
const onConnectStart = useCallback((event: any, params: any) => {
|
||||
isConnectedRef.current = false;
|
||||
|
||||
// Record mouse start position to detect click vs drag
|
||||
if ('clientX' in event && 'clientY' in event) {
|
||||
mouseStartPosRef.current = { x: event.clientX, y: event.clientY };
|
||||
}
|
||||
|
||||
if (params && params.nodeId && params.handleId) {
|
||||
connectionStartRef.current = {
|
||||
nodeId: params.nodeId,
|
||||
handleId: params.handleId,
|
||||
};
|
||||
} else {
|
||||
connectionStartRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Connection end handler function
|
||||
*/
|
||||
const onConnectEnd = useCallback(
|
||||
(event: MouseEvent | TouchEvent) => {
|
||||
if ('clientX' in event && 'clientY' in event) {
|
||||
const { clientX, clientY } = event;
|
||||
setDropdownPosition({ x: clientX, y: clientY });
|
||||
|
||||
if (!isConnectedRef.current && connectionStartRef.current) {
|
||||
// Check mouse movement distance to distinguish click from drag
|
||||
let isHandleClick = false;
|
||||
if (mouseStartPosRef.current) {
|
||||
const movementDistance = Math.sqrt(
|
||||
Math.pow(clientX - mouseStartPosRef.current.x, 2) +
|
||||
Math.pow(clientY - mouseStartPosRef.current.y, 2),
|
||||
);
|
||||
isHandleClick = movementDistance < 5; // Consider clicks within 5px as handle clicks
|
||||
}
|
||||
|
||||
if (isHandleClick) {
|
||||
connectionStartRef.current = null;
|
||||
mouseStartPosRef.current = null;
|
||||
return;
|
||||
}
|
||||
// Create placeholder node and establish connection
|
||||
const mockEvent = { clientX, clientY };
|
||||
const contextData = {
|
||||
nodeId: connectionStartRef.current.nodeId,
|
||||
id: connectionStartRef.current.handleId,
|
||||
type: 'source' as const,
|
||||
position: Position.Right,
|
||||
isFromConnectionDrag: true,
|
||||
};
|
||||
|
||||
// Use Placeholder operator to create node
|
||||
const newNodeId = addCanvasNode(
|
||||
Operator.Placeholder,
|
||||
contextData,
|
||||
)(mockEvent);
|
||||
|
||||
// Record the created placeholder node ID
|
||||
if (newNodeId) {
|
||||
setCreatedPlaceholderRef(newNodeId);
|
||||
}
|
||||
|
||||
// Calculate placeholder node position and display dropdown menu
|
||||
if (newNodeId && reactFlowInstance) {
|
||||
const dropdownScreenPosition = calculateDropdownPosition(
|
||||
clientX,
|
||||
clientY,
|
||||
);
|
||||
|
||||
setDropdownPosition({
|
||||
x: dropdownScreenPosition.x,
|
||||
y: dropdownScreenPosition.y,
|
||||
});
|
||||
|
||||
setActiveDropdown('drag');
|
||||
showModal();
|
||||
preventCloseRef.current = true;
|
||||
setTimeout(() => {
|
||||
preventCloseRef.current = false;
|
||||
}, PREVENT_CLOSE_DELAY);
|
||||
}
|
||||
|
||||
// Reset connection state
|
||||
connectionStartRef.current = null;
|
||||
mouseStartPosRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
setDropdownPosition,
|
||||
addCanvasNode,
|
||||
setCreatedPlaceholderRef,
|
||||
reactFlowInstance,
|
||||
calculateDropdownPosition,
|
||||
setActiveDropdown,
|
||||
showModal,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Connection establishment handler function
|
||||
*/
|
||||
const handleConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
onConnect(connection);
|
||||
isConnectedRef.current = true;
|
||||
},
|
||||
[onConnect],
|
||||
);
|
||||
|
||||
/**
|
||||
* Get connection start context data
|
||||
*/
|
||||
const getConnectionStartContext = useCallback(() => {
|
||||
if (!connectionStartRef.current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId: connectionStartRef.current.nodeId,
|
||||
id: connectionStartRef.current.handleId,
|
||||
type: 'source' as const,
|
||||
position: Position.Right,
|
||||
isFromConnectionDrag: true,
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if close should be prevented
|
||||
*/
|
||||
const shouldPreventClose = useCallback(() => {
|
||||
return preventCloseRef.current;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle canvas move/zoom events
|
||||
* Hide dropdown and remove placeholder when user scrolls or moves canvas
|
||||
*/
|
||||
const onMove = useCallback(() => {
|
||||
// Clean up placeholder and dropdown when canvas moves/zooms
|
||||
removePlaceholderNode();
|
||||
hideModal();
|
||||
clearActiveDropdown();
|
||||
}, [removePlaceholderNode, hideModal, clearActiveDropdown]);
|
||||
|
||||
return {
|
||||
onConnectStart,
|
||||
onConnectEnd,
|
||||
handleConnect,
|
||||
getConnectionStartContext,
|
||||
shouldPreventClose,
|
||||
onMove,
|
||||
};
|
||||
};
|
||||
106
web/src/pages/agent/hooks/use-dropdown-position.ts
Normal file
106
web/src/pages/agent/hooks/use-dropdown-position.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
HALF_PLACEHOLDER_NODE_HEIGHT,
|
||||
HALF_PLACEHOLDER_NODE_WIDTH,
|
||||
} from '../constant';
|
||||
|
||||
/**
|
||||
* Dropdown position calculation Hook
|
||||
* Responsible for calculating dropdown menu position relative to placeholder node
|
||||
*/
|
||||
export const useDropdownPosition = (reactFlowInstance: any) => {
|
||||
/**
|
||||
* Calculate dropdown menu position
|
||||
* @param clientX Mouse click screen X coordinate
|
||||
* @param clientY Mouse click screen Y coordinate
|
||||
* @returns Dropdown menu screen coordinates
|
||||
*/
|
||||
const calculateDropdownPosition = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!reactFlowInstance) {
|
||||
return { x: clientX, y: clientY };
|
||||
}
|
||||
|
||||
// Convert screen coordinates to flow coordinates
|
||||
const placeholderNodePosition = reactFlowInstance.screenToFlowPosition({
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
});
|
||||
|
||||
// Calculate dropdown position in flow coordinate system
|
||||
const dropdownFlowPosition = {
|
||||
x: placeholderNodePosition.x - HALF_PLACEHOLDER_NODE_WIDTH, // Placeholder node left-aligned offset
|
||||
y: placeholderNodePosition.y + HALF_PLACEHOLDER_NODE_HEIGHT, // Placeholder node height plus spacing
|
||||
};
|
||||
|
||||
// Convert flow coordinates back to screen coordinates
|
||||
const dropdownScreenPosition =
|
||||
reactFlowInstance.flowToScreenPosition(dropdownFlowPosition);
|
||||
|
||||
return {
|
||||
x: dropdownScreenPosition.x,
|
||||
y: dropdownScreenPosition.y,
|
||||
};
|
||||
},
|
||||
[reactFlowInstance],
|
||||
);
|
||||
|
||||
/**
|
||||
* Calculate placeholder node flow coordinate position
|
||||
* @param clientX Mouse click screen X coordinate
|
||||
* @param clientY Mouse click screen Y coordinate
|
||||
* @returns Placeholder node flow coordinates
|
||||
*/
|
||||
const getPlaceholderNodePosition = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
if (!reactFlowInstance) {
|
||||
return { x: clientX, y: clientY };
|
||||
}
|
||||
|
||||
return reactFlowInstance.screenToFlowPosition({
|
||||
x: clientX,
|
||||
y: clientY,
|
||||
});
|
||||
},
|
||||
[reactFlowInstance],
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert flow coordinates to screen coordinates
|
||||
* @param flowPosition Flow coordinates
|
||||
* @returns Screen coordinates
|
||||
*/
|
||||
const flowToScreenPosition = useCallback(
|
||||
(flowPosition: { x: number; y: number }) => {
|
||||
if (!reactFlowInstance) {
|
||||
return flowPosition;
|
||||
}
|
||||
|
||||
return reactFlowInstance.flowToScreenPosition(flowPosition);
|
||||
},
|
||||
[reactFlowInstance],
|
||||
);
|
||||
|
||||
/**
|
||||
* Convert screen coordinates to flow coordinates
|
||||
* @param screenPosition Screen coordinates
|
||||
* @returns Flow coordinates
|
||||
*/
|
||||
const screenToFlowPosition = useCallback(
|
||||
(screenPosition: { x: number; y: number }) => {
|
||||
if (!reactFlowInstance) {
|
||||
return screenPosition;
|
||||
}
|
||||
|
||||
return reactFlowInstance.screenToFlowPosition(screenPosition);
|
||||
},
|
||||
[reactFlowInstance],
|
||||
);
|
||||
|
||||
return {
|
||||
calculateDropdownPosition,
|
||||
getPlaceholderNodePosition,
|
||||
flowToScreenPosition,
|
||||
screenToFlowPosition,
|
||||
};
|
||||
};
|
||||
141
web/src/pages/agent/hooks/use-placeholder-manager.ts
Normal file
141
web/src/pages/agent/hooks/use-placeholder-manager.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import useGraphStore from '../store';
|
||||
|
||||
/**
|
||||
* Placeholder node management Hook
|
||||
* Responsible for managing placeholder node creation, deletion, and state tracking
|
||||
*/
|
||||
export const usePlaceholderManager = (reactFlowInstance: any) => {
|
||||
// Reference to the created placeholder node ID
|
||||
const createdPlaceholderRef = useRef<string | null>(null);
|
||||
// Flag indicating whether user has selected a node
|
||||
const userSelectedNodeRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Function to remove placeholder node
|
||||
* Called when user clicks blank area or cancels operation
|
||||
*/
|
||||
const removePlaceholderNode = useCallback(() => {
|
||||
if (
|
||||
createdPlaceholderRef.current &&
|
||||
reactFlowInstance &&
|
||||
!userSelectedNodeRef.current
|
||||
) {
|
||||
const { nodes, edges } = useGraphStore.getState();
|
||||
|
||||
// Remove edges related to placeholder
|
||||
const edgesToRemove = edges.filter(
|
||||
(edge) =>
|
||||
edge.target === createdPlaceholderRef.current ||
|
||||
edge.source === createdPlaceholderRef.current,
|
||||
);
|
||||
|
||||
// Remove placeholder node
|
||||
const nodesToRemove = nodes.filter(
|
||||
(node) => node.id === createdPlaceholderRef.current,
|
||||
);
|
||||
|
||||
if (nodesToRemove.length > 0 || edgesToRemove.length > 0) {
|
||||
reactFlowInstance.deleteElements({
|
||||
nodes: nodesToRemove,
|
||||
edges: edgesToRemove,
|
||||
});
|
||||
}
|
||||
|
||||
createdPlaceholderRef.current = null;
|
||||
}
|
||||
|
||||
// Reset user selection flag
|
||||
userSelectedNodeRef.current = false;
|
||||
}, [reactFlowInstance]);
|
||||
|
||||
/**
|
||||
* User node selection callback
|
||||
* Called when user selects a node type from dropdown menu
|
||||
*/
|
||||
const onNodeCreated = useCallback(
|
||||
(newNodeId: string) => {
|
||||
// First establish connection between new node and source, then delete placeholder
|
||||
if (createdPlaceholderRef.current && reactFlowInstance) {
|
||||
const { nodes, edges, addEdge, updateNode } = useGraphStore.getState();
|
||||
|
||||
// Find placeholder node to get its position
|
||||
const placeholderNode = nodes.find(
|
||||
(node) => node.id === createdPlaceholderRef.current,
|
||||
);
|
||||
|
||||
// Find placeholder-related connection and get source node info
|
||||
const placeholderEdge = edges.find(
|
||||
(edge) => edge.target === createdPlaceholderRef.current,
|
||||
);
|
||||
|
||||
// Update new node position to match placeholder position
|
||||
if (placeholderNode) {
|
||||
const newNode = nodes.find((node) => node.id === newNodeId);
|
||||
if (newNode) {
|
||||
updateNode({
|
||||
...newNode,
|
||||
position: placeholderNode.position,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (placeholderEdge) {
|
||||
// Establish connection between new node and source node
|
||||
addEdge({
|
||||
source: placeholderEdge.source,
|
||||
target: newNodeId,
|
||||
sourceHandle: placeholderEdge.sourceHandle || null,
|
||||
targetHandle: placeholderEdge.targetHandle || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Remove placeholder node and related connections
|
||||
const edgesToRemove = edges.filter(
|
||||
(edge) =>
|
||||
edge.target === createdPlaceholderRef.current ||
|
||||
edge.source === createdPlaceholderRef.current,
|
||||
);
|
||||
|
||||
const nodesToRemove = nodes.filter(
|
||||
(node) => node.id === createdPlaceholderRef.current,
|
||||
);
|
||||
|
||||
if (nodesToRemove.length > 0 || edgesToRemove.length > 0) {
|
||||
reactFlowInstance.deleteElements({
|
||||
nodes: nodesToRemove,
|
||||
edges: edgesToRemove,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark that user has selected a node
|
||||
userSelectedNodeRef.current = true;
|
||||
createdPlaceholderRef.current = null;
|
||||
},
|
||||
[reactFlowInstance],
|
||||
);
|
||||
|
||||
/**
|
||||
* Set the created placeholder node ID
|
||||
*/
|
||||
const setCreatedPlaceholderRef = useCallback((nodeId: string | null) => {
|
||||
createdPlaceholderRef.current = nodeId;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Reset user selection flag
|
||||
*/
|
||||
const resetUserSelectedFlag = useCallback(() => {
|
||||
userSelectedNodeRef.current = false;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
removePlaceholderNode,
|
||||
onNodeCreated,
|
||||
setCreatedPlaceholderRef,
|
||||
resetUserSelectedFlag,
|
||||
createdPlaceholderRef: createdPlaceholderRef.current,
|
||||
userSelectedNodeRef: userSelectedNodeRef.current,
|
||||
};
|
||||
};
|
||||
@ -61,7 +61,7 @@ export const useShowSingleDebugDrawer = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const ExcludedNodes = [Operator.Note];
|
||||
const ExcludedNodes = [Operator.Note, Operator.Placeholder];
|
||||
|
||||
export function useShowDrawer({
|
||||
drawerVisible,
|
||||
|
||||
@ -2133,12 +2133,16 @@ export const QWeatherTimePeriodOptions = [
|
||||
'30d',
|
||||
];
|
||||
|
||||
export const ExeSQLOptions = ['mysql', 'postgres', 'mariadb', 'mssql'].map(
|
||||
(x) => ({
|
||||
label: upperFirst(x),
|
||||
value: x,
|
||||
}),
|
||||
);
|
||||
export const ExeSQLOptions = [
|
||||
'mysql',
|
||||
'postgres',
|
||||
'mariadb',
|
||||
'mssql',
|
||||
'IBM DB2',
|
||||
].map((x) => ({
|
||||
label: upperFirst(x),
|
||||
value: x,
|
||||
}));
|
||||
|
||||
export const WenCaiQueryTypeOptions = [
|
||||
'stock',
|
||||
|
||||
0
web/src/pages/data-flow/options.ts
Normal file
0
web/src/pages/data-flow/options.ts
Normal file
@ -1,6 +1,6 @@
|
||||
import { ConfirmDeleteDialog } from '@/components/confirm-delete-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useFetchKnowledgeGraph } from '@/hooks/knowledge-hooks';
|
||||
import { useFetchKnowledgeGraph } from '@/hooks/use-knowledge-request';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useRemoveKnowledgeGraph } from '@/hooks/knowledge-hooks';
|
||||
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
|
||||
import { useRemoveKnowledgeGraph } from '@/hooks/use-knowledge-request';
|
||||
import { useCallback } from 'react';
|
||||
import { useParams } from 'umi';
|
||||
|
||||
|
||||
@ -70,16 +70,15 @@ export function EmbeddingModelItem() {
|
||||
name={'embd_id'}
|
||||
render={({ field }) => (
|
||||
<FormItem className=" items-center space-y-0 ">
|
||||
<div className="">
|
||||
<div className="flex items-center">
|
||||
<FormLabel
|
||||
required
|
||||
tooltip={t('embeddingModelTip')}
|
||||
className="text-sm whitespace-wrap "
|
||||
className="text-sm whitespace-wrap w-1/4"
|
||||
>
|
||||
<span className="text-destructive mr-1"> *</span>
|
||||
{t('embeddingModel')}
|
||||
</FormLabel>
|
||||
<div className="text-muted-foreground">
|
||||
<div className="text-muted-foreground w-3/4">
|
||||
<FormControl>
|
||||
<RAGFlowSelect
|
||||
{...field}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { DataFlowSelect } from '@/components/data-pipeline-select';
|
||||
import { ButtonLoading } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
|
||||
@ -31,7 +31,12 @@ export function ChatPromptEngine() {
|
||||
<FormItem>
|
||||
<FormLabel>{t('system')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field} rows={8} />
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={8}
|
||||
placeholder={t('messagePlaceholder')}
|
||||
className="overflow-y-auto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
27
web/src/pages/next-chats/widget/index.tsx
Normal file
27
web/src/pages/next-chats/widget/index.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import FloatingChatWidget from '@/components/floating-chat-widget';
|
||||
|
||||
const ChatWidget = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'transparent',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
html, body {
|
||||
background: transparent !important;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#root {
|
||||
background: transparent !important;
|
||||
}
|
||||
`}</style>
|
||||
<FloatingChatWidget />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatWidget;
|
||||
@ -2,7 +2,7 @@ import { IModalManagerChildrenProps } from '@/components/modal-manager';
|
||||
import { LLMFactory } from '@/constants/llm';
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { Form, Input, Modal } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { KeyboardEventHandler, useCallback, useEffect } from 'react';
|
||||
import { ApiKeyPostBody } from '../../interface';
|
||||
|
||||
interface IProps extends Omit<IModalManagerChildrenProps, 'showModal'> {
|
||||
@ -20,7 +20,11 @@ type FieldType = {
|
||||
group_id?: string;
|
||||
};
|
||||
|
||||
const modelsWithBaseUrl = [LLMFactory.OpenAI, LLMFactory.AzureOpenAI];
|
||||
const modelsWithBaseUrl = [
|
||||
LLMFactory.OpenAI,
|
||||
LLMFactory.AzureOpenAI,
|
||||
LLMFactory.TongYiQianWen,
|
||||
];
|
||||
|
||||
const ApiKeyModal = ({
|
||||
visible,
|
||||
@ -34,17 +38,20 @@ const ApiKeyModal = ({
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslate('setting');
|
||||
|
||||
const handleOk = async () => {
|
||||
const handleOk = useCallback(async () => {
|
||||
const ret = await form.validateFields();
|
||||
|
||||
return onOk(ret);
|
||||
};
|
||||
}, [form, onOk]);
|
||||
|
||||
const handleKeyDown = async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await handleOk();
|
||||
}
|
||||
};
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
|
||||
async (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
await handleOk();
|
||||
}
|
||||
},
|
||||
[handleOk],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
@ -78,13 +85,33 @@ const ApiKeyModal = ({
|
||||
<Input onKeyDown={handleKeyDown} />
|
||||
</Form.Item>
|
||||
{modelsWithBaseUrl.some((x) => x === llmFactory) && (
|
||||
<Form.Item<FieldType>
|
||||
label={t('baseUrl')}
|
||||
name="base_url"
|
||||
tooltip={
|
||||
llmFactory === LLMFactory.TongYiQianWen
|
||||
? t('tongyiBaseUrlTip')
|
||||
: t('baseUrlTip')
|
||||
}
|
||||
>
|
||||
<Input
|
||||
placeholder={
|
||||
llmFactory === LLMFactory.TongYiQianWen
|
||||
? t('tongyiBaseUrlPlaceholder')
|
||||
: 'https://api.openai.com/v1'
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
{llmFactory?.toLowerCase() === 'Anthropic'.toLowerCase() && (
|
||||
<Form.Item<FieldType>
|
||||
label={t('baseUrl')}
|
||||
name="base_url"
|
||||
tooltip={t('baseUrlTip')}
|
||||
>
|
||||
<Input
|
||||
placeholder="https://api.openai.com/v1"
|
||||
placeholder="https://api.anthropic.com/v1"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@ -41,6 +41,7 @@ export enum Routes {
|
||||
AgentLogPage = '/agent-log-page',
|
||||
AgentShare = '/agent/share',
|
||||
ChatShare = `${Chats}/share`,
|
||||
ChatWidget = `${Chats}/widget`,
|
||||
UserSetting = '/user-setting',
|
||||
DataFlows = '/data-flows',
|
||||
DataFlow = '/data-flow',
|
||||
@ -75,6 +76,11 @@ const routes = [
|
||||
component: `@/pages${Routes.AgentShare}`,
|
||||
layout: false,
|
||||
},
|
||||
{
|
||||
path: Routes.ChatWidget,
|
||||
component: `@/pages${Routes.ChatWidget}`,
|
||||
layout: false,
|
||||
},
|
||||
{
|
||||
path: Routes.Home,
|
||||
component: '@/layouts',
|
||||
|
||||
Reference in New Issue
Block a user