From bc8aea45c3028191c379bbf67494361a2cf7c1cf Mon Sep 17 00:00:00 2001 From: Arvin Xu Date: Sun, 11 Jan 2026 17:22:32 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20fix=20duplicate=20agent?= =?UTF-8?q?=20and=20group=20(#11411)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix duplicate agent * fix duplicate agent issue * improve tools * fix tests * update * fix testing * fix editor bug --- .../src/models/__tests__/agent.test.ts | 169 +++++- packages/database/src/models/agent.ts | 46 ++ .../src/repositories/agentGroup/index.test.ts | 498 ++++++++++++++++++ .../src/repositories/agentGroup/index.ts | 150 ++++++ .../repositories/home/__tests__/index.test.ts | 114 +++- .../database/src/repositories/home/index.ts | 115 ++-- pnpm-workspace.yaml | 1 + .../Conversation/MainChatInput/index.tsx | 4 +- .../Body/Agent/List/AgentGroupItem/index.tsx | 8 +- .../List/AgentGroupItem/useDropdownMenu.tsx | 100 ++++ .../Body/Agent/List/AgentItem/index.tsx | 6 +- .../Agent/List/AgentItem/useDropdownMenu.tsx | 149 ++++++ .../Body/Agent/List/Item/useDropdownMenu.tsx | 62 --- .../(main)/home/_layout/hooks/index.ts | 1 - .../_layout/hooks/useSessionItemMenuItems.tsx | 238 --------- .../(main)/home/features/InputArea/index.tsx | 2 +- src/features/EditorCanvas/DiffAllToolbar.tsx | 2 +- src/server/routers/lambda/agent.ts | 15 + src/server/routers/lambda/agentGroup.ts | 16 + src/services/agent.ts | 11 + src/services/chatGroup/index.ts | 11 + .../home/slices/sidebarUI/action.test.ts | 45 +- src/store/home/slices/sidebarUI/action.ts | 46 +- 23 files changed, 1391 insertions(+), 418 deletions(-) create mode 100644 src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/useDropdownMenu.tsx create mode 100644 src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/useDropdownMenu.tsx delete mode 100644 src/app/[variants]/(main)/home/_layout/Body/Agent/List/Item/useDropdownMenu.tsx delete mode 100644 src/app/[variants]/(main)/home/_layout/hooks/useSessionItemMenuItems.tsx diff --git a/packages/database/src/models/__tests__/agent.test.ts b/packages/database/src/models/__tests__/agent.test.ts index 46c942c63c..971f0f9cba 100644 --- a/packages/database/src/models/__tests__/agent.test.ts +++ b/packages/database/src/models/__tests__/agent.test.ts @@ -3,7 +3,9 @@ import { INBOX_SESSION_ID } from '@lobechat/const'; import { eq } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getTestDB } from '../../core/getTestDB'; import { + NewAgent, agents, agentsFiles, agentsKnowledgeBases, @@ -11,12 +13,12 @@ import { documents, files, knowledgeBases, + sessionGroups, sessions, users, } from '../../schemas'; import { LobeChatDatabase } from '../../type'; import { AgentModel } from '../agent'; -import { getTestDB } from '../../core/getTestDB'; const serverDB: LobeChatDatabase = await getTestDB(); @@ -290,9 +292,7 @@ describe('AgentModel', () => { // Create agent and session for user2 await serverDB.insert(agents).values({ id: agentId, userId: userId2 }); await serverDB.insert(sessions).values({ id: sessionId, userId: userId2 }); - await serverDB - .insert(agentsToSessions) - .values({ agentId, sessionId, userId: userId2 }); + await serverDB.insert(agentsToSessions).values({ agentId, sessionId, userId: userId2 }); // Try to access with user1's model const result = await agentModel.findBySessionId(sessionId); @@ -1390,6 +1390,167 @@ describe('AgentModel', () => { }); }); + describe('duplicate', () => { + it('should duplicate an agent with all config fields', async () => { + // Create source agent with full config + const [sourceAgent] = await serverDB + .insert(agents) + .values({ + userId, + title: 'Original Agent', + description: 'Original description', + tags: ['tag1', 'tag2'], + avatar: 'avatar-url', + backgroundColor: '#ffffff', + plugins: ['plugin1'], + model: 'gpt-4', + provider: 'openai', + systemRole: 'You are helpful', + openingMessage: 'Hello!', + openingQuestions: ['Q1', 'Q2'], + chatConfig: { historyCount: 10 }, + fewShots: [{ role: 'user', content: 'test' }], + params: { temperature: 0.7 }, + tts: { showAllLocaleVoice: true }, + } as NewAgent) + .returning(); + + const result = await agentModel.duplicate(sourceAgent.id); + + expect(result).toBeDefined(); + expect(result?.agentId).toBeDefined(); + expect(result?.agentId).not.toBe(sourceAgent.id); + + // Verify the duplicated agent + const duplicatedAgent = await serverDB.query.agents.findFirst({ + where: eq(agents.id, result!.agentId), + }); + + expect(duplicatedAgent).toEqual( + expect.objectContaining({ + // Should be copied + title: 'Original Agent (Copy)', + description: 'Original description', + tags: ['tag1', 'tag2'], + avatar: 'avatar-url', + backgroundColor: '#ffffff', + plugins: ['plugin1'], + model: 'gpt-4', + provider: 'openai', + systemRole: 'You are helpful', + openingMessage: 'Hello!', + openingQuestions: ['Q1', 'Q2'], + chatConfig: { historyCount: 10 }, + fewShots: [{ role: 'user', content: 'test' }], + params: { temperature: 0.7 }, + tts: { showAllLocaleVoice: true }, + sessionGroupId: null, + userId, + // Should NOT be copied (new values) + virtual: false, + pinned: null, + clientId: null, + editorData: null, + marketIdentifier: null, + }), + ); + + // Verify these are NOT copied from source + expect(duplicatedAgent?.id).not.toBe(sourceAgent.id); + expect(duplicatedAgent?.slug).not.toBe(sourceAgent.slug); + }); + + it('should use provided title when duplicating', async () => { + const [sourceAgent] = await serverDB + .insert(agents) + .values({ userId, title: 'Original' }) + .returning(); + + const result = await agentModel.duplicate(sourceAgent.id, 'Custom Title'); + + const duplicatedAgent = await serverDB.query.agents.findFirst({ + where: eq(agents.id, result!.agentId), + }); + + expect(duplicatedAgent?.title).toBe('Custom Title'); + }); + + it('should return null for non-existent agent', async () => { + const result = await agentModel.duplicate('non-existent-id'); + + expect(result).toBeNull(); + }); + + it('should not duplicate another user agent', async () => { + const [sourceAgent] = await serverDB + .insert(agents) + .values({ userId: userId2, title: 'User2 Agent' }) + .returning(); + + const result = await agentModel.duplicate(sourceAgent.id); + + expect(result).toBeNull(); + }); + + it('should not copy marketIdentifier, slug, or id', async () => { + const [sourceAgent] = await serverDB + .insert(agents) + .values({ + userId, + title: 'Original', + slug: 'original-slug', + marketIdentifier: 'market-123', + }) + .returning(); + + const result = await agentModel.duplicate(sourceAgent.id); + + const duplicatedAgent = await serverDB.query.agents.findFirst({ + where: eq(agents.id, result!.agentId), + }); + + expect(duplicatedAgent?.id).not.toBe(sourceAgent.id); + expect(duplicatedAgent?.slug).not.toBe('original-slug'); + expect(duplicatedAgent?.marketIdentifier).toBeNull(); + }); + + it('should preserve sessionGroupId when duplicating', async () => { + // Create a session group + const [sessionGroup] = await serverDB + .insert(sessionGroups) + .values({ userId, name: 'Test Group' }) + .returning(); + + const [sourceAgent] = await serverDB + .insert(agents) + .values({ userId, title: 'Agent in Group', sessionGroupId: sessionGroup.id }) + .returning(); + + const result = await agentModel.duplicate(sourceAgent.id); + + const duplicatedAgent = await serverDB.query.agents.findFirst({ + where: eq(agents.id, result!.agentId), + }); + + expect(duplicatedAgent?.sessionGroupId).toBe(sessionGroup.id); + }); + + it('should handle agent with null title', async () => { + const [sourceAgent] = await serverDB + .insert(agents) + .values({ userId, title: null }) + .returning(); + + const result = await agentModel.duplicate(sourceAgent.id); + + const duplicatedAgent = await serverDB.query.agents.findFirst({ + where: eq(agents.id, result!.agentId), + }); + + expect(duplicatedAgent?.title).toBe('Copy'); + }); + }); + describe('queryAgents', () => { it('should return non-virtual agents for the user', async () => { // Create non-virtual agents diff --git a/packages/database/src/models/agent.ts b/packages/database/src/models/agent.ts index dd78828d8d..1c608a3677 100644 --- a/packages/database/src/models/agent.ts +++ b/packages/database/src/models/agent.ts @@ -452,6 +452,52 @@ export class AgentModel { return result[0]; }; + /** + * Duplicate an agent. + * Returns the new agent ID. + */ + duplicate = async (agentId: string, newTitle?: string): Promise<{ agentId: string } | null> => { + // Get the source agent + const sourceAgent = await this.db.query.agents.findFirst({ + where: and(eq(agents.id, agentId), eq(agents.userId, this.userId)), + }); + + if (!sourceAgent) return null; + + // Create new agent with explicit include fields + const [newAgent] = await this.db + .insert(agents) + .values({ + avatar: sourceAgent.avatar, + backgroundColor: sourceAgent.backgroundColor, + chatConfig: sourceAgent.chatConfig, + description: sourceAgent.description, + fewShots: sourceAgent.fewShots, + model: sourceAgent.model, + openingMessage: sourceAgent.openingMessage, + openingQuestions: sourceAgent.openingQuestions, + params: sourceAgent.params, + pinned: sourceAgent.pinned, + // Config + plugins: sourceAgent.plugins, + provider: sourceAgent.provider, + + // Session group + sessionGroupId: sourceAgent.sessionGroupId, + systemRole: sourceAgent.systemRole, + + tags: sourceAgent.tags, + // Metadata + title: newTitle || (sourceAgent.title ? `${sourceAgent.title} (Copy)` : 'Copy'), + tts: sourceAgent.tts, + // User + userId: this.userId, + }) + .returning(); + + return { agentId: newAgent.id }; + }; + /** * Get a builtin agent by slug, creating it if it doesn't exist. * Builtin agents are standalone agents not bound to sessions. diff --git a/packages/database/src/repositories/agentGroup/index.test.ts b/packages/database/src/repositories/agentGroup/index.test.ts index ba7014a009..9858431a02 100644 --- a/packages/database/src/repositories/agentGroup/index.test.ts +++ b/packages/database/src/repositories/agentGroup/index.test.ts @@ -766,4 +766,502 @@ describe('AgentGroupRepository', () => { expect(virtualAgents).toHaveLength(0); }); }); + + describe('duplicate', () => { + it('should duplicate a group with all config fields', async () => { + // Create source group with full config + await serverDB.insert(chatGroups).values({ + config: { + maxResponseInRow: 5, + orchestratorModel: 'gpt-4o', + orchestratorProvider: 'openai', + responseOrder: 'sequential', + scene: 'productive', + }, + id: 'source-group', + pinned: true, + title: 'Source Group', + userId, + }); + + // Create supervisor agent + await serverDB.insert(agents).values({ + id: 'source-supervisor', + model: 'gpt-4o', + provider: 'openai', + title: 'Supervisor', + userId, + virtual: true, + }); + + // Link supervisor to group + await serverDB.insert(chatGroupsAgents).values({ + agentId: 'source-supervisor', + chatGroupId: 'source-group', + order: -1, + role: 'supervisor', + userId, + }); + + const result = await agentGroupRepo.duplicate('source-group'); + + expect(result).not.toBeNull(); + expect(result!.groupId).toBeDefined(); + expect(result!.supervisorAgentId).toBeDefined(); + expect(result!.groupId).not.toBe('source-group'); + expect(result!.supervisorAgentId).not.toBe('source-supervisor'); + + // Verify duplicated group has correct config + const duplicatedGroup = await serverDB.query.chatGroups.findFirst({ + where: (cg, { eq }) => eq(cg.id, result!.groupId), + }); + + expect(duplicatedGroup).toEqual( + expect.objectContaining({ + config: { + maxResponseInRow: 5, + orchestratorModel: 'gpt-4o', + orchestratorProvider: 'openai', + responseOrder: 'sequential', + scene: 'productive', + }, + pinned: true, + title: 'Source Group (Copy)', + userId, + }), + ); + }); + + it('should duplicate group with custom title', async () => { + await serverDB.insert(chatGroups).values({ + id: 'title-group', + title: 'Original Title', + userId, + }); + + await serverDB.insert(agents).values({ + id: 'title-supervisor', + title: 'Supervisor', + userId, + virtual: true, + }); + + await serverDB.insert(chatGroupsAgents).values({ + agentId: 'title-supervisor', + chatGroupId: 'title-group', + order: -1, + role: 'supervisor', + userId, + }); + + const result = await agentGroupRepo.duplicate('title-group', 'Custom New Title'); + + expect(result).not.toBeNull(); + + const duplicatedGroup = await serverDB.query.chatGroups.findFirst({ + where: (cg, { eq }) => eq(cg.id, result!.groupId), + }); + + expect(duplicatedGroup!.title).toBe('Custom New Title'); + }); + + it('should copy virtual member agents (create new agents)', async () => { + // Create source group + await serverDB.insert(chatGroups).values({ + id: 'virtual-member-group', + title: 'Virtual Member Group', + userId, + }); + + // Create supervisor and virtual member agents + await serverDB.insert(agents).values([ + { + id: 'vm-supervisor', + title: 'Supervisor', + userId, + virtual: true, + }, + { + avatar: 'virtual-avatar.png', + backgroundColor: '#ff0000', + description: 'Virtual member description', + id: 'vm-virtual-member', + model: 'gpt-4', + provider: 'openai', + systemRole: 'You are a virtual assistant', + tags: ['tag1', 'tag2'], + title: 'Virtual Member', + userId, + virtual: true, + }, + ]); + + // Link agents to group + await serverDB.insert(chatGroupsAgents).values([ + { + agentId: 'vm-supervisor', + chatGroupId: 'virtual-member-group', + order: -1, + role: 'supervisor', + userId, + }, + { + agentId: 'vm-virtual-member', + chatGroupId: 'virtual-member-group', + enabled: true, + order: 0, + role: 'participant', + userId, + }, + ]); + + const result = await agentGroupRepo.duplicate('virtual-member-group'); + + expect(result).not.toBeNull(); + + // Verify new group has agents + const groupAgents = await serverDB.query.chatGroupsAgents.findMany({ + where: (cga, { eq }) => eq(cga.chatGroupId, result!.groupId), + }); + + // 1 supervisor + 1 virtual member + expect(groupAgents).toHaveLength(2); + + // Verify virtual member agent was copied (new agent created) + const virtualMemberRelation = groupAgents.find( + (ga) => ga.role === 'participant' && ga.agentId !== 'vm-virtual-member', + ); + expect(virtualMemberRelation).toBeDefined(); + + // Verify copied agent has all fields + const copiedAgent = await serverDB.query.agents.findFirst({ + where: (a, { eq }) => eq(a.id, virtualMemberRelation!.agentId), + }); + + expect(copiedAgent).toEqual( + expect.objectContaining({ + avatar: 'virtual-avatar.png', + backgroundColor: '#ff0000', + description: 'Virtual member description', + model: 'gpt-4', + provider: 'openai', + systemRole: 'You are a virtual assistant', + tags: ['tag1', 'tag2'], + title: 'Virtual Member', + userId, + virtual: true, + }), + ); + + // Verify original virtual member still exists + const originalAgent = await serverDB.query.agents.findFirst({ + where: (a, { eq }) => eq(a.id, 'vm-virtual-member'), + }); + expect(originalAgent).toBeDefined(); + }); + + it('should reference non-virtual member agents (only add relationship)', async () => { + // Create source group + await serverDB.insert(chatGroups).values({ + id: 'nonvirtual-member-group', + title: 'Non-Virtual Member Group', + userId, + }); + + // Create supervisor and non-virtual member agents + await serverDB.insert(agents).values([ + { + id: 'nvm-supervisor', + title: 'Supervisor', + userId, + virtual: true, + }, + { + description: 'Regular agent description', + id: 'nvm-regular-member', + model: 'claude-3-opus', + provider: 'anthropic', + title: 'Regular Member', + userId, + virtual: false, + }, + ]); + + // Link agents to group + await serverDB.insert(chatGroupsAgents).values([ + { + agentId: 'nvm-supervisor', + chatGroupId: 'nonvirtual-member-group', + order: -1, + role: 'supervisor', + userId, + }, + { + agentId: 'nvm-regular-member', + chatGroupId: 'nonvirtual-member-group', + enabled: true, + order: 0, + role: 'participant', + userId, + }, + ]); + + const result = await agentGroupRepo.duplicate('nonvirtual-member-group'); + + expect(result).not.toBeNull(); + + // Verify new group has agents + const groupAgents = await serverDB.query.chatGroupsAgents.findMany({ + where: (cga, { eq }) => eq(cga.chatGroupId, result!.groupId), + }); + + // 1 supervisor + 1 non-virtual member + expect(groupAgents).toHaveLength(2); + + // Verify non-virtual member uses the SAME agent ID (just added relationship) + const regularMemberRelation = groupAgents.find((ga) => ga.agentId === 'nvm-regular-member'); + expect(regularMemberRelation).toBeDefined(); + expect(regularMemberRelation!.role).toBe('participant'); + expect(regularMemberRelation!.enabled).toBe(true); + + // Verify no new agent was created for the regular member + const allAgentsWithTitle = await serverDB.query.agents.findMany({ + where: (a, { and, eq }) => and(eq(a.userId, userId), eq(a.title, 'Regular Member')), + }); + // Should only have the original one + expect(allAgentsWithTitle).toHaveLength(1); + expect(allAgentsWithTitle[0].id).toBe('nvm-regular-member'); + }); + + it('should handle mixed virtual and non-virtual members', async () => { + // Create source group + await serverDB.insert(chatGroups).values({ + id: 'mixed-member-group', + title: 'Mixed Member Group', + userId, + }); + + // Create supervisor, virtual member, and non-virtual member agents + await serverDB.insert(agents).values([ + { id: 'mixed-supervisor', title: 'Supervisor', userId, virtual: true }, + { id: 'mixed-virtual', title: 'Virtual Agent', userId, virtual: true }, + { id: 'mixed-regular', title: 'Regular Agent', userId, virtual: false }, + ]); + + // Link agents to group + await serverDB.insert(chatGroupsAgents).values([ + { + agentId: 'mixed-supervisor', + chatGroupId: 'mixed-member-group', + order: -1, + role: 'supervisor', + userId, + }, + { + agentId: 'mixed-virtual', + chatGroupId: 'mixed-member-group', + order: 0, + role: 'participant', + userId, + }, + { + agentId: 'mixed-regular', + chatGroupId: 'mixed-member-group', + order: 1, + role: 'participant', + userId, + }, + ]); + + const result = await agentGroupRepo.duplicate('mixed-member-group'); + + expect(result).not.toBeNull(); + + const groupAgents = await serverDB.query.chatGroupsAgents.findMany({ + where: (cga, { eq }) => eq(cga.chatGroupId, result!.groupId), + }); + + // 1 supervisor + 1 virtual (copied) + 1 non-virtual (referenced) + expect(groupAgents).toHaveLength(3); + + // Verify non-virtual member references original agent + const regularRelation = groupAgents.find((ga) => ga.agentId === 'mixed-regular'); + expect(regularRelation).toBeDefined(); + + // Verify virtual member was copied (new agent ID) + const virtualRelation = groupAgents.find( + (ga) => ga.role === 'participant' && ga.agentId !== 'mixed-regular', + ); + expect(virtualRelation).toBeDefined(); + expect(virtualRelation!.agentId).not.toBe('mixed-virtual'); + }); + + it('should return null for non-existent group', async () => { + const result = await agentGroupRepo.duplicate('non-existent-group'); + + expect(result).toBeNull(); + }); + + it('should not duplicate group belonging to another user', async () => { + // Create group for other user + await serverDB.insert(chatGroups).values({ + id: 'other-user-dup-group', + title: 'Other User Group', + userId: otherUserId, + }); + + const result = await agentGroupRepo.duplicate('other-user-dup-group'); + + expect(result).toBeNull(); + }); + + it('should preserve member order in duplicated group', async () => { + // Create source group + await serverDB.insert(chatGroups).values({ + id: 'order-group', + title: 'Order Group', + userId, + }); + + // Create agents + await serverDB.insert(agents).values([ + { id: 'order-supervisor', title: 'Supervisor', userId, virtual: true }, + { id: 'order-agent-1', title: 'Agent 1', userId, virtual: false }, + { id: 'order-agent-2', title: 'Agent 2', userId, virtual: false }, + { id: 'order-agent-3', title: 'Agent 3', userId, virtual: false }, + ]); + + // Link agents with specific order + await serverDB.insert(chatGroupsAgents).values([ + { + agentId: 'order-supervisor', + chatGroupId: 'order-group', + order: -1, + role: 'supervisor', + userId, + }, + { + agentId: 'order-agent-1', + chatGroupId: 'order-group', + order: 2, + role: 'participant', + userId, + }, + { + agentId: 'order-agent-2', + chatGroupId: 'order-group', + order: 0, + role: 'participant', + userId, + }, + { + agentId: 'order-agent-3', + chatGroupId: 'order-group', + order: 1, + role: 'participant', + userId, + }, + ]); + + const result = await agentGroupRepo.duplicate('order-group'); + + expect(result).not.toBeNull(); + + const groupAgents = await serverDB.query.chatGroupsAgents.findMany({ + where: (cga, { eq }) => eq(cga.chatGroupId, result!.groupId), + }); + + // Verify order is preserved + const supervisorRelation = groupAgents.find((ga) => ga.role === 'supervisor'); + expect(supervisorRelation!.order).toBe(-1); + + const agent1Relation = groupAgents.find((ga) => ga.agentId === 'order-agent-1'); + expect(agent1Relation!.order).toBe(2); + + const agent2Relation = groupAgents.find((ga) => ga.agentId === 'order-agent-2'); + expect(agent2Relation!.order).toBe(0); + + const agent3Relation = groupAgents.find((ga) => ga.agentId === 'order-agent-3'); + expect(agent3Relation!.order).toBe(1); + }); + + it('should duplicate group with default title when source has no title', async () => { + // Create source group without title + await serverDB.insert(chatGroups).values({ + id: 'no-title-group', + title: null, + userId, + }); + + await serverDB.insert(agents).values({ + id: 'no-title-supervisor', + title: 'Supervisor', + userId, + virtual: true, + }); + + await serverDB.insert(chatGroupsAgents).values({ + agentId: 'no-title-supervisor', + chatGroupId: 'no-title-group', + order: -1, + role: 'supervisor', + userId, + }); + + const result = await agentGroupRepo.duplicate('no-title-group'); + + expect(result).not.toBeNull(); + + const duplicatedGroup = await serverDB.query.chatGroups.findFirst({ + where: (cg, { eq }) => eq(cg.id, result!.groupId), + }); + + expect(duplicatedGroup!.title).toBe('Copy'); + }); + + it('should create new supervisor agent with source supervisor config', async () => { + // Create source group + await serverDB.insert(chatGroups).values({ + id: 'supervisor-config-group', + title: 'Supervisor Config Group', + userId, + }); + + // Create supervisor with specific config + await serverDB.insert(agents).values({ + id: 'source-supervisor-with-config', + model: 'claude-3-opus', + provider: 'anthropic', + title: 'Custom Supervisor', + userId, + virtual: true, + }); + + await serverDB.insert(chatGroupsAgents).values({ + agentId: 'source-supervisor-with-config', + chatGroupId: 'supervisor-config-group', + order: -1, + role: 'supervisor', + userId, + }); + + const result = await agentGroupRepo.duplicate('supervisor-config-group'); + + expect(result).not.toBeNull(); + + // Verify new supervisor has same config + const newSupervisor = await serverDB.query.agents.findFirst({ + where: (a, { eq }) => eq(a.id, result!.supervisorAgentId), + }); + + expect(newSupervisor).toEqual( + expect.objectContaining({ + model: 'claude-3-opus', + provider: 'anthropic', + title: 'Custom Supervisor', + virtual: true, + }), + ); + }); + }); }); diff --git a/packages/database/src/repositories/agentGroup/index.ts b/packages/database/src/repositories/agentGroup/index.ts index 1257ffb5a9..23921c4572 100644 --- a/packages/database/src/repositories/agentGroup/index.ts +++ b/packages/database/src/repositories/agentGroup/index.ts @@ -304,4 +304,154 @@ export class AgentGroupRepository { removedFromGroup: agentIds.length, }; } + + /** + * Duplicate a chat group with all its members. + * - Creates a new group with the same config + * - Creates a new supervisor agent + * - For virtual member agents: creates new copies + * - For non-virtual member agents: adds relationship only (references same agents) + * + * @param groupId - The chat group ID to duplicate + * @param newTitle - Optional new title for the duplicated group + * @returns The new group ID and supervisor agent ID, or null if source not found + */ + async duplicate( + groupId: string, + newTitle?: string, + ): Promise<{ groupId: string; supervisorAgentId: string } | null> { + // 1. Get the source group + const sourceGroup = await this.db.query.chatGroups.findFirst({ + where: and(eq(chatGroups.id, groupId), eq(chatGroups.userId, this.userId)), + }); + + if (!sourceGroup) return null; + + // 2. Get all agents in the group with their details + const groupAgentsWithDetails = await this.db + .select({ + agent: agents, + enabled: chatGroupsAgents.enabled, + order: chatGroupsAgents.order, + role: chatGroupsAgents.role, + }) + .from(chatGroupsAgents) + .innerJoin(agents, eq(chatGroupsAgents.agentId, agents.id)) + .where(eq(chatGroupsAgents.chatGroupId, groupId)) + .orderBy(chatGroupsAgents.order); + + // 3. Separate supervisor, virtual members, and non-virtual members + let sourceSupervisor: (typeof groupAgentsWithDetails)[number] | undefined; + const virtualMembers: (typeof groupAgentsWithDetails)[number][] = []; + const nonVirtualMembers: (typeof groupAgentsWithDetails)[number][] = []; + + for (const row of groupAgentsWithDetails) { + if (row.role === 'supervisor') { + sourceSupervisor = row; + } else if (row.agent.virtual) { + virtualMembers.push(row); + } else { + nonVirtualMembers.push(row); + } + } + + // Use transaction to ensure atomicity + return this.db.transaction(async (trx) => { + // 4. Create the new group + const [newGroup] = await trx + .insert(chatGroups) + .values({ + config: sourceGroup.config, + pinned: sourceGroup.pinned, + title: newTitle || (sourceGroup.title ? `${sourceGroup.title} (Copy)` : 'Copy'), + userId: this.userId, + }) + .returning(); + + // 5. Create new supervisor agent + const supervisorAgent = sourceSupervisor?.agent; + const [newSupervisor] = await trx + .insert(agents) + .values({ + model: supervisorAgent?.model, + provider: supervisorAgent?.provider, + title: supervisorAgent?.title || 'Supervisor', + userId: this.userId, + virtual: true, + }) + .returning(); + + // 6. Create copies of virtual member agents using include mode + const newVirtualAgentMap = new Map(); // oldId -> newId + if (virtualMembers.length > 0) { + const virtualAgentConfigs = virtualMembers.map((member) => ({ + // Metadata + avatar: member.agent.avatar, + backgroundColor: member.agent.backgroundColor, + // Config + chatConfig: member.agent.chatConfig, + description: member.agent.description, + fewShots: member.agent.fewShots, + + model: member.agent.model, + openingMessage: member.agent.openingMessage, + openingQuestions: member.agent.openingQuestions, + params: member.agent.params, + plugins: member.agent.plugins, + provider: member.agent.provider, + systemRole: member.agent.systemRole, + tags: member.agent.tags, + title: member.agent.title, + tts: member.agent.tts, + // User & virtual flag + userId: this.userId, + virtual: true, + })); + + const newVirtualAgents = await trx.insert(agents).values(virtualAgentConfigs).returning(); + + // Map old agent IDs to new agent IDs + for (const [i, virtualMember] of virtualMembers.entries()) { + newVirtualAgentMap.set(virtualMember.agent.id, newVirtualAgents[i].id); + } + } + + // 7. Create group-agent relationships + const groupAgentValues: NewChatGroupAgent[] = [ + // Supervisor + { + agentId: newSupervisor.id, + chatGroupId: newGroup.id, + order: -1, + role: 'supervisor', + userId: this.userId, + }, + // Virtual members (using new copied agents) + ...virtualMembers.map((member) => ({ + agentId: newVirtualAgentMap.get(member.agent.id)!, + chatGroupId: newGroup.id, + enabled: member.enabled, + order: member.order, + role: member.role || 'participant', + userId: this.userId, + })), + // Non-virtual members (referencing same agents - only add relationship) + ...nonVirtualMembers.map((member) => ({ + agentId: member.agent.id, + chatGroupId: newGroup.id, + enabled: member.enabled, + order: member.order, + role: member.role || 'participant', + userId: this.userId, + })), + ]; + + await trx.insert(chatGroupsAgents).values(groupAgentValues); + + return { + groupId: newGroup.id, + supervisorAgentId: newSupervisor.id, + }; + }); + } } diff --git a/packages/database/src/repositories/home/__tests__/index.test.ts b/packages/database/src/repositories/home/__tests__/index.test.ts index ba93feadef..9b7a00e451 100644 --- a/packages/database/src/repositories/home/__tests__/index.test.ts +++ b/packages/database/src/repositories/home/__tests__/index.test.ts @@ -36,6 +36,86 @@ describe('HomeRepository', () => { expect(result.groups).toEqual([]); }); + it('should return non-virtual agents without agentsToSessions relationship', async () => { + // Create an agent without session relationship (e.g., duplicated agent) + const agentId = 'standalone-agent'; + + await clientDB.insert(Schema.agents).values({ + id: agentId, + userId, + title: 'Standalone Agent', + description: 'Agent without session', + pinned: false, + virtual: false, + }); + + const result = await homeRepo.getSidebarAgentList(); + + // Agent should appear in ungrouped list even without agentsToSessions + expect(result.ungrouped).toHaveLength(1); + expect(result.ungrouped[0].id).toBe(agentId); + expect(result.ungrouped[0].title).toBe('Standalone Agent'); + }); + + it('should return pinned non-virtual agents without agentsToSessions relationship', async () => { + // Create a pinned agent without session relationship + const agentId = 'pinned-standalone'; + + await clientDB.insert(Schema.agents).values({ + id: agentId, + userId, + title: 'Pinned Standalone Agent', + pinned: true, + virtual: false, + }); + + const result = await homeRepo.getSidebarAgentList(); + + // Agent should appear in pinned list + expect(result.pinned).toHaveLength(1); + expect(result.pinned[0].id).toBe(agentId); + expect(result.pinned[0].pinned).toBe(true); + }); + + it('should return mixed agents with and without session relationships', async () => { + // Agent with session + await clientDB.transaction(async (tx) => { + await tx.insert(Schema.agents).values({ + id: 'with-session', + userId, + title: 'Agent With Session', + pinned: false, + virtual: false, + }); + await tx.insert(Schema.sessions).values({ + id: 'session-1', + slug: 'session-1', + userId, + }); + await tx.insert(Schema.agentsToSessions).values({ + agentId: 'with-session', + sessionId: 'session-1', + userId, + }); + }); + + // Agent without session (e.g., duplicated) + await clientDB.insert(Schema.agents).values({ + id: 'without-session', + userId, + title: 'Agent Without Session', + pinned: false, + virtual: false, + }); + + const result = await homeRepo.getSidebarAgentList(); + + // Both agents should appear + expect(result.ungrouped).toHaveLength(2); + expect(result.ungrouped.map((a) => a.id)).toContain('with-session'); + expect(result.ungrouped.map((a) => a.id)).toContain('without-session'); + }); + it('should return agents with pinned status from agents table', async () => { // Create an agent with pinned=true const agentId = 'agent-1'; @@ -380,6 +460,16 @@ describe('HomeRepository', () => { sessionId: 'session-search-unpinned', userId, }); + + // Agent without session (e.g., duplicated agent) + await tx.insert(Schema.agents).values({ + id: 'search-standalone', + userId, + title: 'Standalone Searchable Agent', + description: 'A standalone agent without session', + pinned: false, + virtual: false, + }); }); }); @@ -388,6 +478,24 @@ describe('HomeRepository', () => { expect(result).toEqual([]); }); + it('should search agents without agentsToSessions relationship', async () => { + const result = await homeRepo.searchAgents('Standalone'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('search-standalone'); + expect(result[0].title).toBe('Standalone Searchable Agent'); + }); + + it('should search and return mixed agents with and without session relationships', async () => { + // Search for "Searchable" should return all 3 agents + const result = await homeRepo.searchAgents('Searchable'); + + expect(result).toHaveLength(3); + expect(result.map((a) => a.id)).toContain('search-pinned'); + expect(result.map((a) => a.id)).toContain('search-unpinned'); + expect(result.map((a) => a.id)).toContain('search-standalone'); + }); + it('should search agents by title and return correct pinned status', async () => { const result = await homeRepo.searchAgents('Searchable Pinned'); @@ -407,15 +515,19 @@ describe('HomeRepository', () => { it('should return multiple matching agents with correct pinned status', async () => { const result = await homeRepo.searchAgents('Searchable'); - expect(result).toHaveLength(2); + // 3 agents: search-pinned, search-unpinned, search-standalone + expect(result).toHaveLength(3); const pinnedAgent = result.find((a) => a.id === 'search-pinned'); const unpinnedAgent = result.find((a) => a.id === 'search-unpinned'); + const standaloneAgent = result.find((a) => a.id === 'search-standalone'); expect(pinnedAgent).toBeDefined(); expect(pinnedAgent!.pinned).toBe(true); expect(unpinnedAgent).toBeDefined(); expect(unpinnedAgent!.pinned).toBe(false); + expect(standaloneAgent).toBeDefined(); + expect(standaloneAgent!.pinned).toBe(false); }); it('should not return virtual agents in search', async () => { diff --git a/packages/database/src/repositories/home/index.ts b/packages/database/src/repositories/home/index.ts index 196544d52b..ca9315e4bf 100644 --- a/packages/database/src/repositories/home/index.ts +++ b/packages/database/src/repositories/home/index.ts @@ -1,6 +1,6 @@ import { SidebarAgentItem, SidebarAgentListResponse, SidebarGroup } from '@lobechat/types'; import { cleanObject } from '@lobechat/utils'; -import { and, desc, eq, ilike, inArray, or } from 'drizzle-orm'; +import { and, desc, eq, ilike, inArray, not, or } from 'drizzle-orm'; import { agents, @@ -36,11 +36,7 @@ export class HomeRepository { * Get sidebar agent list with pinned, grouped, and ungrouped items */ async getSidebarAgentList(): Promise { - // 1. Query all agents (non-virtual) with their session info - // Note: We query both agents.pinned and sessions.pinned for backward compatibility - // agents.pinned takes priority, falling back to sessions.pinned for legacy data - // Note: We query both agents.sessionGroupId and sessions.groupId for backward compatibility - // agents.sessionGroupId takes priority, falling back to sessions.groupId for legacy data + // 1. Query all agents (non-virtual) with their session info (if exists) const agentList = await this.db .select({ agentSessionGroupId: agents.sessionGroupId, @@ -55,9 +51,9 @@ export class HomeRepository { updatedAt: agents.updatedAt, }) .from(agents) - .innerJoin(agentsToSessions, eq(agents.id, agentsToSessions.agentId)) - .innerJoin(sessions, eq(agentsToSessions.sessionId, sessions.id)) - .where(and(eq(agents.userId, this.userId), eq(agents.virtual, false))) + .leftJoin(agentsToSessions, eq(agents.id, agentsToSessions.agentId)) + .leftJoin(sessions, eq(agentsToSessions.sessionId, sessions.id)) + .where(and(eq(agents.userId, this.userId), not(eq(agents.virtual, true)))) .orderBy(desc(agents.updatedAt)); // 2. Query all chatGroups (group chats) @@ -75,32 +71,7 @@ export class HomeRepository { .orderBy(desc(chatGroups.updatedAt)); // 2.1 Query member avatars for each chat group - const chatGroupIds = chatGroupList.map((g) => g.id); - const memberAvatarsMap = new Map>(); - - if (chatGroupIds.length > 0) { - const memberAvatars = await this.db - .select({ - avatar: agents.avatar, - backgroundColor: agents.backgroundColor, - chatGroupId: chatGroupsAgents.chatGroupId, - }) - .from(chatGroupsAgents) - .innerJoin(agents, eq(chatGroupsAgents.agentId, agents.id)) - .where(inArray(chatGroupsAgents.chatGroupId, chatGroupIds)) - .orderBy(chatGroupsAgents.order); - - for (const member of memberAvatars) { - const existing = memberAvatarsMap.get(member.chatGroupId) || []; - if (member.avatar) { - existing.push({ - avatar: member.avatar, - background: member.backgroundColor ?? undefined, - }); - } - memberAvatarsMap.set(member.chatGroupId, existing); - } - } + const memberAvatarsMap = await this.getChatGroupMemberAvatars(chatGroupList.map((g) => g.id)); // 3. Query all sessionGroups (user-defined folders) const groupList = await this.db @@ -125,7 +96,7 @@ export class HomeRepository { id: string; pinned: boolean | null; sessionGroupId: string | null; - sessionId: string; + sessionId: string | null; sessionPinned: boolean | null; title: string | null; updatedAt: Date; @@ -217,7 +188,6 @@ export class HomeRepository { const searchPattern = `%${keyword.toLowerCase()}%`; // 1. Search agents by title or description - // Note: We query both agents.pinned and sessions.pinned for backward compatibility const agentResults = await this.db .select({ avatar: agents.avatar, @@ -230,12 +200,12 @@ export class HomeRepository { updatedAt: agents.updatedAt, }) .from(agents) - .innerJoin(agentsToSessions, eq(agents.id, agentsToSessions.agentId)) - .innerJoin(sessions, eq(agentsToSessions.sessionId, sessions.id)) + .leftJoin(agentsToSessions, eq(agents.id, agentsToSessions.agentId)) + .leftJoin(sessions, eq(agentsToSessions.sessionId, sessions.id)) .where( and( eq(agents.userId, this.userId), - eq(agents.virtual, false), + not(eq(agents.virtual, true)), or(ilike(agents.title, searchPattern), ilike(agents.description, searchPattern)), ), ) @@ -260,35 +230,11 @@ export class HomeRepository { .orderBy(desc(chatGroups.updatedAt)); // 2.1 Query member avatars for matching chat groups - const chatGroupIds = chatGroupResults.map((g) => g.id); - const memberAvatarsMap = new Map>(); - - if (chatGroupIds.length > 0) { - const memberAvatars = await this.db - .select({ - avatar: agents.avatar, - backgroundColor: agents.backgroundColor, - chatGroupId: chatGroupsAgents.chatGroupId, - }) - .from(chatGroupsAgents) - .innerJoin(agents, eq(chatGroupsAgents.agentId, agents.id)) - .where(inArray(chatGroupsAgents.chatGroupId, chatGroupIds)) - .orderBy(chatGroupsAgents.order); - - for (const member of memberAvatars) { - const existing = memberAvatarsMap.get(member.chatGroupId) || []; - if (member.avatar) { - existing.push({ - avatar: member.avatar, - background: member.backgroundColor ?? undefined, - }); - } - memberAvatarsMap.set(member.chatGroupId, existing); - } - } + const memberAvatarsMap = await this.getChatGroupMemberAvatars( + chatGroupResults.map((g) => g.id), + ); // 3. Combine and format results - // For pinned status: agents.pinned takes priority, fallback to sessions.pinned for backward compatibility const results: SidebarAgentItem[] = [ ...agentResults.map((a) => cleanObject({ @@ -320,4 +266,39 @@ export class HomeRepository { return results; } + + /** + * Query member avatars for chat groups + */ + private async getChatGroupMemberAvatars( + chatGroupIds: string[], + ): Promise>> { + const memberAvatarsMap = new Map>(); + + if (chatGroupIds.length === 0) return memberAvatarsMap; + + const memberAvatars = await this.db + .select({ + avatar: agents.avatar, + backgroundColor: agents.backgroundColor, + chatGroupId: chatGroupsAgents.chatGroupId, + }) + .from(chatGroupsAgents) + .innerJoin(agents, eq(chatGroupsAgents.agentId, agents.id)) + .where(inArray(chatGroupsAgents.chatGroupId, chatGroupIds)) + .orderBy(chatGroupsAgents.order); + + for (const member of memberAvatars) { + const existing = memberAvatarsMap.get(member.chatGroupId) || []; + if (member.avatar) { + existing.push({ + avatar: member.avatar, + background: member.backgroundColor ?? undefined, + }); + } + memberAvatarsMap.set(member.chatGroupId, existing); + } + + return memberAvatarsMap; + } } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index bd82441b54..117132673d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,7 @@ packages: onlyBuiltDependencies: - '@vercel/speed-insights' + - '@lobehub/editor' overrides: '@lobehub/chat-plugin-sdk>swagger-client': 3.36.0 diff --git a/src/app/[variants]/(main)/agent/features/Conversation/MainChatInput/index.tsx b/src/app/[variants]/(main)/agent/features/Conversation/MainChatInput/index.tsx index b6df38ab71..e9d3a68f80 100644 --- a/src/app/[variants]/(main)/agent/features/Conversation/MainChatInput/index.tsx +++ b/src/app/[variants]/(main)/agent/features/Conversation/MainChatInput/index.tsx @@ -11,10 +11,10 @@ import { useSendMenuItems } from './useSendMenuItems'; const leftActions: ActionKeys[] = [ 'model', 'search', - 'typo', 'fileUpload', + 'tools', '---', - ['tools', 'params', 'clear'], + ['typo', 'params', 'clear'], 'mainToken', ]; diff --git a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx index f81361bb9b..8721b4e30a 100644 --- a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx +++ b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx @@ -13,8 +13,8 @@ import { useGlobalStore } from '@/store/global'; import { useHomeStore } from '@/store/home'; import Actions from '../Item/Actions'; -import { useDropdownMenu } from '../Item/useDropdownMenu'; import Editing from './Editing'; +import { useGroupDropdownMenu } from './useDropdownMenu'; interface GroupItemProps { className?: string; @@ -85,13 +85,9 @@ const GroupItem = memo(({ item, style, className }) => { return ; }, [isUpdating, avatar]); - const dropdownMenu = useDropdownMenu({ - group: undefined, + const dropdownMenu = useGroupDropdownMenu({ id, - openCreateGroupModal: () => {}, // Groups don't need this - parentType: 'group', pinned: pinned ?? false, - sessionType: 'group', toggleEditing, }); diff --git a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/useDropdownMenu.tsx b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/useDropdownMenu.tsx new file mode 100644 index 0000000000..485c7d9f38 --- /dev/null +++ b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/useDropdownMenu.tsx @@ -0,0 +1,100 @@ +import { Icon, type MenuProps } from '@lobehub/ui'; +import { App } from 'antd'; +import { LucideCopy, Pen, PictureInPicture2Icon, Pin, PinOff, Trash } from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useGlobalStore } from '@/store/global'; +import { useHomeStore } from '@/store/home'; + +interface UseGroupDropdownMenuParams { + id: string; + pinned: boolean; + toggleEditing: (visible?: boolean) => void; +} + +export const useGroupDropdownMenu = ({ + id, + pinned, + toggleEditing, +}: UseGroupDropdownMenuParams): (() => MenuProps['items']) => { + const { t } = useTranslation('chat'); + const { modal, message } = App.useApp(); + + const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow); + const [pinAgentGroup, duplicateAgentGroup, removeAgentGroup] = useHomeStore((s) => [ + s.pinAgentGroup, + s.duplicateAgentGroup, + s.removeAgentGroup, + ]); + + return useMemo( + () => () => + [ + { + icon: , + key: 'pin', + label: t(pinned ? 'pinOff' : 'pin'), + onClick: () => pinAgentGroup(id, !pinned), + }, + { + icon: , + key: 'rename', + label: t('rename', { ns: 'common' }), + onClick: (info: any) => { + info.domEvent?.stopPropagation(); + toggleEditing(true); + }, + }, + { + icon: , + key: 'duplicate', + label: t('duplicate', { ns: 'common' }), + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + duplicateAgentGroup(id); + }, + }, + { + icon: , + key: 'openInNewWindow', + label: t('openInNewWindow'), + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + openAgentInNewWindow(id); + }, + }, + { type: 'divider' }, + { + danger: true, + icon: , + key: 'delete', + label: t('delete', { ns: 'common' }), + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + modal.confirm({ + centered: true, + okButtonProps: { danger: true }, + onOk: async () => { + await removeAgentGroup(id); + message.success(t('confirmRemoveGroupSuccess')); + }, + title: t('confirmRemoveChatGroupItemAlert'), + }); + }, + }, + ] as MenuProps['items'], + [ + t, + pinned, + pinAgentGroup, + id, + toggleEditing, + duplicateAgentGroup, + openAgentInNewWindow, + modal, + removeAgentGroup, + message, + ], + ); +}; diff --git a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx index 15cb282fe7..fdc218401f 100644 --- a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx +++ b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/index.tsx @@ -15,9 +15,9 @@ import { useHomeStore } from '@/store/home'; import { useAgentModal } from '../../ModalProvider'; import Actions from '../Item/Actions'; -import { useDropdownMenu } from '../Item/useDropdownMenu'; import Avatar from './Avatar'; import Editing from './Editing'; +import { useAgentDropdownMenu } from './useDropdownMenu'; interface AgentItemProps { className?: string; @@ -96,13 +96,11 @@ const AgentItem = memo(({ item, style, className }) => { return ; }, [isUpdating, avatar]); - const dropdownMenu = useDropdownMenu({ + const dropdownMenu = useAgentDropdownMenu({ group: undefined, // TODO: pass group from parent if needed id, openCreateGroupModal: handleOpenCreateGroupModal, - parentType: 'agent', pinned: pinned ?? false, - sessionType: 'agent', toggleEditing, }); diff --git a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/useDropdownMenu.tsx b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/useDropdownMenu.tsx new file mode 100644 index 0000000000..e1e3627c34 --- /dev/null +++ b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentItem/useDropdownMenu.tsx @@ -0,0 +1,149 @@ +import { SessionDefaultGroup } from '@lobechat/types'; +import { Icon, type MenuProps } from '@lobehub/ui'; +import { App } from 'antd'; +import isEqual from 'fast-deep-equal'; +import { + Check, + FolderInputIcon, + LucideCopy, + LucidePlus, + Pen, + PictureInPicture2Icon, + Pin, + PinOff, + Trash, +} from 'lucide-react'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useGlobalStore } from '@/store/global'; +import { useHomeStore } from '@/store/home'; +import { homeAgentListSelectors } from '@/store/home/selectors'; + +interface UseAgentDropdownMenuParams { + group: string | undefined; + id: string; + openCreateGroupModal: () => void; + pinned: boolean; + toggleEditing: (visible?: boolean) => void; +} + +export const useAgentDropdownMenu = ({ + group, + id, + openCreateGroupModal, + pinned, + toggleEditing, +}: UseAgentDropdownMenuParams): (() => MenuProps['items']) => { + const { t } = useTranslation('chat'); + const { modal, message } = App.useApp(); + + const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow); + const sessionCustomGroups = useHomeStore(homeAgentListSelectors.agentGroups, isEqual); + const [pinAgent, duplicateAgent, updateAgentGroup, removeAgent] = useHomeStore((s) => [ + s.pinAgent, + s.duplicateAgent, + s.updateAgentGroup, + s.removeAgent, + ]); + + const isDefault = group === SessionDefaultGroup.Default; + + return useMemo( + () => () => + [ + { + icon: , + key: 'pin', + label: t(pinned ? 'pinOff' : 'pin'), + onClick: () => pinAgent(id, !pinned), + }, + { + icon: , + key: 'rename', + label: t('rename', { ns: 'common' }), + onClick: (info: any) => { + info.domEvent?.stopPropagation(); + toggleEditing(true); + }, + }, + { + icon: , + key: 'duplicate', + label: t('duplicate', { ns: 'common' }), + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + duplicateAgent(id); + }, + }, + { + icon: , + key: 'openInNewWindow', + label: t('openInNewWindow'), + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + openAgentInNewWindow(id); + }, + }, + { type: 'divider' }, + { + children: [ + ...sessionCustomGroups.map(({ id: groupId, name }) => ({ + icon: group === groupId ? :
, + key: groupId, + label: name, + onClick: () => updateAgentGroup(id, groupId), + })), + { + icon: isDefault ? :
, + key: 'defaultList', + label: t('defaultList'), + onClick: () => updateAgentGroup(id, SessionDefaultGroup.Default), + }, + { type: 'divider' as const }, + { + icon: , + key: 'createGroup', + label:
{t('sessionGroup.createGroup')}
, + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + openCreateGroupModal(); + }, + }, + ], + icon: , + key: 'moveGroup', + label: t('sessionGroup.moveGroup'), + }, + { type: 'divider' }, + { + danger: true, + icon: , + key: 'delete', + label: t('delete', { ns: 'common' }), + onClick: ({ domEvent }: any) => { + domEvent.stopPropagation(); + modal.confirm({ + centered: true, + okButtonProps: { danger: true }, + onOk: async () => { + await removeAgent(id); + message.success(t('confirmRemoveSessionSuccess')); + }, + title: t('confirmRemoveSessionItemAlert'), + }); + }, + }, + ] as MenuProps['items'], + [ + pinned, + id, + toggleEditing, + sessionCustomGroups, + group, + isDefault, + openCreateGroupModal, + message, + ], + ); +}; diff --git a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/Item/useDropdownMenu.tsx b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/Item/useDropdownMenu.tsx deleted file mode 100644 index cd8dbbfd6c..0000000000 --- a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/Item/useDropdownMenu.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { type MenuProps } from '@lobehub/ui'; -import { useCallback } from 'react'; - -import { useSessionItemMenuItems } from '../../../../hooks'; - -interface ActionProps { - group: string | undefined; - id: string; - openCreateGroupModal: () => void; - parentType: 'agent' | 'group'; - pinned: boolean; - sessionType?: string; - toggleEditing: (visible?: boolean) => void; -} - -export const useDropdownMenu = ({ - group, - id, - openCreateGroupModal, - parentType, - pinned, - sessionType, - toggleEditing, -}: ActionProps): (() => MenuProps['items']) => { - const { - pinMenuItem, - renameMenuItem, - duplicateMenuItem, - openInNewWindowMenuItem, - moveToGroupMenuItem, - deleteMenuItem, - } = useSessionItemMenuItems(); - - return useCallback( - () => - [ - pinMenuItem(id, pinned, parentType), - renameMenuItem(toggleEditing), - duplicateMenuItem(id), - openInNewWindowMenuItem(id), - { type: 'divider' }, - moveToGroupMenuItem(id, group, openCreateGroupModal), - { type: 'divider' }, - deleteMenuItem(id, parentType, sessionType), - ].filter(Boolean) as MenuProps['items'], - [ - id, - pinned, - parentType, - group, - sessionType, - pinMenuItem, - renameMenuItem, - duplicateMenuItem, - openInNewWindowMenuItem, - moveToGroupMenuItem, - deleteMenuItem, - openCreateGroupModal, - toggleEditing, - ], - ); -}; diff --git a/src/app/[variants]/(main)/home/_layout/hooks/index.ts b/src/app/[variants]/(main)/home/_layout/hooks/index.ts index d61031a174..fe3bbfbfee 100644 --- a/src/app/[variants]/(main)/home/_layout/hooks/index.ts +++ b/src/app/[variants]/(main)/home/_layout/hooks/index.ts @@ -1,4 +1,3 @@ export { useCreateMenuItems } from './useCreateMenuItems'; export { useProjectMenuItems } from './useProjectMenuItems'; export { useSessionGroupMenuItems } from './useSessionGroupMenuItems'; -export { useSessionItemMenuItems } from './useSessionItemMenuItems'; diff --git a/src/app/[variants]/(main)/home/_layout/hooks/useSessionItemMenuItems.tsx b/src/app/[variants]/(main)/home/_layout/hooks/useSessionItemMenuItems.tsx deleted file mode 100644 index a79a767c8b..0000000000 --- a/src/app/[variants]/(main)/home/_layout/hooks/useSessionItemMenuItems.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { SessionDefaultGroup } from '@lobechat/types'; -import { Icon } from '@lobehub/ui'; -import { App } from 'antd'; -import { createStaticStyles } from 'antd-style'; -import { type ItemType } from 'antd/es/menu/interface'; -import isEqual from 'fast-deep-equal'; -import { - Check, - FolderInputIcon, - LucideCopy, - LucidePlus, - Pen, - PictureInPicture2Icon, - Pin, - PinOff, - Trash, -} from 'lucide-react'; -import { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useGlobalStore } from '@/store/global'; -import { useHomeStore } from '@/store/home'; -import { homeAgentListSelectors } from '@/store/home/selectors'; - -const styles = createStaticStyles(({ css }) => ({ - modalRoot: css` - z-index: 2000; - `, -})); - -/** - * Hook for generating menu items for individual session/agent items - * Used in List/Item/Actions.tsx - */ -export const useSessionItemMenuItems = () => { - const { t } = useTranslation('chat'); - const { modal, message } = App.useApp(); - - const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow); - const sessionCustomGroups = useHomeStore(homeAgentListSelectors.agentGroups, isEqual); - - const [pinAgent, pinAgentGroup, duplicateAgent, updateAgentGroup, removeAgent, removeAgentGroup] = - useHomeStore((s) => [ - s.pinAgent, - s.pinAgentGroup, - s.duplicateAgent, - s.updateAgentGroup, - s.removeAgent, - s.removeAgentGroup, - ]); - - /** - * Pin/Unpin menu item - */ - const pinMenuItem = useCallback( - (id: string, isPinned: boolean, parentType: 'agent' | 'group'): ItemType => { - const iconElement = ; - return { - icon: iconElement, - key: 'pin', - label: t(isPinned ? 'pinOff' : 'pin'), - onClick: () => { - if (parentType === 'group') { - pinAgentGroup(id, !isPinned); - } else { - pinAgent(id, !isPinned); - } - }, - }; - }, - [t, pinAgentGroup, pinAgent], - ); - - /** - * Rename session menu item - */ - const renameMenuItem = useCallback( - (onToggleEdit: (visible?: boolean) => void): ItemType => { - const iconElement = ; - return { - icon: iconElement, - key: 'rename', - label: t('rename', { ns: 'common' }), - onClick: (info: any) => { - info.domEvent?.stopPropagation(); - onToggleEdit(true); - }, - }; - }, - [t], - ); - - /** - * Duplicate session menu item - */ - const duplicateMenuItem = useCallback( - (id: string): ItemType => { - const iconElement = ; - return { - icon: iconElement, - key: 'duplicate', - label: t('duplicate', { ns: 'common' }), - onClick: ({ domEvent }: any) => { - domEvent.stopPropagation(); - duplicateAgent(id); - }, - }; - }, - [t, duplicateAgent], - ); - - /** - * Open in new window menu item - * Desktop: Opens in a new electron window - * Browser: Opens in a popup window - */ - const openInNewWindowMenuItem = useCallback( - (id: string): ItemType => { - const iconElement = ; - return { - icon: iconElement, - key: 'openInNewWindow', - label: t('openInNewWindow'), - onClick: ({ domEvent }: any) => { - domEvent.stopPropagation(); - openAgentInNewWindow(id); - }, - }; - }, - [t, openAgentInNewWindow], - ); - - /** - * Move to group submenu item - * Contains all custom groups, default list, and create new group option - */ - const moveToGroupMenuItem = useCallback( - ( - id: string, - currentGroup: string | undefined, - onOpenCreateGroupModal: () => void, - ): ItemType => { - const isDefault = currentGroup === SessionDefaultGroup.Default; - - const children = [ - ...sessionCustomGroups.map(({ id: groupId, name }) => { - const checkIcon = currentGroup === groupId ? :
; - return { - icon: checkIcon, - key: groupId, - label: name, - onClick: () => { - updateAgentGroup(id, groupId); - }, - }; - }), - { - icon: isDefault ? :
, - key: 'defaultList', - label: t('defaultList'), - onClick: () => { - updateAgentGroup(id, SessionDefaultGroup.Default); - }, - }, - { - type: 'divider' as const, - }, - { - icon: , - key: 'createGroup', - label:
{t('sessionGroup.createGroup')}
, - onClick: ({ domEvent }: any) => { - domEvent.stopPropagation(); - onOpenCreateGroupModal(); - }, - }, - ]; - - const folderIcon = ; - return { - children, - icon: folderIcon, - key: 'moveGroup', - label: t('sessionGroup.moveGroup'), - }; - }, - [t, sessionCustomGroups, updateAgentGroup], - ); - - /** - * Delete menu item with confirmation modal - * Handles both session and group types - */ - const deleteMenuItem = useCallback( - (id: string, parentType: 'agent' | 'group', sessionType?: string): ItemType => { - const trashIcon = ; - return { - danger: true, - icon: trashIcon, - key: 'delete', - label: t('delete', { ns: 'common' }), - onClick: ({ domEvent }: any) => { - domEvent.stopPropagation(); - modal.confirm({ - centered: true, - classNames: { - root: styles.modalRoot, - }, - okButtonProps: { danger: true }, - onOk: async () => { - if (parentType === 'group') { - await removeAgentGroup(id); - message.success(t('confirmRemoveGroupSuccess')); - } else { - await removeAgent(id); - message.success(t('confirmRemoveSessionSuccess')); - } - }, - title: - sessionType === 'group' - ? t('confirmRemoveChatGroupItemAlert') - : t('confirmRemoveSessionItemAlert'), - }); - }, - }; - }, - [t, modal, styles.modalRoot, removeAgentGroup, message, removeAgent], - ); - - return { - deleteMenuItem, - duplicateMenuItem, - moveToGroupMenuItem, - openInNewWindowMenuItem, - pinMenuItem, - renameMenuItem, - }; -}; diff --git a/src/app/[variants]/(main)/home/features/InputArea/index.tsx b/src/app/[variants]/(main)/home/features/InputArea/index.tsx index 6f1873fe67..613a86af83 100644 --- a/src/app/[variants]/(main)/home/features/InputArea/index.tsx +++ b/src/app/[variants]/(main)/home/features/InputArea/index.tsx @@ -12,7 +12,7 @@ import ModeHeader from './ModeHeader'; import StarterList from './StarterList'; import { useSend } from './useSend'; -const leftActions: ActionKeys[] = ['model', 'search', 'fileUpload']; +const leftActions: ActionKeys[] = ['model', 'search', 'fileUpload', 'tools']; const InputArea = () => { const { loading, send, inboxAgentId } = useSend(); diff --git a/src/features/EditorCanvas/DiffAllToolbar.tsx b/src/features/EditorCanvas/DiffAllToolbar.tsx index fb85a21d33..e8a3cf1832 100644 --- a/src/features/EditorCanvas/DiffAllToolbar.tsx +++ b/src/features/EditorCanvas/DiffAllToolbar.tsx @@ -36,7 +36,7 @@ const styles = createStaticStyles(({ css }) => ({ })); const useIsEditorInit = (editor: IEditor) => { - const [isEditInit, setEditInit] = useState(!!editor.getLexicalEditor()); + const [isEditInit, setEditInit] = useState(!!editor?.getLexicalEditor()); useEffect(() => { if (!editor) return; diff --git a/src/server/routers/lambda/agent.ts b/src/server/routers/lambda/agent.ts index 1223d4ecfe..a5dfc9e3a9 100644 --- a/src/server/routers/lambda/agent.ts +++ b/src/server/routers/lambda/agent.ts @@ -130,6 +130,21 @@ export const agentRouter = router({ return ctx.agentModel.deleteAgentKnowledgeBase(input.agentId, input.knowledgeBaseId); }), + /** + * Duplicate an agent and its associated session. + * Returns the new agent ID and session ID. + */ + duplicateAgent: agentProcedure + .input( + z.object({ + agentId: z.string(), + newTitle: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.agentModel.duplicate(input.agentId, input.newTitle); + }), + /** * Get an agent by marketIdentifier * @returns agent id if exists, null otherwise diff --git a/src/server/routers/lambda/agentGroup.ts b/src/server/routers/lambda/agentGroup.ts index eb566297d0..5629cce7e8 100644 --- a/src/server/routers/lambda/agentGroup.ts +++ b/src/server/routers/lambda/agentGroup.ts @@ -126,6 +126,22 @@ export const agentGroupRouter = router({ return ctx.agentGroupService.deleteGroup(input.id); }), + /** + * Duplicate a chat group with all its members. + * Creates a new group with the same config, a new supervisor, and copies of virtual members. + * Non-virtual members are referenced (not copied). + */ + duplicateGroup: agentGroupProcedure + .input( + z.object({ + groupId: z.string(), + newTitle: z.string().optional(), + }), + ) + .mutation(async ({ input, ctx }) => { + return ctx.agentGroupRepo.duplicate(input.groupId, input.newTitle); + }), + getGroup: agentGroupProcedure .input(z.object({ id: z.string() })) .query(async ({ input, ctx }) => { diff --git a/src/services/agent.ts b/src/services/agent.ts index d14ed00775..70c331f881 100644 --- a/src/services/agent.ts +++ b/src/services/agent.ts @@ -185,6 +185,17 @@ class AgentService { updateAgentPinned = async (agentId: string, pinned: boolean) => { return lambdaClient.agent.updateAgentPinned.mutate({ id: agentId, pinned }); }; + + /** + * Duplicate an agent. + * Returns the new agent ID. + */ + duplicateAgent = async ( + agentId: string, + newTitle?: string, + ): Promise<{ agentId: string } | null> => { + return lambdaClient.agent.duplicateAgent.mutate({ agentId, newTitle }); + }; } export const agentService = new AgentService(); diff --git a/src/services/chatGroup/index.ts b/src/services/chatGroup/index.ts index a7c36d08d3..53201cb94e 100644 --- a/src/services/chatGroup/index.ts +++ b/src/services/chatGroup/index.ts @@ -107,6 +107,17 @@ class ChatGroupService { getGroupAgents = (groupId: string): Promise => { return lambdaClient.group.getGroupAgents.query({ groupId }); }; + + /** + * Duplicate a chat group with all its members. + * Returns the new group ID and supervisor agent ID. + */ + duplicateGroup = ( + groupId: string, + newTitle?: string, + ): Promise<{ groupId: string; supervisorAgentId: string } | null> => { + return lambdaClient.group.duplicateGroup.mutate({ groupId, newTitle }); + }; } export const chatGroupService = new ChatGroupService(); diff --git a/src/store/home/slices/sidebarUI/action.test.ts b/src/store/home/slices/sidebarUI/action.test.ts index 03aa7259a9..4f1b75bdac 100644 --- a/src/store/home/slices/sidebarUI/action.test.ts +++ b/src/store/home/slices/sidebarUI/action.test.ts @@ -6,6 +6,7 @@ import { agentService } from '@/services/agent'; import { chatGroupService } from '@/services/chatGroup'; import { homeService } from '@/services/home'; import { sessionService } from '@/services/session'; +import { getAgentStoreState } from '@/store/agent'; import { useHomeStore } from '@/store/home'; import { getSessionStoreState } from '@/store/session'; @@ -26,6 +27,12 @@ vi.mock('@/store/session', () => ({ })), })); +vi.mock('@/store/agent', () => ({ + getAgentStoreState: vi.fn(() => ({ + setActiveAgentId: vi.fn(), + })), +})); + afterEach(() => { vi.restoreAllMocks(); }); @@ -136,17 +143,16 @@ describe('createSidebarUISlice', () => { }); describe('duplicateAgent', () => { - it('should duplicate an agent and switch to the new session', async () => { + it('should duplicate an agent and switch to the new agent', async () => { const mockAgentId = 'agent-123'; - const mockNewId = 'new-agent-456'; - const mockSwitchSession = vi.fn(); + const mockNewAgentId = 'new-agent-456'; + const mockSetActiveAgentId = vi.fn(); - vi.mocked(getSessionStoreState).mockReturnValue({ - activeId: 'other-agent', - switchSession: mockSwitchSession, + vi.mocked(getAgentStoreState).mockReturnValue({ + setActiveAgentId: mockSetActiveAgentId, } as any); - vi.spyOn(sessionService, 'cloneSession').mockResolvedValueOnce(mockNewId); + vi.spyOn(agentService, 'duplicateAgent').mockResolvedValueOnce({ agentId: mockNewAgentId }); const spyOnRefresh = vi.spyOn(useHomeStore.getState(), 'refreshAgentList'); const { result } = renderHook(() => useHomeStore()); @@ -155,16 +161,16 @@ describe('createSidebarUISlice', () => { await result.current.duplicateAgent(mockAgentId, 'Copied Agent'); }); - expect(sessionService.cloneSession).toHaveBeenCalledWith(mockAgentId, 'Copied Agent'); + expect(agentService.duplicateAgent).toHaveBeenCalledWith(mockAgentId, 'Copied Agent'); expect(spyOnRefresh).toHaveBeenCalled(); - expect(mockSwitchSession).toHaveBeenCalledWith(mockNewId); + expect(mockSetActiveAgentId).toHaveBeenCalledWith(mockNewAgentId); }); it('should show error message when duplication fails', async () => { const mockAgentId = 'agent-123'; const { message } = await import('@/components/AntdStaticMethods'); - vi.spyOn(sessionService, 'cloneSession').mockResolvedValueOnce(undefined); + vi.spyOn(agentService, 'duplicateAgent').mockResolvedValueOnce(null); vi.spyOn(useHomeStore.getState(), 'refreshAgentList'); const { result } = renderHook(() => useHomeStore()); @@ -176,29 +182,24 @@ describe('createSidebarUISlice', () => { expect(message.error).toHaveBeenCalled(); }); - it('should use default title when not provided', async () => { + it('should use provided title when duplicating', async () => { const mockAgentId = 'agent-123'; - const mockNewId = 'new-agent-456'; + const mockNewAgentId = 'new-agent-456'; - vi.mocked(getSessionStoreState).mockReturnValue({ - activeId: 'other-agent', - switchSession: vi.fn(), + vi.mocked(getAgentStoreState).mockReturnValue({ + setActiveAgentId: vi.fn(), } as any); - vi.spyOn(sessionService, 'cloneSession').mockResolvedValueOnce(mockNewId); + vi.spyOn(agentService, 'duplicateAgent').mockResolvedValueOnce({ agentId: mockNewAgentId }); vi.spyOn(useHomeStore.getState(), 'refreshAgentList'); const { result } = renderHook(() => useHomeStore()); await act(async () => { - await result.current.duplicateAgent(mockAgentId); + await result.current.duplicateAgent(mockAgentId, 'Custom Title'); }); - // default title is i18n based - expect(sessionService.cloneSession).toHaveBeenCalledWith( - mockAgentId, - expect.stringContaining('Copy'), - ); + expect(agentService.duplicateAgent).toHaveBeenCalledWith(mockAgentId, 'Custom Title'); }); }); diff --git a/src/store/home/slices/sidebarUI/action.ts b/src/store/home/slices/sidebarUI/action.ts index 56ca6396a1..41104186dd 100644 --- a/src/store/home/slices/sidebarUI/action.ts +++ b/src/store/home/slices/sidebarUI/action.ts @@ -7,8 +7,8 @@ import { agentService } from '@/services/agent'; import { chatGroupService } from '@/services/chatGroup'; import { homeService } from '@/services/home'; import { sessionService } from '@/services/session'; +import { getAgentStoreState } from '@/store/agent'; import type { HomeStore } from '@/store/home/store'; -import { getSessionStoreState } from '@/store/session'; import { type SessionGroupItem } from '@/types/session'; import { setNamespace } from '@/utils/storeDebug'; @@ -17,9 +17,13 @@ const n = setNamespace('sidebarUI'); export interface SidebarUIAction { // ========== Agent Operations ========== /** - * Duplicate an agent + * Duplicate an agent using agentService */ duplicateAgent: (agentId: string, newTitle?: string) => Promise; + /** + * Duplicate a chat group (multi-agent group) + */ + duplicateAgentGroup: (groupId: string, newTitle?: string) => Promise; /** * Pin or unpin an agent */ @@ -94,11 +98,9 @@ export const createSidebarUISlice: StateCreator< key: messageLoadingKey, }); - // Use provided title or generate default - const title = newTitle ?? t('duplicateSession.title', { ns: 'chat', title: 'Agent' }) ?? 'Copy'; - const newId = await sessionService.cloneSession(agentId, title); + const result = await agentService.duplicateAgent(agentId, newTitle); - if (!newId) { + if (!result) { message.destroy(messageLoadingKey); message.error(t('copyFail', { ns: 'common' })); return; @@ -108,9 +110,35 @@ export const createSidebarUISlice: StateCreator< message.destroy(messageLoadingKey); message.success(t('duplicateSession.success', { ns: 'chat' })); - // Switch to new session - const sessionStore = getSessionStoreState(); - sessionStore.switchSession(newId); + // Switch to the new agent + const agentStore = getAgentStoreState(); + agentStore.setActiveAgentId(result.agentId); + }, + + duplicateAgentGroup: async (groupId, newTitle?: string) => { + const messageLoadingKey = 'duplicateAgentGroup.loading'; + + message.loading({ + content: t('duplicateSession.loading', { ns: 'chat' }), + duration: 0, + key: messageLoadingKey, + }); + + const result = await chatGroupService.duplicateGroup(groupId, newTitle); + + if (!result) { + message.destroy(messageLoadingKey); + message.error(t('copyFail', { ns: 'common' })); + return; + } + + await get().refreshAgentList(); + message.destroy(messageLoadingKey); + message.success(t('duplicateSession.success', { ns: 'chat' })); + + // Switch to the new group (using supervisor agent id) + const agentStore = getAgentStoreState(); + agentStore.setActiveAgentId(result.supervisorAgentId); }, pinAgent: async (agentId, pinned) => {