feat(agent): add emoji avatar support and refactor avatar handling

- Rename getAgentAvatar to getAgentDefaultAvatar for clarity
- Add EmojiAvatarWithPicker component for emoji selection
- Update AgentLabel to support both default and emoji avatars
- Add AvatarSetting component for avatar configuration
- Modify agent configuration schema to support emoji avatars
This commit is contained in:
icarus 2025-09-27 17:02:29 +08:00
parent b46237296e
commit 2ccfde1ba4
8 changed files with 150 additions and 37 deletions

View File

@ -0,0 +1,22 @@
import { Button, Popover, PopoverContent, PopoverTrigger } from '@heroui/react'
import React from 'react'
import EmojiPicker from '../EmojiPicker'
type Props = {
emoji: string
onPick: (emoji: string) => void
}
export const EmojiAvatarWithPicker: React.FC<Props> = ({ emoji, onPick }) => {
return (
<Popover>
<PopoverTrigger>
<Button size="sm" startContent={<span className="text-lg">{emoji}</span>} isIconOnly />
</PopoverTrigger>
<PopoverContent>
<EmojiPicker onEmojiClick={onPick}></EmojiPicker>
</PopoverContent>
</Popover>
)
}

View File

@ -11,7 +11,7 @@ export const DEFAULT_CLAUDE_CODE_CONFIG: Omit<AgentBase, 'model'> = {
...DEFAULT_AGENT_CONFIG
} as const
export const getAgentAvatar = (type: AgentType): string => {
export const getAgentDefaultAvatar = (type: AgentType): string => {
switch (type) {
case 'claude-code':
return ClaudeAvatar

View File

@ -1,11 +1,11 @@
import { Avatar, Button, Chip, cn } from '@heroui/react'
import { Button, Chip, cn } from '@heroui/react'
import { DeleteIcon, EditIcon } from '@renderer/components/Icons'
import { getAgentAvatar } from '@renderer/config/agent'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import AgentSettingsPopup from '@renderer/pages/settings/AgentSettings/AgentSettingsPopup'
import { AgentLabel } from '@renderer/pages/settings/AgentSettings/shared'
import { AgentEntity } from '@renderer/types'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@renderer/ui/context-menu'
import { FC, memo, useCallback } from 'react'
import { FC, memo } from 'react'
import { useTranslation } from 'react-i18next'
// const logger = loggerService.withContext('AgentItem')
@ -21,24 +21,13 @@ const AgentItem: FC<AgentItemProps> = ({ agent, isActive, onDelete, onPress }) =
const { t } = useTranslation()
const { sessions } = useSessions(agent.id)
const AgentLabel = useCallback(() => {
const displayName = agent.name ?? agent.id
const avatar = getAgentAvatar(agent.type)
return (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6" src={avatar} name={displayName} />
<span className="text-sm">{displayName}</span>
</div>
)
}, [agent.id, agent.name, agent.type])
return (
<>
<ContextMenu modal={false}>
<ContextMenuTrigger>
<ButtonContainer onPress={onPress} className={isActive ? 'active' : ''}>
<AssistantNameRow className="name flex w-full justify-between" title={agent.name ?? agent.id}>
<AgentLabel />
<AgentLabel agent={agent} />
{isActive && (
<Chip
variant="bordered"

View File

@ -1,13 +1,17 @@
import { Avatar } from '@heroui/react'
import { getAgentDefaultAvatar } from '@renderer/config/agent'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import { GetAgentResponse } from '@renderer/types'
import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessibleDirsSetting } from './AccessibleDirsSetting'
import { AvatarSetting } from './AvatarSetting'
import { DescriptionSetting } from './DescriptionSetting'
import { ModelSetting } from './ModelSetting'
import { NameSetting } from './NameSetting'
import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './shared'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
// const logger = loggerService.withContext('AgentEssentialSettings')
@ -25,8 +29,12 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
<SettingsContainer>
<SettingsItem inline>
<SettingsTitle>{t('agent.type.label')}</SettingsTitle>
<AgentLabel type={agent.type} />
<div className="flex items-center gap-2">
<Avatar src={getAgentDefaultAvatar(agent.type)} className="h-6 w-6 text-lg" />
<span>{(agent?.name ?? agent?.type) ? getAgentTypeLabel(agent.type) : ''}</span>
</div>
</SettingsItem>
<AvatarSetting agent={agent} update={update} />
<NameSetting base={agent} update={update} />
<ModelSetting base={agent} update={update} />
<AccessibleDirsSetting base={agent} update={update} />

View File

@ -104,14 +104,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
afterClose={afterClose}
maskClosable={false}
footer={null}
title={
<AgentLabel
type={agent?.type ?? 'claude-code'}
name={agent?.name}
classNames={{ name: 'text-lg font-extrabold' }}
avatarProps={{ size: 'sm' }}
/>
}
title={<AgentLabel agent={agent} classNames={{ name: 'text-lg font-extrabold' }} avatarProps={{ size: 'sm' }} />}
transitionName="animation-move-down"
styles={{
content: {

View File

@ -0,0 +1,96 @@
import { Avatar, Radio, RadioGroup } from '@heroui/react'
import { loggerService } from '@logger'
import { EmojiAvatarWithPicker } from '@renderer/components/Avatar/EmojiAvatarWithPicker'
import { getAgentDefaultAvatar } from '@renderer/config/agent'
import { AgentEntity, isAgentType, UpdateAgentForm } from '@renderer/types'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import z from 'zod'
import { SettingsItem, SettingsTitle } from './shared'
export interface AvatarSettingsProps {
agent: AgentEntity
update: (form: UpdateAgentForm) => Promise<void>
}
const optionsSchema = z.enum(['default', 'emoji'])
type AvatarOption = z.infer<typeof optionsSchema>
const options = {
DEFAULT: 'default',
EMOJI: 'emoji'
} as const satisfies Record<string, AvatarOption>
const logger = loggerService.withContext('AvatarSetting')
export const AvatarSetting: React.FC<AvatarSettingsProps> = ({ agent, update }) => {
const { t } = useTranslation()
const isDefault = isAgentType(agent.configuration?.avatar)
const [avatarOption, setAvatarOption] = useState<AvatarOption>(isDefault ? options.DEFAULT : options.EMOJI)
const [emoji, setEmoji] = useState(isDefault ? '⭐️' : (agent.configuration?.avatar ?? '⭐️'))
const updateAvatar = useCallback(
(avatar: string) => {
const payload = {
id: agent.id,
// hard-encoded default values. better to implement incremental update for configuration
configuration: {
...agent.configuration,
permission_mode: agent.configuration?.permission_mode ?? 'default',
max_turns: agent.configuration?.max_turns ?? 100,
avatar
}
} satisfies UpdateAgentForm
update(payload)
},
[agent, update]
)
const handleOptionChange = useCallback(
(value: string) => {
const result = optionsSchema.safeParse(value)
if (!result.success) {
logger.error('Invalid option', { value })
return
}
const option = result.data
setAvatarOption(option)
if (option === agent?.configuration?.avatar) return
switch (option) {
case options.DEFAULT:
updateAvatar(agent.type)
break
case options.EMOJI:
updateAvatar(emoji)
break
default:
break
}
},
[agent?.configuration?.avatar, agent.type, emoji, updateAvatar]
)
return (
<SettingsItem inline>
<SettingsTitle>{t('common.avatar')}</SettingsTitle>
<RadioGroup size="sm" orientation="horizontal" value={avatarOption} onValueChange={handleOptionChange}>
<Radio value={options.DEFAULT} classNames={{ label: 'flex flex-row' }}>
<Avatar className="h-6 w-6" src={getAgentDefaultAvatar(agent.type)} />
</Radio>
<Radio value={options.EMOJI}>
<EmojiAvatarWithPicker
emoji={emoji}
onPick={(emoji: string) => {
setEmoji(emoji)
if (emoji === agent?.configuration?.avatar) return
updateAvatar(emoji)
}}
/>
</Radio>
</RadioGroup>
</SettingsItem>
)
}

View File

@ -1,7 +1,8 @@
import { Avatar, AvatarProps, cn } from '@heroui/react'
import { getAgentAvatar } from '@renderer/config/agent'
import EmojiIcon from '@renderer/components/EmojiIcon'
import { getAgentDefaultAvatar } from '@renderer/config/agent'
import { getAgentTypeLabel } from '@renderer/i18n/label'
import { AgentSessionEntity, AgentType } from '@renderer/types'
import { AgentEntity, AgentSessionEntity, isAgentType } from '@renderer/types'
import { Menu, Modal } from 'antd'
import React, { ReactNode } from 'react'
import styled from 'styled-components'
@ -22,8 +23,7 @@ export const SettingsTitle: React.FC<SettingsTitleProps> = ({ children, actions
}
export type AgentLabelProps = {
type: AgentType
name?: string
agent: AgentEntity | undefined | null
classNames?: {
container?: string
avatar?: string
@ -32,11 +32,16 @@ export type AgentLabelProps = {
avatarProps?: AvatarProps
}
export const AgentLabel: React.FC<AgentLabelProps> = ({ type, name, classNames, avatarProps }) => {
export const AgentLabel: React.FC<AgentLabelProps> = ({ agent, classNames, avatarProps }) => {
const isDefault = isAgentType(agent?.configuration?.avatar)
const src = isDefault ? getAgentDefaultAvatar(agent.type) : undefined
const emoji = isDefault ? undefined : agent?.configuration?.avatar
return (
<div className={cn('flex items-center gap-2', classNames?.container)}>
<Avatar src={getAgentAvatar(type)} title={type} {...avatarProps} className={cn('h-5 w-5', classNames?.avatar)} />
<span className={classNames?.name}>{name ?? getAgentTypeLabel(type)}</span>
{isDefault && <Avatar src={src} {...avatarProps} className={cn('h-6 w-6 text-lg', classNames?.avatar)} />}
{!isDefault && <EmojiIcon emoji={emoji || '⭐️'} className={classNames?.avatar} />}
<span className={classNames?.name}>{(agent?.name ?? agent?.type) ? getAgentTypeLabel(agent.type) : ''}</span>
</div>
)
}
@ -83,7 +88,7 @@ export const SettingsItem: React.FC<SettingsItemProps> = ({
export const SettingsContainer: React.FC<React.ComponentPropsWithRef<'div'>> = ({ children, className, ...props }) => {
return (
<div className={cn('flex flex-1 flex-col overflow-auto pr-2', className)} {...props}>
<div className={cn('flex flex-1 flex-col overflow-y-auto overflow-x-hidden pr-2', className)} {...props}>
{children}
</div>
)

View File

@ -52,12 +52,12 @@ export type SlashCommand = z.infer<typeof SlashCommandSchema>
// ------------------ Agent configuration & base schema ------------------
export const AgentConfigurationSchema = z
.object({
avatar: z.string().optional(), // URL or path to avatar image
avatar: z.string().optional(), // agent type as mark of default avatar; single emoji; URL or path to avatar image.
slash_commands: z.array(z.string()).optional(), // Array of slash commands to trigger the agent, this is from agent init response
// https://docs.claude.com/en/docs/claude-code/sdk/sdk-permissions#mode-specific-behaviors
permission_mode: PermissionModeSchema.default('default'), // Permission mode, default to 'default'
max_turns: z.number().default(100) // Maximum number of interaction turns, default to 100
permission_mode: PermissionModeSchema.optional().default('default'), // Permission mode, default to 'default'
max_turns: z.number().optional().default(100) // Maximum number of interaction turns, default to 100
})
.loose()