🐛 fix: fix duplicate agent and group (#11411)

* fix duplicate agent

* fix duplicate agent issue

* improve tools

* fix tests

* update

* fix testing

* fix editor bug
This commit is contained in:
Arvin Xu
2026-01-11 17:22:32 +08:00
committed by GitHub
parent 67a81141df
commit bc8aea45c3
23 changed files with 1391 additions and 418 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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,
}),
);
});
});
});

View File

@@ -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<string, string>(); // 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,
};
});
}
}

View File

@@ -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 () => {

View File

@@ -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<SidebarAgentListResponse> {
// 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<string, Array<{ avatar: string; background?: string }>>();
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<string, Array<{ avatar: string; background?: string }>>();
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<Map<string, Array<{ avatar: string; background?: string }>>> {
const memberAvatarsMap = new Map<string, Array<{ avatar: string; background?: string }>>();
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;
}
}

View File

@@ -6,6 +6,7 @@ packages:
onlyBuiltDependencies:
- '@vercel/speed-insights'
- '@lobehub/editor'
overrides:
'@lobehub/chat-plugin-sdk>swagger-client': 3.36.0

View File

@@ -11,10 +11,10 @@ import { useSendMenuItems } from './useSendMenuItems';
const leftActions: ActionKeys[] = [
'model',
'search',
'typo',
'fileUpload',
'tools',
'---',
['tools', 'params', 'clear'],
['typo', 'params', 'clear'],
'mainToken',
];

View File

@@ -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<GroupItemProps>(({ item, style, className }) => {
return <GroupAvatar avatars={(avatar as any) || []} size={22} />;
}, [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,
});

View File

@@ -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: <Icon icon={pinned ? PinOff : Pin} />,
key: 'pin',
label: t(pinned ? 'pinOff' : 'pin'),
onClick: () => pinAgentGroup(id, !pinned),
},
{
icon: <Icon icon={Pen} />,
key: 'rename',
label: t('rename', { ns: 'common' }),
onClick: (info: any) => {
info.domEvent?.stopPropagation();
toggleEditing(true);
},
},
{
icon: <Icon icon={LucideCopy} />,
key: 'duplicate',
label: t('duplicate', { ns: 'common' }),
onClick: ({ domEvent }: any) => {
domEvent.stopPropagation();
duplicateAgentGroup(id);
},
},
{
icon: <Icon icon={PictureInPicture2Icon} />,
key: 'openInNewWindow',
label: t('openInNewWindow'),
onClick: ({ domEvent }: any) => {
domEvent.stopPropagation();
openAgentInNewWindow(id);
},
},
{ type: 'divider' },
{
danger: true,
icon: <Icon icon={Trash} />,
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,
],
);
};

View File

@@ -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<AgentItemProps>(({ item, style, className }) => {
return <Avatar avatar={typeof avatar === 'string' ? avatar : undefined} />;
}, [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,
});

View File

@@ -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: <Icon icon={pinned ? PinOff : Pin} />,
key: 'pin',
label: t(pinned ? 'pinOff' : 'pin'),
onClick: () => pinAgent(id, !pinned),
},
{
icon: <Icon icon={Pen} />,
key: 'rename',
label: t('rename', { ns: 'common' }),
onClick: (info: any) => {
info.domEvent?.stopPropagation();
toggleEditing(true);
},
},
{
icon: <Icon icon={LucideCopy} />,
key: 'duplicate',
label: t('duplicate', { ns: 'common' }),
onClick: ({ domEvent }: any) => {
domEvent.stopPropagation();
duplicateAgent(id);
},
},
{
icon: <Icon icon={PictureInPicture2Icon} />,
key: 'openInNewWindow',
label: t('openInNewWindow'),
onClick: ({ domEvent }: any) => {
domEvent.stopPropagation();
openAgentInNewWindow(id);
},
},
{ type: 'divider' },
{
children: [
...sessionCustomGroups.map(({ id: groupId, name }) => ({
icon: group === groupId ? <Icon icon={Check} /> : <div />,
key: groupId,
label: name,
onClick: () => updateAgentGroup(id, groupId),
})),
{
icon: isDefault ? <Icon icon={Check} /> : <div />,
key: 'defaultList',
label: t('defaultList'),
onClick: () => updateAgentGroup(id, SessionDefaultGroup.Default),
},
{ type: 'divider' as const },
{
icon: <Icon icon={LucidePlus} />,
key: 'createGroup',
label: <div>{t('sessionGroup.createGroup')}</div>,
onClick: ({ domEvent }: any) => {
domEvent.stopPropagation();
openCreateGroupModal();
},
},
],
icon: <Icon icon={FolderInputIcon} />,
key: 'moveGroup',
label: t('sessionGroup.moveGroup'),
},
{ type: 'divider' },
{
danger: true,
icon: <Icon icon={Trash} />,
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,
],
);
};

View File

@@ -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,
],
);
};

View File

@@ -1,4 +1,3 @@
export { useCreateMenuItems } from './useCreateMenuItems';
export { useProjectMenuItems } from './useProjectMenuItems';
export { useSessionGroupMenuItems } from './useSessionGroupMenuItems';
export { useSessionItemMenuItems } from './useSessionItemMenuItems';

View File

@@ -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 = <Icon icon={isPinned ? PinOff : Pin} />;
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 = <Icon icon={Pen} />;
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 = <Icon icon={LucideCopy} />;
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 = <Icon icon={PictureInPicture2Icon} />;
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 ? <Icon icon={Check} /> : <div />;
return {
icon: checkIcon,
key: groupId,
label: name,
onClick: () => {
updateAgentGroup(id, groupId);
},
};
}),
{
icon: isDefault ? <Icon icon={Check} /> : <div />,
key: 'defaultList',
label: t('defaultList'),
onClick: () => {
updateAgentGroup(id, SessionDefaultGroup.Default);
},
},
{
type: 'divider' as const,
},
{
icon: <Icon icon={LucidePlus} />,
key: 'createGroup',
label: <div>{t('sessionGroup.createGroup')}</div>,
onClick: ({ domEvent }: any) => {
domEvent.stopPropagation();
onOpenCreateGroupModal();
},
},
];
const folderIcon = <Icon icon={FolderInputIcon} />;
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 = <Icon icon={Trash} />;
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,
};
};

View File

@@ -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();

View File

@@ -36,7 +36,7 @@ const styles = createStaticStyles(({ css }) => ({
}));
const useIsEditorInit = (editor: IEditor) => {
const [isEditInit, setEditInit] = useState<boolean>(!!editor.getLexicalEditor());
const [isEditInit, setEditInit] = useState<boolean>(!!editor?.getLexicalEditor());
useEffect(() => {
if (!editor) return;

View File

@@ -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

View File

@@ -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 }) => {

View File

@@ -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();

View File

@@ -107,6 +107,17 @@ class ChatGroupService {
getGroupAgents = (groupId: string): Promise<ChatGroupAgentItem[]> => {
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();

View File

@@ -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');
});
});

View File

@@ -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<void>;
/**
* Duplicate a chat group (multi-agent group)
*/
duplicateAgentGroup: (groupId: string, newTitle?: string) => Promise<void>;
/**
* 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) => {