Compare commits

...

2 Commits

Author SHA1 Message Date
ONLY-yours b1a130d52d feat: 完成引用 & 右键打开 ContextMenu 的功能 2025-07-23 16:26:00 +08:00
ONLY-yours 16bcb907af feat: 支持在 ChatItem 侧右键快速唤起 ContextMenu 2025-07-18 17:10:51 +08:00
15 changed files with 475 additions and 151 deletions
+1
View File
@@ -280,6 +280,7 @@
"pin": "Anheften",
"pinOff": "Anheften aufheben",
"privacy": "Datenschutzrichtlinie",
"quote": "Zitieren",
"regenerate": "Neu generieren",
"releaseNotes": "Versionsdetails",
"rename": "Umbenennen",
+1
View File
@@ -280,6 +280,7 @@
"pin": "Pin",
"pinOff": "Unpin",
"privacy": "Privacy Policy",
"quote": "Quote",
"regenerate": "Regenerate",
"releaseNotes": "Version Details",
"rename": "Rename",
+1
View File
@@ -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",
+1
View File
@@ -280,6 +280,7 @@
"pin": "ピン留め",
"pinOff": "ピン留め解除",
"privacy": "プライバシーポリシー",
"quote": "引用",
"regenerate": "再生成",
"releaseNotes": "リリースノート",
"rename": "名前を変更",
+1
View File
@@ -280,6 +280,7 @@
"pin": "고정",
"pinOff": "고정 해제",
"privacy": "개인정보 보호 정책",
"quote": "인용",
"regenerate": "재생성",
"releaseNotes": "버전 세부정보",
"rename": "이름 바꾸기",
+1
View File
@@ -280,6 +280,7 @@
"pin": "置顶",
"pinOff": "取消置顶",
"privacy": "隐私政策",
"quote": "引用",
"regenerate": "重新生成",
"releaseNotes": "版本详情",
"rename": "重命名",
+1
View File
@@ -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 };
-89
View File
@@ -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;
-31
View File
@@ -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',