mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1a130d52d | |||
| 16bcb907af |
@@ -280,6 +280,7 @@
|
||||
"pin": "Anheften",
|
||||
"pinOff": "Anheften aufheben",
|
||||
"privacy": "Datenschutzrichtlinie",
|
||||
"quote": "Zitieren",
|
||||
"regenerate": "Neu generieren",
|
||||
"releaseNotes": "Versionsdetails",
|
||||
"rename": "Umbenennen",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"pin": "Pin",
|
||||
"pinOff": "Unpin",
|
||||
"privacy": "Privacy Policy",
|
||||
"quote": "Quote",
|
||||
"regenerate": "Regenerate",
|
||||
"releaseNotes": "Version Details",
|
||||
"rename": "Rename",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"pin": "Épingler",
|
||||
"pinOff": "Désactiver l'épinglage",
|
||||
"privacy": "Politique de confidentialité",
|
||||
"quote": "Citer",
|
||||
"regenerate": "Régénérer",
|
||||
"releaseNotes": "Détails de la version",
|
||||
"rename": "Renommer",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"pin": "ピン留め",
|
||||
"pinOff": "ピン留め解除",
|
||||
"privacy": "プライバシーポリシー",
|
||||
"quote": "引用",
|
||||
"regenerate": "再生成",
|
||||
"releaseNotes": "リリースノート",
|
||||
"rename": "名前を変更",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"pin": "고정",
|
||||
"pinOff": "고정 해제",
|
||||
"privacy": "개인정보 보호 정책",
|
||||
"quote": "인용",
|
||||
"regenerate": "재생성",
|
||||
"releaseNotes": "버전 세부정보",
|
||||
"rename": "이름 바꾸기",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"pin": "置顶",
|
||||
"pinOff": "取消置顶",
|
||||
"privacy": "隐私政策",
|
||||
"quote": "引用",
|
||||
"regenerate": "重新生成",
|
||||
"releaseNotes": "版本详情",
|
||||
"rename": "重命名",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"pin": "置頂",
|
||||
"pinOff": "取消置頂",
|
||||
"privacy": "隱私政策",
|
||||
"quote": "引用",
|
||||
"regenerate": "重新生成",
|
||||
"releaseNotes": "版本詳細",
|
||||
"rename": "重新命名",
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { pino } from '@/libs/logger';
|
||||
import { createLambdaContext } from '@/libs/trpc/lambda/context';
|
||||
import { desktopRouter } from '@/server/routers/desktop';
|
||||
|
||||
const handler = (req: NextRequest) =>
|
||||
fetchRequestHandler({
|
||||
/**
|
||||
* @link https://trpc.io/docs/v11/context
|
||||
*/
|
||||
createContext: () => createLambdaContext(req),
|
||||
|
||||
endpoint: '/trpc/desktop',
|
||||
|
||||
onError: ({ error, path, type }) => {
|
||||
pino.info(`Error in tRPC handler (tools) on path: ${path}, type: ${type}`);
|
||||
console.error(error);
|
||||
},
|
||||
|
||||
req,
|
||||
router: desktopRouter,
|
||||
});
|
||||
|
||||
export { handler as GET, handler as POST };
|
||||
@@ -1,89 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, SideNav } from '@lobehub/ui';
|
||||
import { Cog, DatabaseIcon } from 'lucide-react';
|
||||
import { memo, useState } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
import { BRANDING_NAME } from '@/const/branding';
|
||||
import PostgresViewer from '@/features/DevPanel/PostgresViewer';
|
||||
import SystemInspector from '@/features/DevPanel/SystemInspector';
|
||||
import { useStyles } from '@/features/DevPanel/features/FloatPanel';
|
||||
import { electronStylish } from '@/styles/electron';
|
||||
|
||||
const DevTools = memo(() => {
|
||||
const { styles, theme, cx } = useStyles();
|
||||
|
||||
const items = [
|
||||
{
|
||||
children: <PostgresViewer />,
|
||||
icon: <DatabaseIcon size={16} />,
|
||||
key: 'Postgres Viewer',
|
||||
},
|
||||
{
|
||||
children: <SystemInspector />,
|
||||
icon: <Cog size={16} />,
|
||||
key: 'System Status',
|
||||
},
|
||||
];
|
||||
|
||||
const [tab, setTab] = useState<string>(items[0].key);
|
||||
|
||||
return (
|
||||
<Flexbox height={'100%'} style={{ overflow: 'hidden', position: 'relative' }} width={'100%'}>
|
||||
<Flexbox
|
||||
align={'center'}
|
||||
className={cx(`panel-drag-handle`, styles.header, electronStylish.draggable)}
|
||||
horizontal
|
||||
justify={'center'}
|
||||
>
|
||||
<Flexbox align={'baseline'} gap={6} horizontal>
|
||||
<b>{BRANDING_NAME} Dev Tools</b>
|
||||
<span style={{ color: theme.colorTextDescription }}>/</span>
|
||||
<span style={{ color: theme.colorTextDescription }}>{tab}</span>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<Flexbox
|
||||
height={'100%'}
|
||||
horizontal
|
||||
style={{ background: theme.colorBgLayout, overflow: 'hidden', position: 'relative' }}
|
||||
width={'100%'}
|
||||
>
|
||||
<SideNav
|
||||
bottomActions={[]}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
width: 48,
|
||||
}}
|
||||
topActions={items.map((item) => (
|
||||
<ActionIcon
|
||||
active={tab === item.key}
|
||||
icon={item.icon}
|
||||
key={item.key}
|
||||
onClick={() => setTab(item.key)}
|
||||
title={item.key}
|
||||
tooltipProps={{
|
||||
placement: 'right',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
/>
|
||||
{items.map((item) => (
|
||||
<Flexbox
|
||||
flex={1}
|
||||
height={'100%'}
|
||||
key={item.key}
|
||||
style={{
|
||||
display: tab === item.key ? 'flex' : 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{item.children}
|
||||
</Flexbox>
|
||||
))}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default DevTools;
|
||||
@@ -1,31 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { isDesktop } from '@/const/version';
|
||||
import GlobalLayout from '@/layout/GlobalProvider';
|
||||
import { ServerConfigStoreProvider } from '@/store/serverConfig/Provider';
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const RootLayout = async ({ children }: RootLayoutProps) => {
|
||||
if (!isDesktop) return notFound();
|
||||
|
||||
return (
|
||||
<html dir="ltr" suppressHydrationWarning>
|
||||
<body>
|
||||
<NuqsAdapter>
|
||||
<ServerConfigStoreProvider>
|
||||
<GlobalLayout appearance={'auto'} isMobile={false} locale={''}>
|
||||
{children}
|
||||
</GlobalLayout>
|
||||
</ServerConfigStoreProvider>
|
||||
</NuqsAdapter>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default RootLayout;
|
||||
@@ -49,6 +49,7 @@ const Actions = memo<ActionsProps>(({ id, inPortalThread, index }) => {
|
||||
resendThreadMessage,
|
||||
delAndResendThreadMessage,
|
||||
toggleMessageEditing,
|
||||
updateInputMessage,
|
||||
] = useChatStore((s) => [
|
||||
s.deleteMessage,
|
||||
s.regenerateMessage,
|
||||
@@ -60,6 +61,7 @@ const Actions = memo<ActionsProps>(({ id, inPortalThread, index }) => {
|
||||
s.resendThreadMessage,
|
||||
s.delAndResendThreadMessage,
|
||||
s.toggleMessageEditing,
|
||||
s.updateInputMessage,
|
||||
]);
|
||||
const { message } = App.useApp();
|
||||
const virtuosoRef = use(VirtuosoContext);
|
||||
@@ -67,12 +69,22 @@ const Actions = memo<ActionsProps>(({ id, inPortalThread, index }) => {
|
||||
const [showShareModal, setShareModal] = useState(false);
|
||||
|
||||
const handleActionClick = useCallback(
|
||||
async (action: ActionIconGroupEvent) => {
|
||||
async (action: ActionIconGroupEvent & { selectedText?: string }) => {
|
||||
switch (action.key) {
|
||||
case 'edit': {
|
||||
toggleMessageEditing(id, true);
|
||||
|
||||
virtuosoRef?.current?.scrollIntoView({ align: 'start', behavior: 'auto', index });
|
||||
break;
|
||||
}
|
||||
case 'quote': {
|
||||
if (action.selectedText) {
|
||||
const currentInput = useChatStore.getState().inputMessage;
|
||||
const quotedText = `> ${action.selectedText.replaceAll('\n', '\n> ')}\n\n`;
|
||||
updateInputMessage(currentInput + quotedText);
|
||||
message.success(t('quoteSuccess', { defaultValue: 'Text quoted successfully' }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!item) return;
|
||||
@@ -136,7 +148,7 @@ const Actions = memo<ActionsProps>(({ id, inPortalThread, index }) => {
|
||||
translateMessage(id, lang);
|
||||
}
|
||||
},
|
||||
[item],
|
||||
[item, updateInputMessage, t, message],
|
||||
);
|
||||
|
||||
const RenderFunction = renderActions[(item?.role || '') as MessageRoleType] ?? ActionsBar;
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { ActionIcon } from '@lobehub/ui';
|
||||
import { Dropdown } from 'antd';
|
||||
import { createStyles, css, cx } from 'antd-style';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useCustomActions } from '../../Actions/customAction';
|
||||
import { useChatListActionsBar } from '../../hooks/useChatListActionsBar';
|
||||
|
||||
const translateStyle = css`
|
||||
.ant-dropdown-menu-sub {
|
||||
overflow-y: scroll;
|
||||
max-height: 400px;
|
||||
}
|
||||
`;
|
||||
|
||||
const useStyles = createStyles(({ css }) => ({
|
||||
contextMenu: css`
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
min-width: 160px;
|
||||
|
||||
.ant-dropdown-menu {
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
|
||||
}
|
||||
`,
|
||||
trigger: css`
|
||||
pointer-events: none;
|
||||
|
||||
position: fixed;
|
||||
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
opacity: 0;
|
||||
`,
|
||||
}));
|
||||
|
||||
interface ContextMenuProps {
|
||||
onMenuClick: (action: any) => void;
|
||||
position: { x: number; y: number };
|
||||
role?: string;
|
||||
selectedText?: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const ContextMenu = memo<ContextMenuProps>(
|
||||
({ visible, position, selectedText, role, onMenuClick }) => {
|
||||
const { styles } = useStyles();
|
||||
const { t } = useTranslation('common');
|
||||
const { regenerate, edit, copy, del, branching, delAndRegenerate, share, quote } =
|
||||
useChatListActionsBar();
|
||||
const { translate, tts } = useCustomActions();
|
||||
|
||||
const renderIcon = useCallback((IconComponent: any) => {
|
||||
return <ActionIcon icon={<IconComponent size={16} />} size={'small'} />;
|
||||
}, []);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: any[] = [];
|
||||
items.push(
|
||||
{
|
||||
...quote,
|
||||
icon: renderIcon(quote.icon),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
icon: renderIcon(edit.icon),
|
||||
key: 'edit',
|
||||
label: edit.label,
|
||||
},
|
||||
{
|
||||
icon: renderIcon(copy.icon),
|
||||
key: 'copy',
|
||||
label: copy.label,
|
||||
},
|
||||
{
|
||||
disabled: (branching as any).disable,
|
||||
icon: renderIcon(branching.icon),
|
||||
key: 'branching',
|
||||
label: branching.label,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
icon: renderIcon(tts.icon),
|
||||
key: 'tts',
|
||||
label: tts.label,
|
||||
},
|
||||
{
|
||||
children: (translate as any).children?.map((child: any) => ({
|
||||
key: child.key,
|
||||
label: child.label,
|
||||
})),
|
||||
icon: renderIcon(translate.icon),
|
||||
key: 'translate',
|
||||
label: translate.label,
|
||||
popupClassName: cx(translateStyle),
|
||||
},
|
||||
{ type: 'divider' },
|
||||
...(role === 'assistant'
|
||||
? [
|
||||
{
|
||||
icon: renderIcon(share.icon),
|
||||
key: 'share',
|
||||
label: share.label,
|
||||
},
|
||||
{ type: 'divider' as const },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: renderIcon(regenerate.icon),
|
||||
key: 'regenerate',
|
||||
label: regenerate.label,
|
||||
},
|
||||
{
|
||||
disabled: (delAndRegenerate as any).disable,
|
||||
icon: renderIcon(delAndRegenerate.icon),
|
||||
key: 'delAndRegenerate',
|
||||
label: delAndRegenerate.label,
|
||||
},
|
||||
{ type: 'divider' },
|
||||
{
|
||||
danger: (del as any).danger,
|
||||
disabled: (del as any).disable,
|
||||
icon: renderIcon(del.icon),
|
||||
key: 'del',
|
||||
label: del.label,
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
}, [
|
||||
selectedText,
|
||||
t,
|
||||
edit,
|
||||
copy,
|
||||
branching,
|
||||
tts,
|
||||
translate,
|
||||
role,
|
||||
share,
|
||||
regenerate,
|
||||
delAndRegenerate,
|
||||
del,
|
||||
renderIcon,
|
||||
]);
|
||||
|
||||
const handleMenuClick = useMemo(
|
||||
() => (info: any) => {
|
||||
onMenuClick({
|
||||
key: info.key,
|
||||
keyPath: info.keyPath,
|
||||
selectedText: selectedText,
|
||||
});
|
||||
},
|
||||
[onMenuClick, selectedText],
|
||||
);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div
|
||||
className={styles.trigger}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
/>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: menuItems,
|
||||
onClick: handleMenuClick,
|
||||
}}
|
||||
open={visible}
|
||||
placement="bottomLeft"
|
||||
trigger={[]}
|
||||
>
|
||||
<div
|
||||
className={styles.contextMenu}
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
/>
|
||||
</Dropdown>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ContextMenu.displayName = 'ContextMenu';
|
||||
|
||||
export default ContextMenu;
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createStyles } from 'antd-style';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { MouseEventHandler, ReactNode, memo, use, useCallback, useMemo } from 'react';
|
||||
import { MouseEventHandler, ReactNode, memo, use, useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
@@ -24,9 +24,12 @@ import {
|
||||
renderMessages,
|
||||
useAvatarsClick,
|
||||
} from '../../Messages';
|
||||
import { useChatItemContextMenu } from '../../hooks/useChatItemContextMenu';
|
||||
import History from '../History';
|
||||
import { markdownElements } from '../MarkdownElements';
|
||||
import ContextMenu from './ContextMenu';
|
||||
import { InPortalThreadContext } from './InPortalThreadContext';
|
||||
import ShareMessageModal from './ShareMessageModal';
|
||||
import { normalizeThinkTags, processWithArtifact } from './utils';
|
||||
|
||||
const rehypePlugins = markdownElements.map((element) => element.rehypePlugin).filter(Boolean);
|
||||
@@ -69,6 +72,7 @@ const Item = memo<ChatListItemProps>(
|
||||
}) => {
|
||||
const { t } = useTranslation('common');
|
||||
const { styles, cx } = useStyles();
|
||||
const [showShareModal, setShareModal] = useState(false);
|
||||
|
||||
const type = useAgentStore(agentChatConfigSelectors.displayMode);
|
||||
const item = useChatStore(chatSelectors.getMessageById(id), isEqual);
|
||||
@@ -81,6 +85,14 @@ const Item = memo<ChatListItemProps>(
|
||||
editing,
|
||||
toggleMessageEditing,
|
||||
updateMessageContent,
|
||||
updateInputMessage,
|
||||
deleteMessage,
|
||||
regenerateMessage,
|
||||
copyMessage,
|
||||
openThreadCreator,
|
||||
delAndRegenerateMessage,
|
||||
translateMessage,
|
||||
ttsMessage,
|
||||
] = useChatStore((s) => [
|
||||
chatSelectors.isMessageLoading(id)(s),
|
||||
chatSelectors.isMessageGenerating(id)(s),
|
||||
@@ -88,6 +100,14 @@ const Item = memo<ChatListItemProps>(
|
||||
chatSelectors.isMessageEditing(id)(s),
|
||||
s.toggleMessageEditing,
|
||||
s.modifyMessageContent,
|
||||
s.updateInputMessage,
|
||||
s.deleteMessage,
|
||||
s.regenerateMessage,
|
||||
s.copyMessage,
|
||||
s.openThreadCreator,
|
||||
s.delAndRegenerateMessage,
|
||||
s.translateMessage,
|
||||
s.ttsMessage,
|
||||
]);
|
||||
|
||||
// when the message is in RAG flow or the AI generating, it should be in loading state
|
||||
@@ -95,6 +115,90 @@ const Item = memo<ChatListItemProps>(
|
||||
const animated = transitionMode === 'fadeIn' && generating;
|
||||
|
||||
const onAvatarsClick = useAvatarsClick(item?.role);
|
||||
const virtuosoRef = use(VirtuosoContext);
|
||||
|
||||
// Context menu functionality
|
||||
const handleContextMenuAction = useCallback(
|
||||
async (action: any) => {
|
||||
switch (action.key) {
|
||||
case 'quote': {
|
||||
const contentToQuote = action.selectedText || (item ? item.content : '');
|
||||
if (contentToQuote) {
|
||||
const currentInput = useChatStore.getState().inputMessage;
|
||||
const quotedText = `<quote_message>\n${contentToQuote}\n</quote_message>\n\n`;
|
||||
updateInputMessage(quotedText + currentInput);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
if (action.selectedText) {
|
||||
await copyMessage(id, action.selectedText);
|
||||
} else if (item) {
|
||||
await copyMessage(id, item.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
toggleMessageEditing(id, true);
|
||||
virtuosoRef?.current?.scrollIntoView({ align: 'start', behavior: 'auto', index });
|
||||
break;
|
||||
}
|
||||
case 'regenerate': {
|
||||
regenerateMessage(id);
|
||||
// if this message is an error message, we need to delete it
|
||||
if (item?.error) deleteMessage(id);
|
||||
break;
|
||||
}
|
||||
case 'branching': {
|
||||
openThreadCreator(id);
|
||||
break;
|
||||
}
|
||||
case 'delAndRegenerate': {
|
||||
delAndRegenerateMessage(id);
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
setShareModal(true);
|
||||
break;
|
||||
}
|
||||
case 'tts': {
|
||||
ttsMessage(id);
|
||||
break;
|
||||
}
|
||||
case 'del': {
|
||||
deleteMessage(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle translation actions
|
||||
if (action.keyPath?.at(-1) === 'translate') {
|
||||
const lang = action.keyPath[0];
|
||||
translateMessage(id, lang);
|
||||
}
|
||||
},
|
||||
[
|
||||
id,
|
||||
item,
|
||||
updateInputMessage,
|
||||
copyMessage,
|
||||
toggleMessageEditing,
|
||||
regenerateMessage,
|
||||
deleteMessage,
|
||||
openThreadCreator,
|
||||
delAndRegenerateMessage,
|
||||
ttsMessage,
|
||||
translateMessage,
|
||||
virtuosoRef,
|
||||
index,
|
||||
],
|
||||
);
|
||||
|
||||
const { contextMenuState, containerRef, handleContextMenu, handleMenuClick } =
|
||||
useChatItemContextMenu({
|
||||
editing,
|
||||
onActionClick: handleContextMenuAction,
|
||||
});
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(editableContent: ReactNode) => {
|
||||
@@ -192,7 +296,6 @@ const Item = memo<ChatListItemProps>(
|
||||
);
|
||||
|
||||
const onChange = useCallback((value: string) => updateMessageContent(id, value), [id]);
|
||||
const virtuosoRef = use(VirtuosoContext);
|
||||
|
||||
const onDoubleClick = useCallback<MouseEventHandler<HTMLDivElement>>(
|
||||
(e) => {
|
||||
@@ -228,7 +331,11 @@ const Item = memo<ChatListItemProps>(
|
||||
item && (
|
||||
<InPortalThreadContext.Provider value={inPortalThread}>
|
||||
{enableHistoryDivider && <History />}
|
||||
<Flexbox className={cx(styles.message, className, isMessageLoading && styles.loading)}>
|
||||
<Flexbox
|
||||
className={cx(styles.message, className, isMessageLoading && styles.loading)}
|
||||
onContextMenu={handleContextMenu}
|
||||
ref={containerRef}
|
||||
>
|
||||
<ChatItem
|
||||
actions={actionBar}
|
||||
avatar={item.meta}
|
||||
@@ -253,6 +360,22 @@ const Item = memo<ChatListItemProps>(
|
||||
/>
|
||||
{endRender}
|
||||
</Flexbox>
|
||||
<ContextMenu
|
||||
onMenuClick={handleMenuClick}
|
||||
position={contextMenuState.position}
|
||||
role={item?.role}
|
||||
selectedText={contextMenuState.selectedText}
|
||||
visible={contextMenuState.visible}
|
||||
/>
|
||||
{item && showShareModal && (
|
||||
<ShareMessageModal
|
||||
message={item}
|
||||
onCancel={() => {
|
||||
setShareModal(false);
|
||||
}}
|
||||
open={showShareModal}
|
||||
/>
|
||||
)}
|
||||
</InPortalThreadContext.Provider>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { type ActionIconGroupEvent } from '@lobehub/ui';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface ContextMenuState {
|
||||
position: { x: number; y: number };
|
||||
selectedText?: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface UseChatItemContextMenuProps {
|
||||
editing?: boolean;
|
||||
id: string;
|
||||
onActionClick: (action: ActionIconGroupEvent) => void;
|
||||
}
|
||||
|
||||
export const useChatItemContextMenu = ({
|
||||
onActionClick,
|
||||
editing,
|
||||
}: Omit<UseChatItemContextMenuProps, 'id'>) => {
|
||||
const [contextMenuState, setContextMenuState] = useState<ContextMenuState>({
|
||||
position: { x: 0, y: 0 },
|
||||
visible: false,
|
||||
});
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
// Don't show context menu in editing mode
|
||||
if (editing) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the clicked element or its parents have an id containing "msg_"
|
||||
let target = event.target as HTMLElement;
|
||||
let hasMessageId = false;
|
||||
|
||||
while (target && target !== document.body) {
|
||||
if (target.id && target.id.includes('msg_')) {
|
||||
hasMessageId = true;
|
||||
break;
|
||||
}
|
||||
target = target.parentElement as HTMLElement;
|
||||
}
|
||||
|
||||
if (!hasMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Get selected text
|
||||
const selection = window.getSelection();
|
||||
const selectedText = selection?.toString().trim() || '';
|
||||
|
||||
setContextMenuState({
|
||||
position: { x: event.clientX, y: event.clientY },
|
||||
selectedText,
|
||||
visible: true,
|
||||
});
|
||||
},
|
||||
[editing],
|
||||
);
|
||||
|
||||
const hideContextMenu = useCallback(() => {
|
||||
setContextMenuState((prev) => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
(action: ActionIconGroupEvent) => {
|
||||
if (action.key === 'quote' && contextMenuState.selectedText) {
|
||||
// Handle quote action - this will be integrated with ChatInput
|
||||
onActionClick({
|
||||
...action,
|
||||
selectedText: contextMenuState.selectedText,
|
||||
} as ActionIconGroupEvent & { selectedText: string });
|
||||
} else {
|
||||
onActionClick(action);
|
||||
}
|
||||
hideContextMenu();
|
||||
},
|
||||
[contextMenuState.selectedText, onActionClick, hideContextMenu],
|
||||
);
|
||||
|
||||
// Close context menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => {
|
||||
if (contextMenuState.visible) {
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
if (contextMenuState.visible) {
|
||||
// Check if the scroll event is from a dropdown sub-menu
|
||||
const target = event.target as HTMLElement;
|
||||
if (target && target.classList && target.classList.contains('ant-dropdown-menu-sub')) {
|
||||
return; // Don't hide the context menu when scrolling within sub-menu
|
||||
}
|
||||
hideContextMenu();
|
||||
}
|
||||
};
|
||||
|
||||
if (contextMenuState.visible) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('scroll', handleScroll, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('scroll', handleScroll, true);
|
||||
};
|
||||
}
|
||||
}, [contextMenuState.visible, hideContextMenu]);
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
contextMenuState,
|
||||
handleContextMenu,
|
||||
handleMenuClick,
|
||||
hideContextMenu,
|
||||
};
|
||||
};
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DownloadIcon,
|
||||
Edit,
|
||||
ListRestart,
|
||||
Quote,
|
||||
RotateCcw,
|
||||
Share2,
|
||||
Split,
|
||||
@@ -22,6 +23,7 @@ interface ChatListActionsBar {
|
||||
divider: { type: 'divider' };
|
||||
edit: ActionIconGroupItemType;
|
||||
export: ActionIconGroupItemType;
|
||||
quote: ActionIconGroupItemType;
|
||||
regenerate: ActionIconGroupItemType;
|
||||
share: ActionIconGroupItemType;
|
||||
}
|
||||
@@ -75,6 +77,11 @@ export const useChatListActionsBar = ({
|
||||
key: 'export',
|
||||
label: '导出为 PDF',
|
||||
},
|
||||
quote: {
|
||||
icon: Quote,
|
||||
key: 'quote',
|
||||
label: t('quote', { defaultValue: 'Quote' }),
|
||||
},
|
||||
regenerate: {
|
||||
icon: RotateCcw,
|
||||
key: 'regenerate',
|
||||
|
||||
Reference in New Issue
Block a user