mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat: support Inbox (LobeAI) Agent profile customization
Allow users to customize the Inbox Agent's name, avatar, description, tags and opening message, just like regular agents. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FORM_STYLE } from '@/const/layoutTokens';
|
||||
import { INBOX_SESSION_ID } from '@/const/session';
|
||||
import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
|
||||
|
||||
import { selectors, useStore } from '../store';
|
||||
@@ -28,7 +27,7 @@ const AgentMeta = memo(() => {
|
||||
s.autocompleteMeta,
|
||||
s.autocompleteAllMeta,
|
||||
]);
|
||||
const [isInbox, loadingState] = useStore((s) => [s.id === INBOX_SESSION_ID, s.loadingState]);
|
||||
const loadingState = useStore((s) => s.loadingState);
|
||||
const meta = useStore(selectors.currentMetaConfig, isEqual);
|
||||
const [background, setBackground] = useState(meta.backgroundColor);
|
||||
|
||||
@@ -36,8 +35,6 @@ const AgentMeta = memo(() => {
|
||||
form.setFieldsValue(meta);
|
||||
}, [meta]);
|
||||
|
||||
if (isInbox) return;
|
||||
|
||||
const basic = [
|
||||
{
|
||||
Render: AutoGenerateInput,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -54,28 +55,25 @@ describe('useAgentMeta', () => {
|
||||
expect(result.current.avatar).toBe('agent-avatar.png');
|
||||
});
|
||||
|
||||
it('should return Lobe AI title for builtin inbox agent, preserving avatar from backend', () => {
|
||||
it('should use Lobe AI as fallback title for inbox agent when no custom title is set', () => {
|
||||
const mockInboxAgentId = 'inbox-agent-id';
|
||||
const mockMeta = {
|
||||
avatar: '/icons/icon-lobe.png', // Avatar from backend (merged from builtin-agents package)
|
||||
title: 'Original Inbox Title',
|
||||
avatar: '/icons/icon-lobe.png',
|
||||
description: 'Inbox description',
|
||||
};
|
||||
|
||||
// Mock ConversationStore to return inbox agentId
|
||||
vi.mocked(useConversationStore).mockImplementation((selector: any) => {
|
||||
const state = { context: { agentId: mockInboxAgentId } };
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
// Mock AgentStore state with inbox as builtin agent
|
||||
act(() => {
|
||||
useAgentStore.setState({
|
||||
agentMap: {
|
||||
[mockInboxAgentId]: mockMeta,
|
||||
},
|
||||
builtinAgentIdMap: {
|
||||
inbox: mockInboxAgentId,
|
||||
[INBOX_SESSION_ID]: mockInboxAgentId,
|
||||
pageAgent: 'page-agent-id',
|
||||
},
|
||||
});
|
||||
@@ -83,13 +81,44 @@ describe('useAgentMeta', () => {
|
||||
|
||||
const { result } = renderHook(() => useAgentMeta());
|
||||
|
||||
// Should override title with Lobe AI, but preserve avatar from backend
|
||||
expect(result.current.avatar).toBe('/icons/icon-lobe.png');
|
||||
expect(result.current.title).toBe('Lobe AI');
|
||||
// Should preserve other properties
|
||||
expect(result.current.description).toBe('Inbox description');
|
||||
});
|
||||
|
||||
it('should preserve user-customized title for inbox agent', () => {
|
||||
const mockInboxAgentId = 'inbox-agent-id';
|
||||
const mockMeta = {
|
||||
avatar: 'custom-avatar.png',
|
||||
title: 'My Custom Assistant',
|
||||
description: 'Custom description',
|
||||
};
|
||||
|
||||
vi.mocked(useConversationStore).mockImplementation((selector: any) => {
|
||||
const state = { context: { agentId: mockInboxAgentId } };
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
useAgentStore.setState({
|
||||
agentMap: {
|
||||
[mockInboxAgentId]: mockMeta,
|
||||
},
|
||||
builtinAgentIdMap: {
|
||||
[INBOX_SESSION_ID]: mockInboxAgentId,
|
||||
pageAgent: 'page-agent-id',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentMeta());
|
||||
|
||||
// Should preserve user-customized title for inbox agent
|
||||
expect(result.current.title).toBe('My Custom Assistant');
|
||||
expect(result.current.avatar).toBe('custom-avatar.png');
|
||||
expect(result.current.description).toBe('Custom description');
|
||||
});
|
||||
|
||||
it('should return Lobe AI title for page agent (builtin), preserving avatar from backend', () => {
|
||||
const mockPageAgentId = 'page-agent-id';
|
||||
const mockMeta = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { INBOX_SESSION_ID } from '@lobechat/const';
|
||||
import { type MetaData } from '@lobechat/types';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
@@ -10,9 +11,9 @@ const LOBE_AI_TITLE = 'Lobe AI';
|
||||
|
||||
/**
|
||||
* Hook to get agent meta data for a specific agent or the current conversation.
|
||||
* Handles special cases for builtin agents (inbox, page agent, agent builder)
|
||||
* by showing Lobe AI title instead of the agent's own meta.
|
||||
* Avatar is now returned from the backend (merged from builtin-agents package).
|
||||
* Handles special cases for builtin agents by showing Lobe AI title as fallback.
|
||||
* Inbox agent supports user customization - uses stored title if available.
|
||||
* Other builtin agents always show Lobe AI title.
|
||||
*
|
||||
* @param messageAgentId - Optional agent ID from the message. If provided, uses this agent's meta.
|
||||
* Falls back to the current conversation's agent if not provided.
|
||||
@@ -30,7 +31,12 @@ export const useAgentMeta = (messageAgentId?: string | null): MetaData => {
|
||||
const isBuiltinAgent = builtinAgentIds.includes(agentId);
|
||||
|
||||
if (isBuiltinAgent) {
|
||||
// Use avatar from backend (merged from builtin-agents package), only override title
|
||||
const isInbox = builtinAgentIdMap[INBOX_SESSION_ID] === agentId;
|
||||
if (isInbox) {
|
||||
// Inbox supports customization: use stored title, fallback to Lobe AI
|
||||
return { ...agentMeta, title: agentMeta.title || LOBE_AI_TITLE };
|
||||
}
|
||||
// Other builtin agents always show Lobe AI title
|
||||
return { ...agentMeta, title: LOBE_AI_TITLE };
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@ const Agent = memo<PropsWithChildren>(() => {
|
||||
agentSelectors.currentAgentBackgroundColor(s),
|
||||
]);
|
||||
|
||||
const displayTitle = isInbox ? 'Lobe AI' : title || t('defaultSession', { ns: 'common' });
|
||||
const displayTitle =
|
||||
title || (isInbox ? t('inbox.title', { ns: 'chat' }) : t('defaultSession', { ns: 'common' }));
|
||||
|
||||
if (isLoading) return <SkeletonItem height={32} padding={0} />;
|
||||
|
||||
@@ -43,7 +44,7 @@ const Agent = memo<PropsWithChildren>(() => {
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={isInbox ? DEFAULT_INBOX_AVATAR : avatar || DEFAULT_AVATAR}
|
||||
avatar={avatar || (isInbox ? DEFAULT_INBOX_AVATAR : DEFAULT_AVATAR)}
|
||||
background={backgroundColor || undefined}
|
||||
shape={'square'}
|
||||
size={28}
|
||||
|
||||
@@ -34,7 +34,9 @@ const InboxWelcome = memo(() => {
|
||||
return agentSystemRoleMsg;
|
||||
}, [openingMessage, agentSystemRoleMsg, meta.description]);
|
||||
|
||||
const displayTitle = isInbox ? 'Lobe AI' : meta.title || t('defaultSession', { ns: 'common' });
|
||||
const displayTitle =
|
||||
meta.title ||
|
||||
(isInbox ? t('inbox.title', { ns: 'chat' }) : t('defaultSession', { ns: 'common' }));
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -47,7 +49,7 @@ const InboxWelcome = memo(() => {
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={isInbox ? DEFAULT_INBOX_AVATAR : meta.avatar || DEFAULT_AVATAR}
|
||||
avatar={meta.avatar || (isInbox ? DEFAULT_INBOX_AVATAR : DEFAULT_AVATAR)}
|
||||
background={meta.backgroundColor}
|
||||
shape={'square'}
|
||||
size={78}
|
||||
@@ -57,7 +59,9 @@ const InboxWelcome = memo(() => {
|
||||
</Text>
|
||||
<Flexbox width={'min(100%, 640px)'}>
|
||||
<Markdown fontSize={fontSize} variant={'chat'}>
|
||||
{isInbox ? t('guide.defaultMessageWithoutCreate', { appName: 'Lobe AI' }) : message}
|
||||
{isInbox && !openingMessage
|
||||
? t('guide.defaultMessageWithoutCreate', { appName: displayTitle })
|
||||
: message}
|
||||
</Markdown>
|
||||
</Flexbox>
|
||||
{openingQuestions.length > 0 && (
|
||||
|
||||
@@ -24,7 +24,7 @@ const Content = memo(() => {
|
||||
]);
|
||||
const config = useAgentStore(agentSelectors.currentAgentConfig, isEqual);
|
||||
const meta = useAgentStore(agentSelectors.currentAgentMeta, isEqual);
|
||||
const [tab, setTab] = useState(isInbox ? ChatSettingsTabs.Modal : ChatSettingsTabs.Meta);
|
||||
const [tab, setTab] = useState(ChatSettingsTabs.Meta);
|
||||
|
||||
const updateAgentConfig = async (config: any) => {
|
||||
if (!agentId) return;
|
||||
@@ -37,37 +37,34 @@ const Content = memo(() => {
|
||||
};
|
||||
|
||||
const menuItems: ItemType[] = useMemo(
|
||||
() =>
|
||||
[
|
||||
!isInbox
|
||||
? {
|
||||
icon: <Icon icon={UserIcon} />,
|
||||
key: ChatSettingsTabs.Meta,
|
||||
label: t('agentTab.meta'),
|
||||
}
|
||||
: null,
|
||||
!isInbox
|
||||
? {
|
||||
icon: <Icon icon={MessageSquareHeartIcon} />,
|
||||
key: ChatSettingsTabs.Opening,
|
||||
label: t('agentTab.opening'),
|
||||
}
|
||||
: null,
|
||||
{
|
||||
icon: <Icon icon={MessagesSquareIcon} />,
|
||||
key: ChatSettingsTabs.Chat,
|
||||
label: t('agentTab.chat'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={BrainIcon} />,
|
||||
key: ChatSettingsTabs.Modal,
|
||||
label: t('agentTab.modal'),
|
||||
},
|
||||
].filter(Boolean) as ItemType[],
|
||||
[t, isInbox],
|
||||
() => [
|
||||
{
|
||||
icon: <Icon icon={UserIcon} />,
|
||||
key: ChatSettingsTabs.Meta,
|
||||
label: t('agentTab.meta'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={MessageSquareHeartIcon} />,
|
||||
key: ChatSettingsTabs.Opening,
|
||||
label: t('agentTab.opening'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={MessagesSquareIcon} />,
|
||||
key: ChatSettingsTabs.Chat,
|
||||
label: t('agentTab.chat'),
|
||||
},
|
||||
{
|
||||
icon: <Icon icon={BrainIcon} />,
|
||||
key: ChatSettingsTabs.Modal,
|
||||
label: t('agentTab.modal'),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const displayTitle = isInbox ? 'Lobe AI' : meta.title || t('defaultSession', { ns: 'common' });
|
||||
const displayTitle =
|
||||
meta.title ||
|
||||
(isInbox ? t('inbox.title', { ns: 'chat' }) : t('defaultSession', { ns: 'common' }));
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
@@ -100,7 +97,7 @@ const Content = memo(() => {
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
avatar={isInbox ? DEFAULT_INBOX_AVATAR : meta.avatar || DEFAULT_AVATAR}
|
||||
avatar={meta.avatar || (isInbox ? DEFAULT_INBOX_AVATAR : DEFAULT_AVATAR)}
|
||||
background={meta.backgroundColor || undefined}
|
||||
shape={'square'}
|
||||
size={28}
|
||||
|
||||
@@ -4,11 +4,12 @@ import { DEFAULT_INBOX_AVATAR, SESSION_CHAT_URL } from '@lobechat/const';
|
||||
import { Avatar } from '@lobehub/ui';
|
||||
import { type CSSProperties } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import NavItem from '@/features/NavPanel/components/NavItem';
|
||||
import { useAgentStore } from '@/store/agent';
|
||||
import { builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { agentSelectors, builtinAgentSelectors } from '@/store/agent/selectors';
|
||||
import { useChatStore } from '@/store/chat';
|
||||
import { operationSelectors } from '@/store/chat/selectors';
|
||||
|
||||
@@ -18,10 +19,16 @@ interface InboxItemProps {
|
||||
}
|
||||
|
||||
const InboxItem = memo<InboxItemProps>(({ className, style }) => {
|
||||
const { t } = useTranslation('chat');
|
||||
const inboxAgentId = useAgentStore(builtinAgentSelectors.inboxAgentId);
|
||||
const inboxMeta = useAgentStore((s) => {
|
||||
const id = builtinAgentSelectors.inboxAgentId(s);
|
||||
return id ? agentSelectors.getAgentMetaById(id)(s) : undefined;
|
||||
});
|
||||
|
||||
const isLoading = useChatStore(operationSelectors.isAgentRuntimeRunning);
|
||||
const inboxAgentTitle = 'Lobe AI';
|
||||
const inboxAgentTitle = inboxMeta?.title || t('inbox.title');
|
||||
const inboxAvatar = inboxMeta?.avatar || DEFAULT_INBOX_AVATAR;
|
||||
|
||||
return (
|
||||
<Link aria-label={inboxAgentTitle} to={SESSION_CHAT_URL(inboxAgentId, false)}>
|
||||
@@ -30,14 +37,7 @@ const InboxItem = memo<InboxItemProps>(({ className, style }) => {
|
||||
loading={isLoading}
|
||||
style={style}
|
||||
title={inboxAgentTitle}
|
||||
icon={
|
||||
<Avatar
|
||||
emojiScaleWithBackground
|
||||
avatar={DEFAULT_INBOX_AVATAR}
|
||||
shape={'square'}
|
||||
size={24}
|
||||
/>
|
||||
}
|
||||
icon={<Avatar emojiScaleWithBackground avatar={inboxAvatar} shape={'square'} size={24} />}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -195,6 +195,29 @@ describe('AgentService', () => {
|
||||
expect((result as any)?.avatar).toBe('/avatars/lobe-ai.png');
|
||||
});
|
||||
|
||||
it('should preserve user-customized avatar for inbox agent', async () => {
|
||||
const customAvatar = 'custom-inbox-avatar.png';
|
||||
const mockAgent = {
|
||||
id: 'agent-1',
|
||||
slug: 'inbox',
|
||||
model: 'gpt-4',
|
||||
avatar: customAvatar,
|
||||
};
|
||||
|
||||
const mockAgentModel = {
|
||||
getBuiltinAgent: vi.fn().mockResolvedValue(mockAgent),
|
||||
};
|
||||
|
||||
(AgentModel as any).mockImplementation(() => mockAgentModel);
|
||||
(parseAgentConfig as any).mockReturnValue({});
|
||||
|
||||
const newService = new AgentService(mockDb, mockUserId);
|
||||
const result = await newService.getBuiltinAgent('inbox');
|
||||
|
||||
// Should preserve user-customized avatar instead of overriding with builtin avatar
|
||||
expect((result as any)?.avatar).toBe(customAvatar);
|
||||
});
|
||||
|
||||
it('should not include avatar for non-builtin agents', async () => {
|
||||
const mockAgent = {
|
||||
id: 'agent-1',
|
||||
|
||||
@@ -81,9 +81,9 @@ export class AgentService {
|
||||
const mergedConfig = this.mergeDefaultConfig(agent, defaultAgentConfig);
|
||||
if (!mergedConfig) return null;
|
||||
|
||||
// Merge avatar from builtin-agents package definition
|
||||
// Merge avatar from builtin-agents package definition (only as fallback)
|
||||
const builtinAgent = BUILTIN_AGENTS[slug as BuiltinAgentSlug];
|
||||
if (builtinAgent?.avatar) {
|
||||
if (builtinAgent?.avatar && !mergedConfig.avatar) {
|
||||
return { ...mergedConfig, avatar: builtinAgent.avatar };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user