diff --git a/apps/buddy/electron/main/config/LexoraConfigStore.ts b/apps/buddy/electron/main/config/LexoraConfigStore.ts index 6637b64ec..196203b6c 100644 --- a/apps/buddy/electron/main/config/LexoraConfigStore.ts +++ b/apps/buddy/electron/main/config/LexoraConfigStore.ts @@ -64,6 +64,15 @@ const desktopConfigSchema = z.object({ device_name: '', avatar: '', }), + agent_profile: z.object({ + name: z.string().max(30).default(''), + avatar: z.string().max(DESKTOP_PROFILE_AVATAR_MAX_DATA_URL_LENGTH).default(''), + sync_with_user_profile: z.boolean().default(false), + }).passthrough().default({ + name: '', + avatar: '', + sync_with_user_profile: false, + }), developer_tools_enabled: z.boolean().default(false), language: z.enum(['zh-CN', 'en-US']).default('zh-CN'), launch_at_login: z.boolean().default(false), @@ -86,6 +95,7 @@ const desktopConfigSchema = z.object({ task_sidebar_pinned_items: [], task_sidebar: { collapsed: false, collapsed_sections: [], collapsed_spaces: [] }, profile: { user_name: '', device_name: '', avatar: '' }, + agent_profile: { name: '', avatar: '', sync_with_user_profile: false }, developer_tools_enabled: false, language: 'zh-CN', launch_at_login: false, @@ -312,6 +322,11 @@ function decodeConfig(value: unknown): LexoraConfig { deviceName: config.desktop.profile.device_name, avatar: config.desktop.profile.avatar, }, + agentProfile: { + name: config.desktop.agent_profile.name, + avatar: config.desktop.agent_profile.avatar, + syncWithUserProfile: config.desktop.agent_profile.sync_with_user_profile, + }, developerToolsEnabled: config.desktop.developer_tools_enabled, language: config.desktop.language, launchAtLogin: config.desktop.launch_at_login, @@ -363,6 +378,11 @@ function encodeConfig(config: LexoraConfig) { device_name: config.desktop.profile.deviceName, avatar: config.desktop.profile.avatar, }, + agent_profile: { + name: config.desktop.agentProfile.name, + avatar: config.desktop.agentProfile.avatar, + sync_with_user_profile: config.desktop.agentProfile.syncWithUserProfile, + }, developer_tools_enabled: config.desktop.developerToolsEnabled, language: config.desktop.language, launch_at_login: config.desktop.launchAtLogin, @@ -405,6 +425,10 @@ function mergeConfig(current: LexoraConfig, patch: LexoraConfigPatch): LexoraCon ...current.desktop.profile, ...patch.desktop?.profile, }, + agentProfile: { + ...current.desktop.agentProfile, + ...patch.desktop?.agentProfile, + }, }, pet: { ...current.pet, @@ -433,6 +457,10 @@ function mergeConfigFile(file: unknown, config: LexoraConfig): Record { deviceName: '', userName: '', }, + agentProfile: { + avatar: '', + name: '', + syncWithUserProfile: false, + }, sidebarCollapsed: false, theme: 'dark', }) diff --git a/apps/buddy/electron/shared/desktopApi.ts b/apps/buddy/electron/shared/desktopApi.ts index f9e117f42..811dbe58f 100644 --- a/apps/buddy/electron/shared/desktopApi.ts +++ b/apps/buddy/electron/shared/desktopApi.ts @@ -99,6 +99,12 @@ export interface DesktopUserProfileConfig { avatar: string } +export interface DesktopAgentProfileConfig { + name: string + avatar: string + syncWithUserProfile: boolean +} + export interface DesktopAppInfo { runtimeProfile: BuddyRuntimeProfile isPackaged: boolean @@ -181,6 +187,7 @@ export interface LexoraConfig { updateNotificationsEnabled: boolean notifyWhenFocused: boolean profile: DesktopUserProfileConfig + agentProfile: DesktopAgentProfileConfig pluginAuthor: string sidebarCollapsed: boolean theme: 'system' | 'light' | 'dark' @@ -196,10 +203,11 @@ export interface LexoraConfigPatch { runtime?: Partial browser?: Partial proxy?: LexoraConfig['proxy'] - desktop?: Partial> & { + desktop?: Partial> & { chat?: Partial taskSidebar?: Partial profile?: Partial + agentProfile?: Partial } pet?: Partial } diff --git a/apps/buddy/electron/shared/desktopApiSchemas.ts b/apps/buddy/electron/shared/desktopApiSchemas.ts index abc356e8d..18ee70d53 100644 --- a/apps/buddy/electron/shared/desktopApiSchemas.ts +++ b/apps/buddy/electron/shared/desktopApiSchemas.ts @@ -46,6 +46,12 @@ const desktopUserProfilePatchSchema = z.object({ avatar: z.string().max(DESKTOP_PROFILE_AVATAR_MAX_DATA_URL_LENGTH).optional(), }).strict() +const desktopAgentProfilePatchSchema = z.object({ + name: z.string().max(30).optional(), + avatar: z.string().max(DESKTOP_PROFILE_AVATAR_MAX_DATA_URL_LENGTH).optional(), + syncWithUserProfile: z.boolean().optional(), +}).strict() + export const feedbackIssueInputSchema = z.object({ feedback: z.string().max(4_000), }).strict() @@ -75,6 +81,7 @@ export const lexoraConfigPatchSchema: z.ZodType = z.object({ taskSidebarPinnedItems: taskSidebarPinnedItemsSchema.optional(), taskSidebar: taskSidebarPreferencesSchema.optional(), profile: desktopUserProfilePatchSchema.optional(), + agentProfile: desktopAgentProfilePatchSchema.optional(), developerToolsEnabled: z.boolean().optional(), pluginAuthor: extensionAuthorSchema.optional(), language: z.enum(['zh-CN', 'en-US']).optional(), diff --git a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue index 14624c756..89bce327b 100644 --- a/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue +++ b/apps/buddy/src/app/bootstrap/DesktopAppProvider.vue @@ -1,4 +1,5 @@ diff --git a/apps/buddy/src/modules/settings/settingsContext.ts b/apps/buddy/src/modules/settings/settingsContext.ts index 5e531e6a6..4ec7897d3 100644 --- a/apps/buddy/src/modules/settings/settingsContext.ts +++ b/apps/buddy/src/modules/settings/settingsContext.ts @@ -1,4 +1,4 @@ -import type { DesktopAppInfo, DesktopBrowserApi } from '@buddy-electron/shared/desktopApi' +import type { DesktopAgentProfileConfig, DesktopAppInfo, DesktopBrowserApi, DesktopUserProfileConfig } from '@buddy-electron/shared/desktopApi' import type { BuddyCapabilities } from '@buddy-shared/platform' import type { Ref } from 'vue' import type { ApplicationSettings, ShortcutSettings } from './contracts' @@ -19,6 +19,14 @@ export interface SettingsContext { applicationSettings: ApplicationSettings dataSettings: DesktopDataSettingsCapability platformCapabilities: Readonly> + profile: { + config: Readonly> + update: (patch: Partial) => Promise + } + agentProfile: { + config: Readonly> + update: (patch: Partial) => Promise + } providerSettings: ModelProvidersStore ready: Promise openTask: (conversationId: string) => Promise diff --git a/apps/buddy/src/modules/settings/widgets/app/DesktopAgentIdentitySettings.vue b/apps/buddy/src/modules/settings/widgets/app/DesktopAgentIdentitySettings.vue new file mode 100644 index 000000000..c8961a92b --- /dev/null +++ b/apps/buddy/src/modules/settings/widgets/app/DesktopAgentIdentitySettings.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue index 9338011c7..0799c6d39 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue +++ b/apps/buddy/src/modules/tasks/widgets/transcript/BuddyChatAgentIdentity.vue @@ -1,26 +1,34 @@ @@ -50,6 +58,17 @@ const { t } = useBuddyI18n(() => props.language) object-fit: cover; } +.buddy-chat-agent-identity__initials { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: var(--buddy-text-on-accent); + font-size: calc(var(--buddy-chat-avatar-size) * 0.45); + font-weight: 600; + line-height: 1; +} + .buddy-chat-agent-identity__name { min-width: 0; color: var(--buddy-chat-agent-name-color); diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts index 7faa2339f..f63de5886 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatActivities.spec.ts @@ -512,6 +512,7 @@ function mountTurn(nodes: ChatAgentTurnNode[], status: ChatAgentTurn['status'] = isDark: shallowRef(false), appSidebarCollapsed: shallowRef(false), chat: shallowRef({ outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'random' }), + agentIdentity: shallowRef({ avatar: '', avatarColor: null, initials: null, name: '' }), }) useProvideChatContent({ canPreviewFile: () => false, previewFile: () => {}, writeClipboardText: async () => {} }) return () => h(NMessageProvider, null, { default: () => [ diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatAgentIdentity.spec.ts b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatAgentIdentity.spec.ts new file mode 100644 index 000000000..2238227b0 --- /dev/null +++ b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatAgentIdentity.spec.ts @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import type { Ref } from 'vue' +import { describe, expect, it } from 'vitest' +import { createApp, h, nextTick, shallowRef } from 'vue' +import { useProvideDesktopUi } from '@/shared/ui/desktopUiContext' +import { BRAND_ASSET_URLS } from '@/shared/branding/brandAssets' +import BuddyChatAgentIdentity from '../BuddyChatAgentIdentity.vue' + +interface AgentIdentityInput { + avatar: string + avatarColor: string | null + initials: string | null + name: string +} + +function render(agentIdentity: Ref) { + const root = document.createElement('div') + const app = createApp({ + setup() { + useProvideDesktopUi({ + language: shallowRef('zh-CN'), + isDark: shallowRef(false), + sidebarCollapsed: shallowRef(false), + chat: shallowRef({ outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'random' }), + agentIdentity, + }) + return () => h(BuddyChatAgentIdentity, { language: 'zh-CN' }) + }, + }) + app.mount(root) + return { app, root } +} + +function identity(overrides: Partial = {}): AgentIdentityInput { + return { avatar: '', avatarColor: null, initials: null, name: '', ...overrides } +} + +describe('buddyChatAgentIdentity', () => { + it('falls back to the brand avatar and default name', async () => { + const { app, root } = render(shallowRef(identity())) + await nextTick() + + expect(root.querySelector('img')?.getAttribute('src')).toBe(BRAND_ASSET_URLS.chatAvatar) + expect(root.querySelector('.buddy-chat-agent-identity__name')?.textContent.trim()).toBe('Lexora Buddy') + + app.unmount() + }) + + it('renders the configured agent name and avatar', async () => { + const { app, root } = render(shallowRef(identity({ avatar: 'data:image/png;base64,abc', name: '小助手' }))) + await nextTick() + + expect(root.querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,abc') + expect(root.querySelector('.buddy-chat-agent-identity__name')?.textContent.trim()).toBe('小助手') + + app.unmount() + }) + + it('mirrors the profile initials avatar when syncing with the user profile', async () => { + const { app, root } = render(shallowRef(identity({ avatarColor: '#4a72b0', initials: 'AQ', name: 'Ayong Q' }))) + await nextTick() + + expect(root.querySelector('img')).toBeNull() + expect(root.querySelector('.buddy-chat-agent-identity__initials')?.textContent.trim()).toBe('AQ') + expect(root.querySelector('.buddy-chat-agent-identity__avatar')?.getAttribute('style')).toContain('rgb(74, 114, 176)') + expect(root.querySelector('.buddy-chat-agent-identity__name')?.textContent.trim()).toBe('Ayong Q') + + app.unmount() + }) +}) diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatMessageContent.spec.ts b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatMessageContent.spec.ts index 21b56063e..3070ce149 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatMessageContent.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/BuddyChatMessageContent.spec.ts @@ -86,6 +86,7 @@ describe('buddyChatMessageContent', () => { isDark: shallowRef(false), appSidebarCollapsed: shallowRef(false), chat: shallowRef({ outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'random' }), + agentIdentity: shallowRef({ avatar: '', avatarColor: null, initials: null, name: '' }), }) useProvideChatContent({ canPreviewFile, @@ -225,6 +226,7 @@ function renderContent(message: LocalMessage, isDark = false) { isDark: shallowRef(isDark), appSidebarCollapsed: shallowRef(false), chat: shallowRef({ outlinePosition: 'top-right', permissionMode: 'policy_approval', welcome: 'random' }), + agentIdentity: shallowRef({ avatar: '', avatarColor: null, initials: null, name: '' }), }) return () => h(BuddyChatMessageContent, { language: 'zh-CN', diff --git a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/DesktopChatTranscript.spec.ts b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/DesktopChatTranscript.spec.ts index 53a9a36fc..ec3f592c4 100644 --- a/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/DesktopChatTranscript.spec.ts +++ b/apps/buddy/src/modules/tasks/widgets/transcript/__tests__/DesktopChatTranscript.spec.ts @@ -97,6 +97,7 @@ function mountTranscript(loadOutlineMessages: () => Promise h(DesktopChatTranscript, props.value) }, diff --git a/apps/buddy/src/shared/ui/desktopUiContext.ts b/apps/buddy/src/shared/ui/desktopUiContext.ts index ca58d46ba..0d872f349 100644 --- a/apps/buddy/src/shared/ui/desktopUiContext.ts +++ b/apps/buddy/src/shared/ui/desktopUiContext.ts @@ -8,6 +8,7 @@ interface DesktopUiContext { isDark: Readonly> chat: Readonly>> appSidebarCollapsed: Readonly> + agentIdentity: Readonly> } const [useProvideDesktopUi, injectDesktopUi] = createInjectionState( diff --git a/pr-assets/ai-identity-demo.png b/pr-assets/ai-identity-demo.png new file mode 100644 index 000000000..e42171d3a Binary files /dev/null and b/pr-assets/ai-identity-demo.png differ