@lobehub/lobehub 2.0.0-next.264 → 2.0.0-next.265

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 (31) hide show
  1. package/.github/workflows/manual-build-desktop.yml +16 -37
  2. package/CHANGELOG.md +27 -0
  3. package/apps/desktop/native-deps.config.mjs +19 -3
  4. package/apps/desktop/src/main/controllers/__tests__/SystemCtr.test.ts +13 -0
  5. package/apps/desktop/src/main/utils/permissions.ts +86 -22
  6. package/changelog/v1.json +9 -0
  7. package/package.json +2 -2
  8. package/packages/database/src/models/__tests__/agent.test.ts +165 -4
  9. package/packages/database/src/models/agent.ts +46 -0
  10. package/packages/database/src/repositories/agentGroup/index.test.ts +498 -0
  11. package/packages/database/src/repositories/agentGroup/index.ts +150 -0
  12. package/packages/database/src/repositories/home/__tests__/index.test.ts +113 -1
  13. package/packages/database/src/repositories/home/index.ts +48 -67
  14. package/pnpm-workspace.yaml +1 -0
  15. package/src/app/[variants]/(main)/agent/features/Conversation/MainChatInput/index.tsx +2 -2
  16. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx +2 -6
  17. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/useDropdownMenu.tsx +100 -0
  18. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx +2 -4
  19. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/useDropdownMenu.tsx +149 -0
  20. package/src/app/[variants]/(main)/home/_layout/hooks/index.ts +0 -1
  21. package/src/app/[variants]/(main)/home/features/InputArea/index.tsx +1 -1
  22. package/src/features/ChatInput/InputEditor/index.tsx +1 -0
  23. package/src/features/EditorCanvas/DiffAllToolbar.tsx +1 -1
  24. package/src/server/routers/lambda/agent.ts +15 -0
  25. package/src/server/routers/lambda/agentGroup.ts +16 -0
  26. package/src/services/agent.ts +11 -0
  27. package/src/services/chatGroup/index.ts +11 -0
  28. package/src/store/home/slices/sidebarUI/action.test.ts +23 -22
  29. package/src/store/home/slices/sidebarUI/action.ts +37 -9
  30. package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/Item/useDropdownMenu.tsx +0 -62
  31. package/src/app/[variants]/(main)/home/_layout/hooks/useSessionItemMenuItems.tsx +0 -238
@@ -130,6 +130,21 @@ export const agentRouter = router({
130
130
  return ctx.agentModel.deleteAgentKnowledgeBase(input.agentId, input.knowledgeBaseId);
131
131
  }),
132
132
 
133
+ /**
134
+ * Duplicate an agent and its associated session.
135
+ * Returns the new agent ID and session ID.
136
+ */
137
+ duplicateAgent: agentProcedure
138
+ .input(
139
+ z.object({
140
+ agentId: z.string(),
141
+ newTitle: z.string().optional(),
142
+ }),
143
+ )
144
+ .mutation(async ({ input, ctx }) => {
145
+ return ctx.agentModel.duplicate(input.agentId, input.newTitle);
146
+ }),
147
+
133
148
  /**
134
149
  * Get an agent by marketIdentifier
135
150
  * @returns agent id if exists, null otherwise
@@ -126,6 +126,22 @@ export const agentGroupRouter = router({
126
126
  return ctx.agentGroupService.deleteGroup(input.id);
127
127
  }),
128
128
 
129
+ /**
130
+ * Duplicate a chat group with all its members.
131
+ * Creates a new group with the same config, a new supervisor, and copies of virtual members.
132
+ * Non-virtual members are referenced (not copied).
133
+ */
134
+ duplicateGroup: agentGroupProcedure
135
+ .input(
136
+ z.object({
137
+ groupId: z.string(),
138
+ newTitle: z.string().optional(),
139
+ }),
140
+ )
141
+ .mutation(async ({ input, ctx }) => {
142
+ return ctx.agentGroupRepo.duplicate(input.groupId, input.newTitle);
143
+ }),
144
+
129
145
  getGroup: agentGroupProcedure
130
146
  .input(z.object({ id: z.string() }))
131
147
  .query(async ({ input, ctx }) => {
@@ -185,6 +185,17 @@ class AgentService {
185
185
  updateAgentPinned = async (agentId: string, pinned: boolean) => {
186
186
  return lambdaClient.agent.updateAgentPinned.mutate({ id: agentId, pinned });
187
187
  };
188
+
189
+ /**
190
+ * Duplicate an agent.
191
+ * Returns the new agent ID.
192
+ */
193
+ duplicateAgent = async (
194
+ agentId: string,
195
+ newTitle?: string,
196
+ ): Promise<{ agentId: string } | null> => {
197
+ return lambdaClient.agent.duplicateAgent.mutate({ agentId, newTitle });
198
+ };
188
199
  }
189
200
 
190
201
  export const agentService = new AgentService();
@@ -107,6 +107,17 @@ class ChatGroupService {
107
107
  getGroupAgents = (groupId: string): Promise<ChatGroupAgentItem[]> => {
108
108
  return lambdaClient.group.getGroupAgents.query({ groupId });
109
109
  };
110
+
111
+ /**
112
+ * Duplicate a chat group with all its members.
113
+ * Returns the new group ID and supervisor agent ID.
114
+ */
115
+ duplicateGroup = (
116
+ groupId: string,
117
+ newTitle?: string,
118
+ ): Promise<{ groupId: string; supervisorAgentId: string } | null> => {
119
+ return lambdaClient.group.duplicateGroup.mutate({ groupId, newTitle });
120
+ };
110
121
  }
111
122
 
112
123
  export const chatGroupService = new ChatGroupService();
@@ -6,6 +6,7 @@ import { agentService } from '@/services/agent';
6
6
  import { chatGroupService } from '@/services/chatGroup';
7
7
  import { homeService } from '@/services/home';
8
8
  import { sessionService } from '@/services/session';
9
+ import { getAgentStoreState } from '@/store/agent';
9
10
  import { useHomeStore } from '@/store/home';
10
11
  import { getSessionStoreState } from '@/store/session';
11
12
 
@@ -26,6 +27,12 @@ vi.mock('@/store/session', () => ({
26
27
  })),
27
28
  }));
28
29
 
30
+ vi.mock('@/store/agent', () => ({
31
+ getAgentStoreState: vi.fn(() => ({
32
+ setActiveAgentId: vi.fn(),
33
+ })),
34
+ }));
35
+
29
36
  afterEach(() => {
30
37
  vi.restoreAllMocks();
31
38
  });
@@ -136,17 +143,16 @@ describe('createSidebarUISlice', () => {
136
143
  });
137
144
 
138
145
  describe('duplicateAgent', () => {
139
- it('should duplicate an agent and switch to the new session', async () => {
146
+ it('should duplicate an agent and switch to the new agent', async () => {
140
147
  const mockAgentId = 'agent-123';
141
- const mockNewId = 'new-agent-456';
142
- const mockSwitchSession = vi.fn();
148
+ const mockNewAgentId = 'new-agent-456';
149
+ const mockSetActiveAgentId = vi.fn();
143
150
 
144
- vi.mocked(getSessionStoreState).mockReturnValue({
145
- activeId: 'other-agent',
146
- switchSession: mockSwitchSession,
151
+ vi.mocked(getAgentStoreState).mockReturnValue({
152
+ setActiveAgentId: mockSetActiveAgentId,
147
153
  } as any);
148
154
 
149
- vi.spyOn(sessionService, 'cloneSession').mockResolvedValueOnce(mockNewId);
155
+ vi.spyOn(agentService, 'duplicateAgent').mockResolvedValueOnce({ agentId: mockNewAgentId });
150
156
  const spyOnRefresh = vi.spyOn(useHomeStore.getState(), 'refreshAgentList');
151
157
 
152
158
  const { result } = renderHook(() => useHomeStore());
@@ -155,16 +161,16 @@ describe('createSidebarUISlice', () => {
155
161
  await result.current.duplicateAgent(mockAgentId, 'Copied Agent');
156
162
  });
157
163
 
158
- expect(sessionService.cloneSession).toHaveBeenCalledWith(mockAgentId, 'Copied Agent');
164
+ expect(agentService.duplicateAgent).toHaveBeenCalledWith(mockAgentId, 'Copied Agent');
159
165
  expect(spyOnRefresh).toHaveBeenCalled();
160
- expect(mockSwitchSession).toHaveBeenCalledWith(mockNewId);
166
+ expect(mockSetActiveAgentId).toHaveBeenCalledWith(mockNewAgentId);
161
167
  });
162
168
 
163
169
  it('should show error message when duplication fails', async () => {
164
170
  const mockAgentId = 'agent-123';
165
171
  const { message } = await import('@/components/AntdStaticMethods');
166
172
 
167
- vi.spyOn(sessionService, 'cloneSession').mockResolvedValueOnce(undefined);
173
+ vi.spyOn(agentService, 'duplicateAgent').mockResolvedValueOnce(null);
168
174
  vi.spyOn(useHomeStore.getState(), 'refreshAgentList');
169
175
 
170
176
  const { result } = renderHook(() => useHomeStore());
@@ -176,29 +182,24 @@ describe('createSidebarUISlice', () => {
176
182
  expect(message.error).toHaveBeenCalled();
177
183
  });
178
184
 
179
- it('should use default title when not provided', async () => {
185
+ it('should use provided title when duplicating', async () => {
180
186
  const mockAgentId = 'agent-123';
181
- const mockNewId = 'new-agent-456';
187
+ const mockNewAgentId = 'new-agent-456';
182
188
 
183
- vi.mocked(getSessionStoreState).mockReturnValue({
184
- activeId: 'other-agent',
185
- switchSession: vi.fn(),
189
+ vi.mocked(getAgentStoreState).mockReturnValue({
190
+ setActiveAgentId: vi.fn(),
186
191
  } as any);
187
192
 
188
- vi.spyOn(sessionService, 'cloneSession').mockResolvedValueOnce(mockNewId);
193
+ vi.spyOn(agentService, 'duplicateAgent').mockResolvedValueOnce({ agentId: mockNewAgentId });
189
194
  vi.spyOn(useHomeStore.getState(), 'refreshAgentList');
190
195
 
191
196
  const { result } = renderHook(() => useHomeStore());
192
197
 
193
198
  await act(async () => {
194
- await result.current.duplicateAgent(mockAgentId);
199
+ await result.current.duplicateAgent(mockAgentId, 'Custom Title');
195
200
  });
196
201
 
197
- // default title is i18n based
198
- expect(sessionService.cloneSession).toHaveBeenCalledWith(
199
- mockAgentId,
200
- expect.stringContaining('Copy'),
201
- );
202
+ expect(agentService.duplicateAgent).toHaveBeenCalledWith(mockAgentId, 'Custom Title');
202
203
  });
203
204
  });
204
205
 
@@ -7,8 +7,8 @@ import { agentService } from '@/services/agent';
7
7
  import { chatGroupService } from '@/services/chatGroup';
8
8
  import { homeService } from '@/services/home';
9
9
  import { sessionService } from '@/services/session';
10
+ import { getAgentStoreState } from '@/store/agent';
10
11
  import type { HomeStore } from '@/store/home/store';
11
- import { getSessionStoreState } from '@/store/session';
12
12
  import { type SessionGroupItem } from '@/types/session';
13
13
  import { setNamespace } from '@/utils/storeDebug';
14
14
 
@@ -17,9 +17,13 @@ const n = setNamespace('sidebarUI');
17
17
  export interface SidebarUIAction {
18
18
  // ========== Agent Operations ==========
19
19
  /**
20
- * Duplicate an agent
20
+ * Duplicate an agent using agentService
21
21
  */
22
22
  duplicateAgent: (agentId: string, newTitle?: string) => Promise<void>;
23
+ /**
24
+ * Duplicate a chat group (multi-agent group)
25
+ */
26
+ duplicateAgentGroup: (groupId: string, newTitle?: string) => Promise<void>;
23
27
  /**
24
28
  * Pin or unpin an agent
25
29
  */
@@ -94,11 +98,35 @@ export const createSidebarUISlice: StateCreator<
94
98
  key: messageLoadingKey,
95
99
  });
96
100
 
97
- // Use provided title or generate default
98
- const title = newTitle ?? t('duplicateSession.title', { ns: 'chat', title: 'Agent' }) ?? 'Copy';
99
- const newId = await sessionService.cloneSession(agentId, title);
101
+ const result = await agentService.duplicateAgent(agentId, newTitle);
102
+
103
+ if (!result) {
104
+ message.destroy(messageLoadingKey);
105
+ message.error(t('copyFail', { ns: 'common' }));
106
+ return;
107
+ }
108
+
109
+ await get().refreshAgentList();
110
+ message.destroy(messageLoadingKey);
111
+ message.success(t('duplicateSession.success', { ns: 'chat' }));
112
+
113
+ // Switch to the new agent
114
+ const agentStore = getAgentStoreState();
115
+ agentStore.setActiveAgentId(result.agentId);
116
+ },
117
+
118
+ duplicateAgentGroup: async (groupId, newTitle?: string) => {
119
+ const messageLoadingKey = 'duplicateAgentGroup.loading';
120
+
121
+ message.loading({
122
+ content: t('duplicateSession.loading', { ns: 'chat' }),
123
+ duration: 0,
124
+ key: messageLoadingKey,
125
+ });
126
+
127
+ const result = await chatGroupService.duplicateGroup(groupId, newTitle);
100
128
 
101
- if (!newId) {
129
+ if (!result) {
102
130
  message.destroy(messageLoadingKey);
103
131
  message.error(t('copyFail', { ns: 'common' }));
104
132
  return;
@@ -108,9 +136,9 @@ export const createSidebarUISlice: StateCreator<
108
136
  message.destroy(messageLoadingKey);
109
137
  message.success(t('duplicateSession.success', { ns: 'chat' }));
110
138
 
111
- // Switch to new session
112
- const sessionStore = getSessionStoreState();
113
- sessionStore.switchSession(newId);
139
+ // Switch to the new group (using supervisor agent id)
140
+ const agentStore = getAgentStoreState();
141
+ agentStore.setActiveAgentId(result.supervisorAgentId);
114
142
  },
115
143
 
116
144
  pinAgent: async (agentId, pinned) => {
@@ -1,62 +0,0 @@
1
- import { type MenuProps } from '@lobehub/ui';
2
- import { useCallback } from 'react';
3
-
4
- import { useSessionItemMenuItems } from '../../../../hooks';
5
-
6
- interface ActionProps {
7
- group: string | undefined;
8
- id: string;
9
- openCreateGroupModal: () => void;
10
- parentType: 'agent' | 'group';
11
- pinned: boolean;
12
- sessionType?: string;
13
- toggleEditing: (visible?: boolean) => void;
14
- }
15
-
16
- export const useDropdownMenu = ({
17
- group,
18
- id,
19
- openCreateGroupModal,
20
- parentType,
21
- pinned,
22
- sessionType,
23
- toggleEditing,
24
- }: ActionProps): (() => MenuProps['items']) => {
25
- const {
26
- pinMenuItem,
27
- renameMenuItem,
28
- duplicateMenuItem,
29
- openInNewWindowMenuItem,
30
- moveToGroupMenuItem,
31
- deleteMenuItem,
32
- } = useSessionItemMenuItems();
33
-
34
- return useCallback(
35
- () =>
36
- [
37
- pinMenuItem(id, pinned, parentType),
38
- renameMenuItem(toggleEditing),
39
- duplicateMenuItem(id),
40
- openInNewWindowMenuItem(id),
41
- { type: 'divider' },
42
- moveToGroupMenuItem(id, group, openCreateGroupModal),
43
- { type: 'divider' },
44
- deleteMenuItem(id, parentType, sessionType),
45
- ].filter(Boolean) as MenuProps['items'],
46
- [
47
- id,
48
- pinned,
49
- parentType,
50
- group,
51
- sessionType,
52
- pinMenuItem,
53
- renameMenuItem,
54
- duplicateMenuItem,
55
- openInNewWindowMenuItem,
56
- moveToGroupMenuItem,
57
- deleteMenuItem,
58
- openCreateGroupModal,
59
- toggleEditing,
60
- ],
61
- );
62
- };
@@ -1,238 +0,0 @@
1
- import { SessionDefaultGroup } from '@lobechat/types';
2
- import { Icon } from '@lobehub/ui';
3
- import { App } from 'antd';
4
- import { createStaticStyles } from 'antd-style';
5
- import { type ItemType } from 'antd/es/menu/interface';
6
- import isEqual from 'fast-deep-equal';
7
- import {
8
- Check,
9
- FolderInputIcon,
10
- LucideCopy,
11
- LucidePlus,
12
- Pen,
13
- PictureInPicture2Icon,
14
- Pin,
15
- PinOff,
16
- Trash,
17
- } from 'lucide-react';
18
- import { useCallback } from 'react';
19
- import { useTranslation } from 'react-i18next';
20
-
21
- import { useGlobalStore } from '@/store/global';
22
- import { useHomeStore } from '@/store/home';
23
- import { homeAgentListSelectors } from '@/store/home/selectors';
24
-
25
- const styles = createStaticStyles(({ css }) => ({
26
- modalRoot: css`
27
- z-index: 2000;
28
- `,
29
- }));
30
-
31
- /**
32
- * Hook for generating menu items for individual session/agent items
33
- * Used in List/Item/Actions.tsx
34
- */
35
- export const useSessionItemMenuItems = () => {
36
- const { t } = useTranslation('chat');
37
- const { modal, message } = App.useApp();
38
-
39
- const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow);
40
- const sessionCustomGroups = useHomeStore(homeAgentListSelectors.agentGroups, isEqual);
41
-
42
- const [pinAgent, pinAgentGroup, duplicateAgent, updateAgentGroup, removeAgent, removeAgentGroup] =
43
- useHomeStore((s) => [
44
- s.pinAgent,
45
- s.pinAgentGroup,
46
- s.duplicateAgent,
47
- s.updateAgentGroup,
48
- s.removeAgent,
49
- s.removeAgentGroup,
50
- ]);
51
-
52
- /**
53
- * Pin/Unpin menu item
54
- */
55
- const pinMenuItem = useCallback(
56
- (id: string, isPinned: boolean, parentType: 'agent' | 'group'): ItemType => {
57
- const iconElement = <Icon icon={isPinned ? PinOff : Pin} />;
58
- return {
59
- icon: iconElement,
60
- key: 'pin',
61
- label: t(isPinned ? 'pinOff' : 'pin'),
62
- onClick: () => {
63
- if (parentType === 'group') {
64
- pinAgentGroup(id, !isPinned);
65
- } else {
66
- pinAgent(id, !isPinned);
67
- }
68
- },
69
- };
70
- },
71
- [t, pinAgentGroup, pinAgent],
72
- );
73
-
74
- /**
75
- * Rename session menu item
76
- */
77
- const renameMenuItem = useCallback(
78
- (onToggleEdit: (visible?: boolean) => void): ItemType => {
79
- const iconElement = <Icon icon={Pen} />;
80
- return {
81
- icon: iconElement,
82
- key: 'rename',
83
- label: t('rename', { ns: 'common' }),
84
- onClick: (info: any) => {
85
- info.domEvent?.stopPropagation();
86
- onToggleEdit(true);
87
- },
88
- };
89
- },
90
- [t],
91
- );
92
-
93
- /**
94
- * Duplicate session menu item
95
- */
96
- const duplicateMenuItem = useCallback(
97
- (id: string): ItemType => {
98
- const iconElement = <Icon icon={LucideCopy} />;
99
- return {
100
- icon: iconElement,
101
- key: 'duplicate',
102
- label: t('duplicate', { ns: 'common' }),
103
- onClick: ({ domEvent }: any) => {
104
- domEvent.stopPropagation();
105
- duplicateAgent(id);
106
- },
107
- };
108
- },
109
- [t, duplicateAgent],
110
- );
111
-
112
- /**
113
- * Open in new window menu item
114
- * Desktop: Opens in a new electron window
115
- * Browser: Opens in a popup window
116
- */
117
- const openInNewWindowMenuItem = useCallback(
118
- (id: string): ItemType => {
119
- const iconElement = <Icon icon={PictureInPicture2Icon} />;
120
- return {
121
- icon: iconElement,
122
- key: 'openInNewWindow',
123
- label: t('openInNewWindow'),
124
- onClick: ({ domEvent }: any) => {
125
- domEvent.stopPropagation();
126
- openAgentInNewWindow(id);
127
- },
128
- };
129
- },
130
- [t, openAgentInNewWindow],
131
- );
132
-
133
- /**
134
- * Move to group submenu item
135
- * Contains all custom groups, default list, and create new group option
136
- */
137
- const moveToGroupMenuItem = useCallback(
138
- (
139
- id: string,
140
- currentGroup: string | undefined,
141
- onOpenCreateGroupModal: () => void,
142
- ): ItemType => {
143
- const isDefault = currentGroup === SessionDefaultGroup.Default;
144
-
145
- const children = [
146
- ...sessionCustomGroups.map(({ id: groupId, name }) => {
147
- const checkIcon = currentGroup === groupId ? <Icon icon={Check} /> : <div />;
148
- return {
149
- icon: checkIcon,
150
- key: groupId,
151
- label: name,
152
- onClick: () => {
153
- updateAgentGroup(id, groupId);
154
- },
155
- };
156
- }),
157
- {
158
- icon: isDefault ? <Icon icon={Check} /> : <div />,
159
- key: 'defaultList',
160
- label: t('defaultList'),
161
- onClick: () => {
162
- updateAgentGroup(id, SessionDefaultGroup.Default);
163
- },
164
- },
165
- {
166
- type: 'divider' as const,
167
- },
168
- {
169
- icon: <Icon icon={LucidePlus} />,
170
- key: 'createGroup',
171
- label: <div>{t('sessionGroup.createGroup')}</div>,
172
- onClick: ({ domEvent }: any) => {
173
- domEvent.stopPropagation();
174
- onOpenCreateGroupModal();
175
- },
176
- },
177
- ];
178
-
179
- const folderIcon = <Icon icon={FolderInputIcon} />;
180
- return {
181
- children,
182
- icon: folderIcon,
183
- key: 'moveGroup',
184
- label: t('sessionGroup.moveGroup'),
185
- };
186
- },
187
- [t, sessionCustomGroups, updateAgentGroup],
188
- );
189
-
190
- /**
191
- * Delete menu item with confirmation modal
192
- * Handles both session and group types
193
- */
194
- const deleteMenuItem = useCallback(
195
- (id: string, parentType: 'agent' | 'group', sessionType?: string): ItemType => {
196
- const trashIcon = <Icon icon={Trash} />;
197
- return {
198
- danger: true,
199
- icon: trashIcon,
200
- key: 'delete',
201
- label: t('delete', { ns: 'common' }),
202
- onClick: ({ domEvent }: any) => {
203
- domEvent.stopPropagation();
204
- modal.confirm({
205
- centered: true,
206
- classNames: {
207
- root: styles.modalRoot,
208
- },
209
- okButtonProps: { danger: true },
210
- onOk: async () => {
211
- if (parentType === 'group') {
212
- await removeAgentGroup(id);
213
- message.success(t('confirmRemoveGroupSuccess'));
214
- } else {
215
- await removeAgent(id);
216
- message.success(t('confirmRemoveSessionSuccess'));
217
- }
218
- },
219
- title:
220
- sessionType === 'group'
221
- ? t('confirmRemoveChatGroupItemAlert')
222
- : t('confirmRemoveSessionItemAlert'),
223
- });
224
- },
225
- };
226
- },
227
- [t, modal, styles.modalRoot, removeAgentGroup, message, removeAgent],
228
- );
229
-
230
- return {
231
- deleteMenuItem,
232
- duplicateMenuItem,
233
- moveToGroupMenuItem,
234
- openInNewWindowMenuItem,
235
- pinMenuItem,
236
- renameMenuItem,
237
- };
238
- };