enhancement: built-in custom emoji and show metadata message (#5085)

* enhancement: built-in custom emoji and show metadata message

* chore: seperate render avatar as component

* fix: avatar on assistant screen
This commit is contained in:
Faisal Amir 2025-05-23 15:37:47 +07:00 committed by GitHub
parent 063292db6d
commit dfe15fac32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 239 additions and 24 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -0,0 +1,31 @@
import React from 'react'
/**
* Checks if an avatar is a custom image (starts with '/images/')
*/
const isCustomImageAvatar = (avatar: React.ReactNode): avatar is string => {
return typeof avatar === 'string' && avatar.startsWith('/images/')
}
/**
* Component for rendering assistant avatars with consistent styling
*/
interface AvatarEmojiProps {
avatar?: React.ReactNode
fallback?: React.ReactNode
imageClassName?: string
textClassName?: string
}
export const AvatarEmoji: React.FC<AvatarEmojiProps> = ({
avatar,
fallback = '👋',
imageClassName = 'w-5 h-5 object-contain',
textClassName = 'text-base',
}) => {
if (isCustomImageAvatar(avatar)) {
return <img src={avatar} alt="Custom avatar" className={imageClassName} />
}
return <span className={textClassName}>{avatar || fallback}</span>
}

View File

@ -10,6 +10,7 @@ import { useAssistant } from '@/hooks/useAssistant'
import AddEditAssistant from './dialogs/AddEditAssistant' import AddEditAssistant from './dialogs/AddEditAssistant'
import { IconCirclePlus, IconSettings } from '@tabler/icons-react' import { IconCirclePlus, IconSettings } from '@tabler/icons-react'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
const DropdownAssistant = () => { const DropdownAssistant = () => {
const { const {
@ -34,13 +35,21 @@ const DropdownAssistant = () => {
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}> <DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
<div className="flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1">
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="bg-main-view-fg/5 py-0.5 hover:bg-main-view-fg/8 px-2 rounded font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40"> <button className="bg-main-view-fg/5 py-1 hover:bg-main-view-fg/8 px-2 rounded font-medium cursor-pointer flex items-center gap-1.5 relative z-20 max-w-40">
<span className="text-main-view-fg/80 truncate"> <div className="text-main-view-fg/80 flex items-center gap-1">
{selectedAssistant?.avatar && ( {selectedAssistant?.avatar && (
<span className="mr-1">{selectedAssistant?.avatar}</span> <span className="shrink-0 w-4 h-4 relative flex items-center justify-center">
)} <AvatarEmoji
{selectedAssistant?.name || 'Jan'} avatar={selectedAssistant.avatar}
imageClassName="object-cover"
textClassName="text-sm"
/>
</span> </span>
)}
<div className="truncate max-w-30">
<span>{selectedAssistant?.name || 'Jan'}</span>
</div>
</div>
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<div <div
@ -67,17 +76,25 @@ const DropdownAssistant = () => {
> >
{assistants.map((assistant) => ( {assistants.map((assistant) => (
<div className="relative pr-6" key={assistant.id}> <div className="relative pr-6" key={assistant.id}>
<DropdownMenuItem className="flex justify-between items-center"> <DropdownMenuItem asChild>
<span <div
className="truncate text-main-view-fg/70 flex-1 cursor-pointer" className="text-main-view-fg/70 cursor-pointer"
onClick={() => { onClick={() => {
setCurrentAssistant(assistant) setCurrentAssistant(assistant)
updateCurrentThreadAssistant(assistant) updateCurrentThreadAssistant(assistant)
}} }}
> >
<span className="mr-1">{assistant?.avatar}</span> <div className="shrink-0 relative w-4 h-4">
{assistant.name} <AvatarEmoji
</span> avatar={assistant?.avatar}
imageClassName="object-cover"
textClassName=""
/>
</div>
<div className="truncate text-left">
<span>{assistant.name}</span>
</div>
</div>
</DropdownMenuItem> </DropdownMenuItem>
<div className="absolute top-1/2 -translate-y-1/2 right-1"> <div className="absolute top-1/2 -translate-y-1/2 right-1">
<div className="size-5 text-main-view-fg/50 cursor-pointer relative z-10 flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"> <div className="size-5 text-main-view-fg/50 cursor-pointer relative z-10 flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out">

View File

@ -7,6 +7,7 @@ import {
IconRefresh, IconRefresh,
IconTrash, IconTrash,
IconPencil, IconPencil,
IconInfoCircle,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { useAppState } from '@/hooks/useAppState' import { useAppState } from '@/hooks/useAppState'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -32,6 +33,9 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip' } from '@/components/ui/tooltip'
import { formatDate } from '@/utils/formatDate' import { formatDate } from '@/utils/formatDate'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
const CopyButton = ({ text }: { text: string }) => { const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -154,7 +158,7 @@ export const ThreadContent = memo(
{item.content?.[0]?.text && item.role === 'user' && ( {item.content?.[0]?.text && item.role === 'user' && (
<div> <div>
<div className="flex justify-end w-full"> <div className="flex justify-end w-full">
<div className="bg-accent text-accent-fg p-2 rounded-md inline-block"> <div className="bg-main-view-fg/4 text-main-view-fg p-2 rounded-md inline-block">
<p className="select-text">{item.content?.[0].text.value}</p> <p className="select-text">{item.content?.[0].text.value}</p>
</div> </div>
</div> </div>
@ -237,8 +241,13 @@ export const ThreadContent = memo(
<> <>
{item.showAssistant && ( {item.showAssistant && (
<div className="flex items-center gap-2 mb-3 text-main-view-fg/60"> <div className="flex items-center gap-2 mb-3 text-main-view-fg/60">
<div className="flex items-center gap-2 size-8 rounded-md justify-center border border-main-view-fg/10 bg-main-view-fg/5 p-2"> <div className="flex items-center gap-2 size-8 rounded-md justify-center border border-main-view-fg/10 bg-main-view-fg/5 p-1">
<span className="text-base">{assistant?.avatar || '👋'}</span> <AvatarEmoji
avatar={assistant?.avatar}
fallback="👋"
imageClassName="w-6 h-6 object-contain"
textClassName="text-base"
/>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
@ -303,6 +312,55 @@ export const ThreadContent = memo(
<p>Delete</p> <p>Delete</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Metadata</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Message Metadata</DialogTitle>
<div className="space-y-2">
<div className="border border-main-view-fg/10 rounded-md overflow-hidden">
<CodeEditor
value={JSON.stringify(
item.metadata || {},
null,
2
)}
language="json"
readOnly
style={{
fontFamily: 'ui-monospace',
backgroundColor: 'transparent',
height: '100%',
}}
className="w-full h-full"
/>
</div>
</div>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
{item.isLastMessage && ( {item.isLastMessage && (
<Tooltip> <Tooltip>

View File

@ -20,6 +20,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useTheme } from '@/hooks/useTheme' import { useTheme } from '@/hooks/useTheme'
import { teamEmoji } from '@/utils/teamEmoji'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
interface AddEditAssistantProps { interface AddEditAssistantProps {
open: boolean open: boolean
@ -218,10 +220,15 @@ export default function AddEditAssistant({
<div className="relative"> <div className="relative">
<label className="text-sm mb-2 inline-block">Emoji</label> <label className="text-sm mb-2 inline-block">Emoji</label>
<div <div
className="border rounded-sm p-2 w-9 h-9 flex items-center justify-center border-main-view-fg/10 cursor-pointer" className="border rounded-sm p-1 w-9 h-9 flex items-center justify-center border-main-view-fg/10 cursor-pointer"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
> >
{avatar || '😊'} <AvatarEmoji
avatar={avatar}
fallback="😊"
imageClassName="w-5 h-5 object-contain"
textClassName=""
/>
</div> </div>
<div className="relative" ref={emojiPickerRef}> <div className="relative" ref={emojiPickerRef}>
<EmojiPicker <EmojiPicker
@ -229,10 +236,16 @@ export default function AddEditAssistant({
theme={isDark ? ('dark' as Theme) : ('light' as Theme)} theme={isDark ? ('dark' as Theme) : ('light' as Theme)}
className="!absolute !z-40 !overflow-y-auto top-2" className="!absolute !z-40 !overflow-y-auto top-2"
height={350} height={350}
customEmojis={teamEmoji}
lazyLoadEmojis lazyLoadEmojis
previewConfig={{ showPreview: false }} previewConfig={{ showPreview: false }}
onEmojiClick={(emojiData: EmojiClickData) => { onEmojiClick={(emojiData: EmojiClickData) => {
// For custom emojis, use the imageUrl instead of the emoji name
if (emojiData.isCustom && emojiData.imageUrl) {
setAvatar(emojiData.imageUrl)
} else {
setAvatar(emojiData.emoji) setAvatar(emojiData.emoji)
}
setShowEmojiPicker(false) setShowEmojiPicker(false)
}} }}
/> />

View File

@ -28,12 +28,19 @@ export const useAssistant = create<AssistantState>()(
currentAssistant: defaultAssistant, currentAssistant: defaultAssistant,
addAssistant: (assistant) => addAssistant: (assistant) =>
set({ assistants: [...get().assistants, assistant] }), set({ assistants: [...get().assistants, assistant] }),
updateAssistant: (assistant) => updateAssistant: (assistant) => {
const state = get()
set({ set({
assistants: get().assistants.map((a) => assistants: state.assistants.map((a) =>
a.id === assistant.id ? assistant : a a.id === assistant.id ? assistant : a
), ),
}), // Update currentAssistant if it's the same assistant being updated
currentAssistant:
state.currentAssistant.id === assistant.id
? assistant
: state.currentAssistant,
})
},
deleteAssistant: (id) => deleteAssistant: (id) =>
set({ assistants: get().assistants.filter((a) => a.id !== id) }), set({ assistants: get().assistants.filter((a) => a.id !== id) }),
setCurrentAssistant: (assistant) => { setCurrentAssistant: (assistant) => {

View File

@ -16,6 +16,7 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { AvatarEmoji } from '@/containers/AvatarEmoji'
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export const Route = createFileRoute(route.assistant as any)({ export const Route = createFileRoute(route.assistant as any)({
@ -67,10 +68,16 @@ function Assistant() {
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className="text-base font-medium text-main-view-fg/80"> <h3 className="text-base font-medium text-main-view-fg/80">
{assistant.avatar && ( <div className="flex items-center gap-1">
<span className="mr-1">{assistant.avatar}</span> <span className="shrink-0 w-4 h-4 relative flex items-center justify-center">
)} <AvatarEmoji
{assistant.name} avatar={assistant?.avatar}
imageClassName="object-cover"
textClassName="text-sm"
/>
</span>
<span>{assistant.name}</span>
</div>
</h3> </h3>
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
{/* <div {/* <div

View File

@ -0,0 +1,82 @@
export const teamEmoji = [
{
names: ['louis'],
imgUrl: '/images/emoji/louis.png',
id: 'louis',
},
{
names: ['emre'],
imgUrl: '/images/emoji/emre.png',
id: 'emre',
},
{
names: ['akarshan'],
imgUrl: '/images/emoji/akarshan.png',
id: 'akarshan',
},
{
names: ['daniel'],
imgUrl: '/images/emoji/daniel.png',
id: 'daniel',
},
{
names: ['gaunerst'],
imgUrl: '/images/emoji/gaunerst.png',
id: 'gaunerst',
},
{
names: ['yuuki'],
imgUrl: '/images/emoji/yuuki.png',
id: 'yuuki',
},
{
names: ['rach'],
imgUrl: '/images/emoji/rach.png',
id: 'rach',
},
{
names: ['faisal'],
imgUrl: '/images/emoji/faisal.png',
id: 'faisal',
},
{
names: ['nicole'],
imgUrl: '/images/emoji/nicole.png',
id: 'nicole',
},
{
names: ['doug'],
imgUrl: '/images/emoji/doug.png',
id: 'doug',
},
{
names: ['sang'],
imgUrl: '/images/emoji/sang.png',
id: 'sang',
},
{
names: ['alex'],
imgUrl: '/images/emoji/alex.png',
id: 'alex',
},
{
names: ['rex'],
imgUrl: '/images/emoji/rex.png',
id: 'rex',
},
{
names: ['hien'],
imgUrl: '/images/emoji/hien.png',
id: 'hien',
},
{
names: ['bach'],
imgUrl: '/images/emoji/bach.png',
id: 'bach',
},
{
names: ['alan'],
imgUrl: '/images/emoji/alan.png',
id: 'alan',
},
]