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:
Kevin Hu
2025-09-30 13:13:15 +08:00
committed by GitHub
parent 4d6ff672eb
commit 20b577a72c
201 changed files with 7929 additions and 1110 deletions

View File

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

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

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

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

View File

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

View File

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

View File

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

View File

@ -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',

View File

@ -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,
): {

View File

@ -55,7 +55,7 @@ export const translationTable = createTranslationTable(
[
'English',
'Vietnamese',
'Rus',
'ru',
'Spanish',
'zh',
'zh-TRADITIONAL',

View File

@ -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',

View File

@ -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 users request.
2. Decompose it into logical subtasks.
3. Execute each subtask step by step, reasoning transparently.
4. Validate accuracy and consistency.
1. Understand the users 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',

View File

@ -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',

View File

@ -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",

View File

@ -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',

View File

@ -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: 'チャットモデル',

View File

@ -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',

View File

@ -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: 'Токенизатор',
},
},
};

View File

@ -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: '聊天模型',

View File

@ -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: '聊天模型',

View File

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

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

View File

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

View File

@ -83,6 +83,10 @@ function RetrievalForm({ node }: INextOperatorForm) {
title: 'formalized_content',
type: initialRetrievalValues.outputs.formalized_content.type,
},
{
title: 'json',
type: initialRetrievalValues.outputs.json.type,
},
];
}, []);

View File

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

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

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

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

View File

@ -61,7 +61,7 @@ export const useShowSingleDebugDrawer = () => {
};
};
const ExcludedNodes = [Operator.Note];
const ExcludedNodes = [Operator.Note, Operator.Placeholder];
export function useShowDrawer({
drawerVisible,

View File

@ -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',

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import { DataFlowSelect } from '@/components/data-pipeline-select';
import { ButtonLoading } from '@/components/ui/button';
import {
Dialog,

View File

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

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

View File

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

View File

@ -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',