@lobehub/lobehub 2.0.0-next.109 → 2.0.0-next.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/apps/desktop/src/common/routes.ts +0 -6
  3. package/apps/desktop/src/main/appBrowsers.ts +0 -13
  4. package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +29 -48
  5. package/apps/desktop/src/main/controllers/__tests__/BrowserWindowsCtr.test.ts +21 -72
  6. package/apps/desktop/src/main/core/browser/Browser.ts +1 -0
  7. package/apps/desktop/src/main/core/browser/BrowserManager.ts +1 -56
  8. package/apps/desktop/src/main/menus/impls/macOS.ts +9 -3
  9. package/changelog/v1.json +9 -0
  10. package/locales/ar/setting.json +7 -1
  11. package/locales/bg-BG/setting.json +7 -1
  12. package/locales/de-DE/setting.json +7 -1
  13. package/locales/en-US/setting.json +7 -1
  14. package/locales/es-ES/setting.json +7 -1
  15. package/locales/fa-IR/setting.json +7 -1
  16. package/locales/fr-FR/setting.json +7 -1
  17. package/locales/it-IT/setting.json +7 -1
  18. package/locales/ja-JP/setting.json +7 -1
  19. package/locales/ko-KR/setting.json +7 -1
  20. package/locales/nl-NL/setting.json +7 -1
  21. package/locales/pl-PL/setting.json +7 -1
  22. package/locales/pt-BR/setting.json +7 -1
  23. package/locales/ru-RU/setting.json +7 -1
  24. package/locales/tr-TR/setting.json +7 -1
  25. package/locales/vi-VN/setting.json +7 -1
  26. package/locales/zh-CN/setting.json +6 -0
  27. package/locales/zh-TW/setting.json +7 -1
  28. package/package.json +1 -1
  29. package/packages/const/src/settings/common.ts +1 -0
  30. package/packages/model-bank/src/aiModels/ollamacloud.ts +0 -1
  31. package/packages/types/src/user/settings/general.ts +3 -0
  32. package/src/app/[variants]/(main)/chat/components/topic/features/Topic/TopicListContent/TopicItem/index.tsx +32 -18
  33. package/src/app/[variants]/(main)/layouts/desktop/DesktopLayoutContainer.tsx +3 -6
  34. package/src/app/[variants]/(main)/layouts/desktop/SideBar/PinList/index.tsx +21 -14
  35. package/src/app/[variants]/(main)/settings/common/features/Common/Common.tsx +23 -1
  36. package/src/features/ChatItem/components/MessageContent.tsx +2 -1
  37. package/src/features/ChatList/Messages/Assistant/Actions/index.tsx +1 -0
  38. package/src/features/ChatList/Messages/Assistant/index.tsx +1 -1
  39. package/src/features/ChatList/Messages/Default.tsx +2 -0
  40. package/src/features/ChatList/Messages/index.tsx +80 -31
  41. package/src/features/ChatList/components/ContextMenu.tsx +391 -0
  42. package/src/features/ChatList/hooks/useChatItemContextMenu.tsx +135 -0
  43. package/src/locales/default/setting.ts +6 -0
  44. package/src/store/user/slices/settings/selectors/general.ts +8 -0
@@ -0,0 +1,391 @@
1
+ 'use client';
2
+
3
+ import { UIChatMessage } from '@lobechat/types';
4
+ import { ActionIcon } from '@lobehub/ui';
5
+ import type { ActionIconGroupEvent, ActionIconGroupItemType } from '@lobehub/ui';
6
+ import { Dropdown, type MenuProps } from 'antd';
7
+ import { App } from 'antd';
8
+ import { createStyles } from 'antd-style';
9
+ import isEqual from 'fast-deep-equal';
10
+ import { isValidElement, memo, useCallback, useMemo, useState, type RefObject , ComponentType, ReactNode } from 'react';
11
+ import { createPortal } from 'react-dom';
12
+ import { useTranslation } from 'react-i18next';
13
+ import type { VListHandle } from 'virtua';
14
+
15
+ import ShareMessageModal from './ShareMessageModal';
16
+ import { useChatListActionsBar } from '../hooks/useChatListActionsBar';
17
+ import { getChatStoreState, useChatStore } from '@/store/chat';
18
+ import {
19
+ displayMessageSelectors,
20
+ messageStateSelectors,
21
+ threadSelectors,
22
+ } from '@/store/chat/selectors';
23
+ import { useSessionStore } from '@/store/session';
24
+ import { sessionSelectors } from '@/store/session/selectors';
25
+
26
+ interface ActionMenuItem extends ActionIconGroupItemType {
27
+ children?: { key: string; label: ReactNode }[];
28
+ disable?: boolean;
29
+ popupClassName?: string;
30
+ }
31
+
32
+ type MenuItem = ActionMenuItem | { type: 'divider' };
33
+ type ContextMenuEvent = ActionIconGroupEvent & { selectedText?: string };
34
+
35
+ const useStyles = createStyles(({ css }) => ({
36
+ contextMenu: css`
37
+ position: fixed;
38
+ z-index: 1000;
39
+ min-width: 160px;
40
+
41
+ .ant-dropdown-menu {
42
+ border: none;
43
+ border-radius: 6px;
44
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 15%);
45
+ }
46
+ `,
47
+ trigger: css`
48
+ pointer-events: none;
49
+
50
+ position: fixed;
51
+
52
+ width: 1px;
53
+ height: 1px;
54
+
55
+ opacity: 0;
56
+ `,
57
+ }));
58
+
59
+ interface ContextMenuProps {
60
+ id: string;
61
+ inPortalThread: boolean;
62
+ index: number;
63
+ onClose: () => void;
64
+ position: { x: number; y: number };
65
+ selectedText?: string;
66
+ topic: string | null;
67
+ virtuaRef?: RefObject<VListHandle | null> | null;
68
+ visible: boolean;
69
+ }
70
+
71
+ const ContextMenu = memo<ContextMenuProps>(
72
+ ({ visible, position, selectedText, id, index, inPortalThread, topic, virtuaRef, onClose }) => {
73
+ const { styles } = useStyles();
74
+ const { message } = App.useApp();
75
+ const { t } = useTranslation('common');
76
+ const [shareMessage, setShareMessage] = useState<UIChatMessage | null>(null);
77
+ const [isShareModalOpen, setShareModalOpen] = useState(false);
78
+
79
+ const [role, error, isCollapsed, hasThread, isRegenerating] = useChatStore(
80
+ (s) => {
81
+ const item = displayMessageSelectors.getDisplayMessageById(id)(s);
82
+ return [
83
+ item?.role,
84
+ item?.error,
85
+ messageStateSelectors.isMessageCollapsed(id)(s),
86
+ threadSelectors.hasThreadBySourceMsgId(id)(s),
87
+ messageStateSelectors.isMessageRegenerating(id)(s),
88
+ ];
89
+ },
90
+ isEqual,
91
+ );
92
+
93
+ const isThreadMode = useChatStore((s) => !!s.activeThreadId);
94
+ const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
95
+ const actionsBar = useChatListActionsBar({ hasThread, isRegenerating });
96
+ const inThread = isThreadMode || inPortalThread;
97
+
98
+ const [
99
+ toggleMessageEditing,
100
+ deleteMessage,
101
+ regenerateUserMessage,
102
+ regenerateAssistantMessage,
103
+ translateMessage,
104
+ ttsMessage,
105
+ delAndRegenerateMessage,
106
+ copyMessage,
107
+ openThreadCreator,
108
+ resendThreadMessage,
109
+ delAndResendThreadMessage,
110
+ toggleMessageCollapsed,
111
+ ] = useChatStore((s) => [
112
+ s.toggleMessageEditing,
113
+ s.deleteMessage,
114
+ s.regenerateUserMessage,
115
+ s.regenerateAssistantMessage,
116
+ s.translateMessage,
117
+ s.ttsMessage,
118
+ s.delAndRegenerateMessage,
119
+ s.copyMessage,
120
+ s.openThreadCreator,
121
+ s.resendThreadMessage,
122
+ s.delAndResendThreadMessage,
123
+ s.toggleMessageCollapsed,
124
+ ]);
125
+
126
+ const getMessage = useCallback(
127
+ () => displayMessageSelectors.getDisplayMessageById(id)(getChatStoreState()),
128
+ [id],
129
+ );
130
+
131
+ const menuItems = useMemo<MenuItem[]>(() => {
132
+ if (!role) return [];
133
+
134
+ const {
135
+ branching,
136
+ collapse,
137
+ copy,
138
+ del,
139
+ delAndRegenerate,
140
+ divider,
141
+ edit,
142
+ expand,
143
+ regenerate,
144
+ share,
145
+ translate,
146
+ tts,
147
+ } = actionsBar;
148
+
149
+ if (role === 'assistant') {
150
+ if (error) {
151
+ return [edit, copy, divider, del, divider, regenerate].filter(Boolean) as MenuItem[];
152
+ }
153
+
154
+ const collapseAction = isCollapsed ? expand : collapse;
155
+ const list: MenuItem[] = [edit, copy, collapseAction];
156
+
157
+ if (!inThread && !isGroupSession) list.push(branching);
158
+
159
+ list.push(
160
+ divider,
161
+ tts,
162
+ translate,
163
+ divider,
164
+ share,
165
+ divider,
166
+ regenerate,
167
+ delAndRegenerate,
168
+ del,
169
+ );
170
+
171
+ return list.filter(Boolean) as MenuItem[];
172
+ }
173
+
174
+ if (role === 'user') {
175
+ const list: MenuItem[] = [edit, copy];
176
+
177
+ if (!inThread) list.push(branching);
178
+
179
+ list.push(divider, tts, translate, divider, regenerate, del);
180
+
181
+ return list.filter(Boolean) as MenuItem[];
182
+ }
183
+
184
+ return [];
185
+ }, [actionsBar, error, inThread, isCollapsed, isGroupSession, role]);
186
+
187
+ const handleShare = useCallback(() => {
188
+ const item = getMessage();
189
+ if (!item || item.role !== 'assistant') return;
190
+
191
+ setShareMessage(item);
192
+ setShareModalOpen(true);
193
+ }, [getMessage]);
194
+
195
+ const handleShareClose = useCallback(() => {
196
+ setShareModalOpen(false);
197
+ setShareMessage(null);
198
+ }, []);
199
+
200
+ const handleAction = useCallback(
201
+ async (action: ContextMenuEvent) => {
202
+ const item = getMessage();
203
+ if (!item) return;
204
+
205
+ if (action.key === 'edit') {
206
+ toggleMessageEditing(id, true);
207
+ virtuaRef?.current?.scrollToIndex(index, { align: 'start' });
208
+ }
209
+
210
+ switch (action.key) {
211
+ case 'copy': {
212
+ await copyMessage(id, item.content);
213
+ message.success(t('copySuccess'));
214
+ break;
215
+ }
216
+ case 'expand':
217
+ case 'collapse': {
218
+ toggleMessageCollapsed(id);
219
+ break;
220
+ }
221
+ case 'branching': {
222
+ if (!topic) {
223
+ message.warning(t('branchingRequiresSavedTopic'));
224
+ break;
225
+ }
226
+ openThreadCreator(id);
227
+ break;
228
+ }
229
+ case 'del': {
230
+ deleteMessage(id);
231
+ break;
232
+ }
233
+ case 'regenerate': {
234
+ if (inPortalThread) {
235
+ resendThreadMessage(id);
236
+ } else if (role === 'assistant') {
237
+ regenerateAssistantMessage(id);
238
+ } else {
239
+ regenerateUserMessage(id);
240
+ }
241
+
242
+ if (item.error) deleteMessage(id);
243
+ break;
244
+ }
245
+ case 'delAndRegenerate': {
246
+ if (inPortalThread) {
247
+ delAndResendThreadMessage(id);
248
+ } else {
249
+ delAndRegenerateMessage(id);
250
+ }
251
+ break;
252
+ }
253
+ case 'tts': {
254
+ ttsMessage(id);
255
+ break;
256
+ }
257
+ case 'share': {
258
+ handleShare();
259
+ break;
260
+ }
261
+ }
262
+
263
+ if (action.keyPath?.at(-1) === 'translate') {
264
+ const lang = action.keyPath[0];
265
+ translateMessage(id, lang);
266
+ }
267
+ },
268
+ [
269
+ copyMessage,
270
+ deleteMessage,
271
+ delAndRegenerateMessage,
272
+ delAndResendThreadMessage,
273
+ getMessage,
274
+ handleShare,
275
+ id,
276
+ index,
277
+ inPortalThread,
278
+ message,
279
+ openThreadCreator,
280
+ regenerateAssistantMessage,
281
+ regenerateUserMessage,
282
+ resendThreadMessage,
283
+ role,
284
+ t,
285
+ toggleMessageCollapsed,
286
+ toggleMessageEditing,
287
+ topic,
288
+ translateMessage,
289
+ ttsMessage,
290
+ virtuaRef,
291
+ ],
292
+ );
293
+
294
+ const renderIcon = useCallback((iconComponent: ActionIconGroupItemType['icon']) => {
295
+ if (!iconComponent) return null;
296
+
297
+ if (isValidElement(iconComponent)) {
298
+ return <ActionIcon icon={iconComponent} size={'small'} />;
299
+ }
300
+
301
+ const IconComponent = iconComponent as ComponentType<{ size?: number }>;
302
+
303
+ return <ActionIcon icon={<IconComponent size={16} />} size={'small'} />;
304
+ }, []);
305
+
306
+ const dropdownMenuItems = useMemo(() => {
307
+ return (menuItems ?? []).filter(Boolean).map((item) => {
308
+ if ('type' in item && item.type === 'divider') return { type: 'divider' as const };
309
+
310
+ const actionItem = item as ActionMenuItem;
311
+ const children = actionItem.children?.map((child) => ({
312
+ key: child.key,
313
+ label: child.label,
314
+ }));
315
+ const disabled =
316
+ actionItem.disabled ??
317
+ (typeof actionItem.disable === 'boolean' ? actionItem.disable : undefined);
318
+
319
+ return {
320
+ children,
321
+ danger: actionItem.danger,
322
+ disabled,
323
+ icon: renderIcon(actionItem.icon),
324
+ key: actionItem.key,
325
+ label: actionItem.label,
326
+ popupClassName: actionItem.popupClassName,
327
+ };
328
+ });
329
+ }, [menuItems, renderIcon]);
330
+
331
+ const handleMenuClick = useCallback(
332
+ (info: Parameters<NonNullable<MenuProps['onClick']>>[0]) => {
333
+ const event = {
334
+ ...info,
335
+ selectedText,
336
+ } as ContextMenuEvent;
337
+
338
+ handleAction(event);
339
+ onClose();
340
+ },
341
+ [handleAction, onClose, selectedText],
342
+ );
343
+
344
+ if (!visible || menuItems.length === 0) return null;
345
+
346
+ return (
347
+ <>
348
+ {createPortal(
349
+ <>
350
+ <div
351
+ className={styles.trigger}
352
+ style={{
353
+ left: position.x,
354
+ top: position.y,
355
+ }}
356
+ />
357
+ <Dropdown
358
+ menu={{
359
+ items: dropdownMenuItems,
360
+ onClick: handleMenuClick,
361
+ }}
362
+ open={visible}
363
+ placement="bottomLeft"
364
+ trigger={[]}
365
+ >
366
+ <div
367
+ className={styles.contextMenu}
368
+ style={{
369
+ left: position.x,
370
+ top: position.y,
371
+ }}
372
+ />
373
+ </Dropdown>
374
+ </>,
375
+ document.body,
376
+ )}
377
+ {shareMessage && (
378
+ <ShareMessageModal
379
+ message={shareMessage}
380
+ onCancel={handleShareClose}
381
+ open={isShareModalOpen}
382
+ />
383
+ )}
384
+ </>
385
+ );
386
+ },
387
+ );
388
+
389
+ ContextMenu.displayName = 'ContextMenu';
390
+
391
+ export default ContextMenu;
@@ -0,0 +1,135 @@
1
+ import { type ActionIconGroupEvent } from '@lobehub/ui';
2
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
3
+
4
+ import { useUserStore } from '@/store/user';
5
+ import { userGeneralSettingsSelectors } from '@/store/user/selectors';
6
+
7
+ import { MessageContentClassName } from '../Messages/Default';
8
+
9
+ interface ContextMenuState {
10
+ position: { x: number; y: number };
11
+ selectedText?: string;
12
+ visible: boolean;
13
+ }
14
+
15
+ interface UseChatItemContextMenuProps {
16
+ editing?: boolean;
17
+ id: string;
18
+ onActionClick: (action: ActionIconGroupEvent) => void;
19
+ }
20
+
21
+ export const useChatItemContextMenu = ({
22
+ onActionClick,
23
+ editing,
24
+ }: Omit<UseChatItemContextMenuProps, 'id'>) => {
25
+ const contextMenuMode = useUserStore(userGeneralSettingsSelectors.contextMenuMode);
26
+
27
+ const [contextMenuState, setContextMenuState] = useState<ContextMenuState>({
28
+ position: { x: 0, y: 0 },
29
+ visible: false,
30
+ });
31
+
32
+ const containerRef = useRef<HTMLDivElement>(null);
33
+
34
+ const handleContextMenu = useCallback(
35
+ (event: React.MouseEvent) => {
36
+ // Don't show context menu if disabled in settings
37
+ if (contextMenuMode === 'disabled') {
38
+ return;
39
+ }
40
+
41
+ // Don't show context menu in editing mode
42
+ if (editing) {
43
+ return;
44
+ }
45
+
46
+ // Check if the clicked element or its parents have an id containing "msg_"
47
+ let target = event.target as HTMLElement;
48
+ let hasMessageId = false;
49
+
50
+ while (target && target !== document.body) {
51
+ if (target.className.includes(MessageContentClassName)) {
52
+ hasMessageId = true;
53
+ break;
54
+ }
55
+ target = target.parentElement as HTMLElement;
56
+ }
57
+
58
+ if (!hasMessageId) {
59
+ return;
60
+ }
61
+
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+
65
+ // Get selected text
66
+ const selection = window.getSelection();
67
+ const selectedText = selection?.toString().trim() || '';
68
+
69
+ setContextMenuState({
70
+ position: { x: event.clientX, y: event.clientY },
71
+ selectedText,
72
+ visible: true,
73
+ });
74
+ },
75
+ [contextMenuMode, editing],
76
+ );
77
+
78
+ const hideContextMenu = useCallback(() => {
79
+ setContextMenuState((prev) => ({ ...prev, visible: false }));
80
+ }, []);
81
+
82
+ const handleMenuClick = useCallback(
83
+ (action: ActionIconGroupEvent) => {
84
+ if (action.key === 'quote' && contextMenuState.selectedText) {
85
+ // Handle quote action - this will be integrated with ChatInput
86
+ onActionClick({
87
+ ...action,
88
+ selectedText: contextMenuState.selectedText,
89
+ } as ActionIconGroupEvent & { selectedText: string });
90
+ } else {
91
+ onActionClick(action);
92
+ }
93
+ hideContextMenu();
94
+ },
95
+ [contextMenuState.selectedText, onActionClick, hideContextMenu],
96
+ );
97
+
98
+ // Close context menu when clicking outside
99
+ useEffect(() => {
100
+ const handleClickOutside = () => {
101
+ if (contextMenuState.visible) {
102
+ hideContextMenu();
103
+ }
104
+ };
105
+
106
+ const handleScroll = (event: Event) => {
107
+ if (contextMenuState.visible) {
108
+ // Check if the scroll event is from a dropdown sub-menu
109
+ const target = event.target as HTMLElement;
110
+ if (target && target.classList && target.classList.contains('ant-dropdown-menu-sub')) {
111
+ return; // Don't hide the context menu when scrolling within sub-menu
112
+ }
113
+ hideContextMenu();
114
+ }
115
+ };
116
+
117
+ if (contextMenuState.visible) {
118
+ document.addEventListener('click', handleClickOutside);
119
+ document.addEventListener('scroll', handleScroll, true);
120
+
121
+ return () => {
122
+ document.removeEventListener('click', handleClickOutside);
123
+ document.removeEventListener('scroll', handleScroll, true);
124
+ };
125
+ }
126
+ }, [contextMenuState.visible, hideContextMenu]);
127
+
128
+ return {
129
+ containerRef,
130
+ contextMenuState,
131
+ handleContextMenu,
132
+ handleMenuClick,
133
+ hideContextMenu,
134
+ };
135
+ };
@@ -297,6 +297,12 @@ export default {
297
297
  elegant: '优雅',
298
298
  title: '响应动画',
299
299
  },
300
+ contextMenuMode: {
301
+ default: '默认',
302
+ desc: '选择聊天消息右键菜单的显示方案',
303
+ disabled: '不使用',
304
+ title: '右键菜单方案',
305
+ },
300
306
  neutralColor: {
301
307
  desc: '不同色彩倾向的灰阶自定义',
302
308
  title: '中性色',
@@ -1,3 +1,5 @@
1
+ import { isDesktop } from '@lobechat/const';
2
+
1
3
  import { UserStore } from '../../../store';
2
4
  import { currentSettings } from './settings';
3
5
 
@@ -10,10 +12,16 @@ const highlighterTheme = (s: UserStore) => generalConfig(s).highlighterTheme;
10
12
  const mermaidTheme = (s: UserStore) => generalConfig(s).mermaidTheme;
11
13
  const transitionMode = (s: UserStore) => generalConfig(s).transitionMode;
12
14
  const animationMode = (s: UserStore) => generalConfig(s).animationMode;
15
+ const contextMenuMode = (s: UserStore) => {
16
+ const config = generalConfig(s).contextMenuMode;
17
+ if (config !== undefined) return config;
18
+ return isDesktop ? 'default' : 'disabled';
19
+ };
13
20
 
14
21
  export const userGeneralSettingsSelectors = {
15
22
  animationMode,
16
23
  config: generalConfig,
24
+ contextMenuMode,
17
25
  fontSize,
18
26
  highlighterTheme,
19
27
  mermaidTheme,