mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
🐛 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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ packages:
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@vercel/speed-insights'
|
||||
- '@lobehub/editor'
|
||||
|
||||
overrides:
|
||||
'@lobehub/chat-plugin-sdk>swagger-client': 3.36.0
|
||||
|
||||
@@ -11,10 +11,10 @@ import { useSendMenuItems } from './useSendMenuItems';
|
||||
const leftActions: ActionKeys[] = [
|
||||
'model',
|
||||
'search',
|
||||
'typo',
|
||||
'fileUpload',
|
||||
'tools',
|
||||
'---',
|
||||
['tools', 'params', 'clear'],
|
||||
['typo', 'params', 'clear'],
|
||||
'mainToken',
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
export { useCreateMenuItems } from './useCreateMenuItems';
|
||||
export { useProjectMenuItems } from './useProjectMenuItems';
|
||||
export { useSessionGroupMenuItems } from './useSessionGroupMenuItems';
|
||||
export { useSessionItemMenuItems } from './useSessionItemMenuItems';
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user