diff --git a/packages/database/src/repositories/home/index.test.ts b/packages/database/src/repositories/home/index.test.ts index 9a655b18bd..394065d5ff 100644 --- a/packages/database/src/repositories/home/index.test.ts +++ b/packages/database/src/repositories/home/index.test.ts @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { getTestDB } from '../../core/getTestDB'; import { NewAgent, agents } from '../../schemas/agent'; -import { NewChatGroup, chatGroups } from '../../schemas/chatGroup'; +import { NewChatGroup, chatGroups, chatGroupsAgents } from '../../schemas/chatGroup'; import { agentsToSessions } from '../../schemas/relations'; import { NewSession, NewSessionGroup, sessionGroups, sessions } from '../../schemas/session'; import { users } from '../../schemas/user'; @@ -158,6 +158,126 @@ describe('HomeRepository', () => { type: 'group', }); }); + + it('should return custom avatar when chat group has one set', async () => { + const [group] = await serverDB + .insert(chatGroups) + .values({ + avatar: '🚀', + backgroundColor: '#ff5500', + pinned: false, + title: 'Custom Avatar Group', + userId, + }) + .returning(); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.ungrouped).toHaveLength(1); + expect(result.ungrouped[0]).toMatchObject({ + avatar: '🚀', + backgroundColor: '#ff5500', + title: 'Custom Avatar Group', + type: 'group', + }); + }); + + it('should return member avatars when chat group has no custom avatar', async () => { + // Create chat group without custom avatar + const [group] = await serverDB + .insert(chatGroups) + .values({ + pinned: false, + title: 'No Custom Avatar Group', + userId, + }) + .returning(); + + // Create member agents + const [agent1] = await serverDB + .insert(agents) + .values({ + avatar: '🤖', + backgroundColor: '#0000ff', + title: 'Agent 1', + userId, + virtual: true, + }) + .returning(); + + const [agent2] = await serverDB + .insert(agents) + .values({ + avatar: '🧑‍💻', + backgroundColor: '#00ff00', + title: 'Agent 2', + userId, + virtual: true, + }) + .returning(); + + // Link agents to group + await serverDB.insert(chatGroupsAgents).values([ + { agentId: agent1.id, chatGroupId: group.id, order: 0, userId }, + { agentId: agent2.id, chatGroupId: group.id, order: 1, userId }, + ]); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.ungrouped).toHaveLength(1); + const groupItem = result.ungrouped[0]; + expect(groupItem.type).toBe('group'); + // Avatar should be an array of member avatars + expect(Array.isArray(groupItem.avatar)).toBe(true); + const avatarArray = groupItem.avatar as Array<{ avatar: string; background?: string }>; + expect(avatarArray).toHaveLength(2); + expect(avatarArray[0]).toMatchObject({ avatar: '🤖', background: '#0000ff' }); + expect(avatarArray[1]).toMatchObject({ avatar: '🧑‍💻', background: '#00ff00' }); + // backgroundColor should not be set when using member avatars + expect(groupItem.backgroundColor).toBeUndefined(); + }); + + it('should prioritize custom avatar over member avatars', async () => { + // Create chat group WITH custom avatar + const [group] = await serverDB + .insert(chatGroups) + .values({ + avatar: '🎯', + backgroundColor: '#ff0000', + pinned: false, + title: 'Group With Both', + userId, + }) + .returning(); + + // Create member agent + const [agent] = await serverDB + .insert(agents) + .values({ + avatar: '🤖', + backgroundColor: '#0000ff', + title: 'Member Agent', + userId, + virtual: true, + }) + .returning(); + + // Link agent to group + await serverDB.insert(chatGroupsAgents).values({ + agentId: agent.id, + chatGroupId: group.id, + order: 0, + userId, + }); + + const result = await homeRepo.getSidebarAgentList(); + + expect(result.ungrouped).toHaveLength(1); + const groupItem = result.ungrouped[0]; + // Should use custom avatar (string), not member avatars (array) + expect(groupItem.avatar).toBe('🎯'); + expect(groupItem.backgroundColor).toBe('#ff0000'); + }); }); describe('getSidebarAgentList - pinned items', () => { @@ -883,5 +1003,101 @@ describe('HomeRepository', () => { expect(result[0].title).toBe('New Search Agent'); expect(result[1].title).toBe('Old Search Agent'); }); + + it('should return custom avatar for chat groups with custom avatar in search', async () => { + await serverDB.insert(chatGroups).values({ + avatar: '🎨', + backgroundColor: '#abcdef', + title: 'Searchable Custom Avatar Group', + userId, + }); + + const result = await homeRepo.searchAgents('Searchable Custom'); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ + avatar: '🎨', + backgroundColor: '#abcdef', + title: 'Searchable Custom Avatar Group', + type: 'group', + }); + }); + + it('should return member avatars for chat groups without custom avatar in search', async () => { + // Create chat group without custom avatar + const [group] = await serverDB + .insert(chatGroups) + .values({ + title: 'Searchable Member Avatar Group', + userId, + }) + .returning(); + + // Create member agent + const [agent] = await serverDB + .insert(agents) + .values({ + avatar: '🤖', + backgroundColor: '#112233', + title: 'Search Member', + userId, + virtual: true, + }) + .returning(); + + await serverDB.insert(chatGroupsAgents).values({ + agentId: agent.id, + chatGroupId: group.id, + order: 0, + userId, + }); + + const result = await homeRepo.searchAgents('Searchable Member Avatar'); + + expect(result).toHaveLength(1); + const groupItem = result[0]; + expect(groupItem.type).toBe('group'); + expect(Array.isArray(groupItem.avatar)).toBe(true); + const avatarArray = groupItem.avatar as Array<{ avatar: string; background?: string }>; + expect(avatarArray).toHaveLength(1); + expect(avatarArray[0]).toMatchObject({ avatar: '🤖', background: '#112233' }); + expect(groupItem.backgroundColor).toBeUndefined(); + }); + + it('should prioritize custom avatar over member avatars in search', async () => { + // Create chat group WITH custom avatar and members + const [group] = await serverDB + .insert(chatGroups) + .values({ + avatar: '🏆', + backgroundColor: '#gold00', + title: 'Searchable Priority Group', + userId, + }) + .returning(); + + const [agent] = await serverDB + .insert(agents) + .values({ + avatar: '🤖', + title: 'Priority Member', + userId, + virtual: true, + }) + .returning(); + + await serverDB.insert(chatGroupsAgents).values({ + agentId: agent.id, + chatGroupId: group.id, + order: 0, + userId, + }); + + const result = await homeRepo.searchAgents('Searchable Priority'); + + expect(result).toHaveLength(1); + expect(result[0].avatar).toBe('🏆'); + expect(result[0].backgroundColor).toBe('#gold00'); + }); }); }); diff --git a/packages/database/src/repositories/home/index.ts b/packages/database/src/repositories/home/index.ts index ca9315e4bf..d60ebea3fc 100644 --- a/packages/database/src/repositories/home/index.ts +++ b/packages/database/src/repositories/home/index.ts @@ -59,6 +59,8 @@ export class HomeRepository { // 2. Query all chatGroups (group chats) const chatGroupList = await this.db .select({ + avatar: chatGroups.avatar, + backgroundColor: chatGroups.backgroundColor, description: chatGroups.description, groupId: chatGroups.groupId, id: chatGroups.id, @@ -102,6 +104,8 @@ export class HomeRepository { updatedAt: Date; }>, chatGroupItems: Array<{ + avatar: string | null; + backgroundColor: string | null; description: string | null; groupId: string | null; id: string; @@ -132,7 +136,9 @@ export class HomeRepository { updatedAt: a.updatedAt, })), ...chatGroupItems.map((g) => ({ - avatar: memberAvatarsMap.get(g.id) ?? null, + // If group has custom avatar, use it (string); otherwise fallback to member avatars (array) + avatar: g.avatar ? g.avatar : (memberAvatarsMap.get(g.id) ?? null), + backgroundColor: g.avatar ? g.backgroundColor : null, description: g.description, groupId: g.groupId, id: g.id, @@ -214,6 +220,8 @@ export class HomeRepository { // 2. Search chat groups by title or description const chatGroupResults = await this.db .select({ + avatar: chatGroups.avatar, + backgroundColor: chatGroups.backgroundColor, description: chatGroups.description, id: chatGroups.id, pinned: chatGroups.pinned, @@ -250,7 +258,8 @@ export class HomeRepository { ), ...chatGroupResults.map((g) => cleanObject({ - avatar: memberAvatarsMap.get(g.id), + avatar: g.avatar ? g.avatar : (memberAvatarsMap.get(g.id) ?? null), + backgroundColor: g.avatar ? g.backgroundColor : null, description: g.description, id: g.id, pinned: g.pinned ?? false, diff --git a/packages/types/src/home.ts b/packages/types/src/home.ts index 68fc8c63bc..b2229430c3 100644 --- a/packages/types/src/home.ts +++ b/packages/types/src/home.ts @@ -17,11 +17,15 @@ export interface GroupMemberAvatar { export interface SidebarAgentItem { /** * Avatar can be: - * - string: single avatar for agents - * - GroupMemberAvatar[]: array of member avatars for groups + * - string: single avatar for agents or custom group avatar + * - GroupMemberAvatar[]: array of member avatars for groups (when no custom avatar) * - null: no avatar */ avatar?: GroupMemberAvatar[] | string | null; + /** + * Background color for the avatar (used for custom group avatars) + */ + backgroundColor?: string | null; description?: string | null; id: string; pinned: boolean; diff --git a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx index 255d8bf26a..fec51fd7a3 100644 --- a/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx +++ b/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx @@ -7,7 +7,7 @@ import { type CSSProperties, type DragEvent, memo, useCallback, useMemo } from ' import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import GroupAvatar from '@/features/GroupAvatar'; +import AgentGroupAvatar from '@/features/AgentGroupAvatar'; import NavItem from '@/features/NavPanel/components/NavItem'; import { useGlobalStore } from '@/store/global'; import { useHomeStore } from '@/store/home'; @@ -23,7 +23,7 @@ interface GroupItemProps { } const GroupItem = memo(({ item, style, className }) => { - const { id, avatar, title, pinned } = item; + const { id, avatar, backgroundColor, title, pinned } = item; const { t } = useTranslation('chat'); const openAgentInNewWindow = useGlobalStore((s) => s.openAgentInNewWindow); @@ -82,8 +82,21 @@ const GroupItem = memo(({ item, style, className }) => { if (isUpdating) { return ; } - return ; - }, [isUpdating, avatar]); + + // If avatar is a string, it's a custom group avatar + const customAvatar = typeof avatar === 'string' ? avatar : undefined; + // If avatar is an array, it's member avatars for composition + const memberAvatars = Array.isArray(avatar) ? avatar : []; + + return ( + + ); + }, [isUpdating, avatar, backgroundColor]); const dropdownMenu = useGroupDropdownMenu({ id,