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
BIN
web-app/public/images/emoji/akarshan.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
web-app/public/images/emoji/alan.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web-app/public/images/emoji/alex.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
web-app/public/images/emoji/bach.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web-app/public/images/emoji/daniel.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
web-app/public/images/emoji/doug.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
web-app/public/images/emoji/emre.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
BIN
web-app/public/images/emoji/faisal.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
web-app/public/images/emoji/gaunerst.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
web-app/public/images/emoji/hien.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
web-app/public/images/emoji/louis.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
web-app/public/images/emoji/nicole.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
web-app/public/images/emoji/rach.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
web-app/public/images/emoji/rex.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web-app/public/images/emoji/sang.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
web-app/public/images/emoji/yuuki.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
31
web-app/src/containers/AvatarEmoji.tsx
Normal 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>
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
avatar={selectedAssistant.avatar}
|
||||||
|
imageClassName="object-cover"
|
||||||
|
textClassName="text-sm"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{selectedAssistant?.name || 'Jan'}
|
<div className="truncate max-w-30">
|
||||||
</span>
|
<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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) => {
|
||||||
setAvatar(emojiData.emoji)
|
// For custom emojis, use the imageUrl instead of the emoji name
|
||||||
|
if (emojiData.isCustom && emojiData.imageUrl) {
|
||||||
|
setAvatar(emojiData.imageUrl)
|
||||||
|
} else {
|
||||||
|
setAvatar(emojiData.emoji)
|
||||||
|
}
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
82
web-app/src/utils/teamEmoji.ts
Normal 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',
|
||||||
|
},
|
||||||
|
]
|
||||||