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:
arvinxx
2026-03-03 00:19:34 +08:00
parent a9511344f9
commit 91cdb4ef11
9 changed files with 121 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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