578 lines
20 KiB
TypeScript
578 lines
20 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
IconPlus,
|
|
IconTrash,
|
|
IconChevronDown,
|
|
IconMoodSmile,
|
|
} from '@tabler/icons-react'
|
|
import EmojiPicker, { EmojiClickData, Theme } from 'emoji-picker-react'
|
|
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { paramsSettings } from '@/lib/predefinedParams'
|
|
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from '@/components/ui/dropdown-menu'
|
|
import { useTheme } from '@/hooks/useTheme'
|
|
import { teamEmoji } from '@/utils/teamEmoji'
|
|
import { AvatarEmoji } from '@/containers/AvatarEmoji'
|
|
import { useTranslation } from '@/i18n/react-i18next-compat'
|
|
import { cn, isDev } from '@/lib/utils'
|
|
|
|
interface AddEditAssistantProps {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
editingKey: string | null
|
|
initialData?: Assistant
|
|
onSave: (assistant: Assistant) => void
|
|
}
|
|
|
|
export default function AddEditAssistant({
|
|
open,
|
|
onOpenChange,
|
|
editingKey,
|
|
initialData,
|
|
onSave,
|
|
}: AddEditAssistantProps) {
|
|
const [avatar, setAvatar] = useState<string | undefined>(initialData?.avatar)
|
|
|
|
const [name, setName] = useState(initialData?.name || '')
|
|
const [description, setDescription] = useState<string | undefined>(
|
|
initialData?.description
|
|
)
|
|
const [instructions, setInstructions] = useState(
|
|
initialData?.instructions || ''
|
|
)
|
|
const { isDark } = useTheme()
|
|
const [paramsKeys, setParamsKeys] = useState<string[]>([''])
|
|
const [paramsValues, setParamsValues] = useState<unknown[]>([''])
|
|
const [paramsTypes, setParamsTypes] = useState<string[]>(['string'])
|
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
|
const emojiPickerTriggerRef = useRef<HTMLDivElement>(null)
|
|
const [nameError, setNameError] = useState<string | null>(null)
|
|
const [toolSteps, setToolSteps] = useState(20)
|
|
|
|
// Handle click outside emoji picker or trigger
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (
|
|
emojiPickerRef.current &&
|
|
emojiPickerTriggerRef.current &&
|
|
!emojiPickerRef.current.contains(event.target as Node) &&
|
|
!emojiPickerTriggerRef.current.contains(event.target as Node)
|
|
) {
|
|
setShowEmojiPicker(false)
|
|
}
|
|
}
|
|
|
|
if (showEmojiPicker) {
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside)
|
|
}
|
|
}, [showEmojiPicker])
|
|
|
|
// Reset form when modal opens/closes or editing key changes
|
|
useEffect(() => {
|
|
if (open && editingKey && initialData) {
|
|
setAvatar(initialData.avatar)
|
|
setName(initialData.name)
|
|
setDescription(initialData.description)
|
|
setInstructions(initialData.instructions)
|
|
setShowEmojiPicker(false)
|
|
setToolSteps(initialData.tool_steps ?? 20)
|
|
|
|
// Convert parameters object to arrays of keys and values
|
|
const keys = Object.keys(initialData.parameters || {})
|
|
const values = Object.values(initialData.parameters || {})
|
|
|
|
// Determine parameter types based on values
|
|
const types = values.map((value) => {
|
|
if (typeof value === 'boolean') return 'boolean'
|
|
if (typeof value === 'number') return 'number'
|
|
if (typeof value === 'object') return 'json'
|
|
return 'string'
|
|
})
|
|
|
|
setParamsKeys(keys.length > 0 ? keys : [''])
|
|
setParamsValues(values.length > 0 ? values : [''])
|
|
setParamsTypes(types.length > 0 ? types : ['string'])
|
|
} else if (open) {
|
|
// Add mode - reset form
|
|
resetForm()
|
|
}
|
|
}, [open, editingKey, initialData])
|
|
|
|
const resetForm = () => {
|
|
setAvatar(undefined)
|
|
setName('')
|
|
setDescription(undefined)
|
|
setInstructions('')
|
|
setParamsKeys([''])
|
|
setParamsValues([''])
|
|
setParamsTypes(['string'])
|
|
setNameError(null)
|
|
setShowEmojiPicker(false)
|
|
setToolSteps(20)
|
|
}
|
|
|
|
const handleParameterChange = (
|
|
index: number,
|
|
value: unknown,
|
|
field: 'key' | 'value' | 'type'
|
|
) => {
|
|
if (field === 'key') {
|
|
const newKeys = [...paramsKeys]
|
|
newKeys[index] = value as string
|
|
setParamsKeys(newKeys)
|
|
} else if (field === 'value') {
|
|
const newValues = [...paramsValues]
|
|
|
|
// Convert value based on parameter type
|
|
if (paramsTypes[index] === 'number' && typeof value === 'string') {
|
|
newValues[index] = value === '' ? '' : Number(value)
|
|
} else if (
|
|
paramsTypes[index] === 'boolean' &&
|
|
typeof value === 'boolean'
|
|
) {
|
|
newValues[index] = value
|
|
} else if (paramsTypes[index] === 'json' && typeof value === 'string') {
|
|
try {
|
|
newValues[index] = value === '' ? {} : JSON.parse(value)
|
|
} catch {
|
|
// If JSON is invalid, keep as string
|
|
newValues[index] = value
|
|
}
|
|
} else {
|
|
newValues[index] = value
|
|
}
|
|
|
|
setParamsValues(newValues)
|
|
} else {
|
|
const newTypes = [...paramsTypes]
|
|
newTypes[index] = value as string
|
|
|
|
// Reset value based on the new type
|
|
const newValues = [...paramsValues]
|
|
|
|
if (value === 'string') {
|
|
newValues[index] = ''
|
|
} else if (value === 'number') {
|
|
newValues[index] = ''
|
|
} else if (value === 'boolean') {
|
|
newValues[index] = false
|
|
} else if (value === 'json') {
|
|
newValues[index] = {}
|
|
}
|
|
|
|
setParamsValues(newValues)
|
|
setParamsTypes(newTypes)
|
|
}
|
|
}
|
|
|
|
const handleAddParameter = () => {
|
|
setParamsKeys([...paramsKeys, ''])
|
|
setParamsValues([...paramsValues, ''])
|
|
setParamsTypes([...paramsTypes, 'string'])
|
|
}
|
|
|
|
const handleRemoveParameter = (index: number) => {
|
|
const newKeys = [...paramsKeys]
|
|
const newValues = [...paramsValues]
|
|
const newTypes = [...paramsTypes]
|
|
newKeys.splice(index, 1)
|
|
newValues.splice(index, 1)
|
|
newTypes.splice(index, 1)
|
|
setParamsKeys(newKeys.length > 0 ? newKeys : [''])
|
|
setParamsValues(newValues.length > 0 ? newValues : [''])
|
|
setParamsTypes(newTypes.length > 0 ? newTypes : ['string'])
|
|
}
|
|
|
|
const handleSave = () => {
|
|
if (!name.trim()) {
|
|
setNameError(t('assistants:nameRequired'))
|
|
return
|
|
}
|
|
setNameError(null)
|
|
// Convert parameters arrays to object
|
|
const parameters: Record<string, unknown> = {}
|
|
paramsKeys.forEach((key, index) => {
|
|
if (key) {
|
|
parameters[key] = paramsValues[index]
|
|
}
|
|
})
|
|
|
|
const assistant: Assistant = {
|
|
avatar,
|
|
id: initialData?.id || Math.random().toString(36).substring(7),
|
|
name,
|
|
created_at: initialData?.created_at || Date.now(),
|
|
description,
|
|
instructions,
|
|
parameters: parameters || {},
|
|
tool_steps: toolSteps,
|
|
}
|
|
onSave(assistant)
|
|
onOpenChange(false)
|
|
resetForm()
|
|
}
|
|
|
|
const { t } = useTranslation()
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
onInteractOutside={(e) => {
|
|
e.preventDefault()
|
|
}}
|
|
>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingKey
|
|
? t('assistants:editAssistant')
|
|
: t('assistants:addAssistant')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative">
|
|
<label className="text-sm mb-2 inline-block">
|
|
{t('assistants:emoji')}
|
|
</label>
|
|
<div
|
|
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)}
|
|
ref={emojiPickerTriggerRef}
|
|
>
|
|
{avatar ? (
|
|
<AvatarEmoji
|
|
avatar={avatar}
|
|
imageClassName="w-5 h-5 object-contain"
|
|
textClassName=""
|
|
/>
|
|
) : (
|
|
<IconMoodSmile size={18} className="text-main-view-fg/50" />
|
|
)}
|
|
</div>
|
|
<div className="relative" ref={emojiPickerRef}>
|
|
<EmojiPicker
|
|
open={showEmojiPicker}
|
|
theme={isDark ? ('dark' as Theme) : ('light' as Theme)}
|
|
className="!absolute !z-40 !overflow-y-auto top-2"
|
|
height={350}
|
|
customEmojis={isDev() ? teamEmoji : []}
|
|
lazyLoadEmojis
|
|
previewConfig={{ showPreview: false }}
|
|
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)
|
|
}
|
|
setShowEmojiPicker(false)
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 w-full">
|
|
<label className="text-sm mb-2 inline-block">
|
|
{t(`common:name`)}
|
|
</label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => {
|
|
setName(e.target.value)
|
|
if (e.target.value.trim()) setNameError(null)
|
|
}}
|
|
placeholder={t('assistants:enterName')}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{nameError && (
|
|
<div className="ml-12 text-xs text-destructive mt-1">
|
|
{nameError}
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm mb-2 inline-block">
|
|
{t('assistants:description')}
|
|
</label>
|
|
<Textarea
|
|
value={description || ''}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder={t('assistants:enterDescription')}
|
|
className="resize-none"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm mb-2 inline-block">
|
|
{t('assistants:instructions')}
|
|
</label>
|
|
<Textarea
|
|
value={instructions}
|
|
onChange={(e) => setInstructions(e.target.value)}
|
|
placeholder={t('assistants:enterInstructions')}
|
|
className="resize-none"
|
|
rows={4}
|
|
/>
|
|
<div className="text-xs text-main-view-fg/60">
|
|
{t('assistants:instructionsDateHint')}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 my-4 mt-6">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm">{t('common:settings')}</label>
|
|
</div>
|
|
<div className="flex justify-between items-center gap-2">
|
|
<div className="w-full">
|
|
<p className="text-sm">{t('assistants:maxToolSteps')}</p>
|
|
</div>
|
|
<Input
|
|
value={toolSteps}
|
|
type="number"
|
|
min={0}
|
|
onChange={(e) => {
|
|
const newSteps = e.target.value
|
|
const stepNumber = Number(newSteps)
|
|
setToolSteps(isNaN(stepNumber) ? 20 : stepNumber)
|
|
}}
|
|
placeholder="20"
|
|
className="w-18 text-right"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 my-4">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm">
|
|
{t('assistants:predefinedParameters')}
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(paramsSettings).map(([key, setting]) => (
|
|
<div
|
|
key={key}
|
|
onClick={() => {
|
|
// Check if parameter already exists
|
|
const existingIndex = paramsKeys.findIndex(
|
|
(k) => k === setting.key
|
|
)
|
|
if (existingIndex === -1) {
|
|
// Add new parameter
|
|
const newKeys = [...paramsKeys]
|
|
const newValues = [...paramsValues]
|
|
const newTypes = [...paramsTypes]
|
|
|
|
// If the last param is empty, replace it, otherwise add new
|
|
if (paramsKeys[paramsKeys.length - 1] === '') {
|
|
newKeys[newKeys.length - 1] = setting.key
|
|
newValues[newValues.length - 1] = setting.value
|
|
newTypes[newTypes.length - 1] =
|
|
typeof setting.value === 'boolean'
|
|
? 'boolean'
|
|
: typeof setting.value === 'number'
|
|
? 'number'
|
|
: 'string'
|
|
} else {
|
|
newKeys.push(setting.key)
|
|
newValues.push(setting.value)
|
|
newTypes.push(
|
|
typeof setting.value === 'boolean'
|
|
? 'boolean'
|
|
: typeof setting.value === 'number'
|
|
? 'number'
|
|
: 'string'
|
|
)
|
|
}
|
|
|
|
setParamsKeys(newKeys)
|
|
setParamsValues(newValues)
|
|
setParamsTypes(newTypes)
|
|
}
|
|
}}
|
|
className={cn(
|
|
'text-xs bg-main-view-fg/10 py-1 px-2 rounded-sm cursor-pointer',
|
|
paramsKeys.includes(setting.key) && 'opacity-50'
|
|
)}
|
|
>
|
|
{setting.title}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm">{t('assistants:parameters')}</label>
|
|
<div
|
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
|
onClick={handleAddParameter}
|
|
>
|
|
<IconPlus size={18} className="text-main-view-fg/60" />
|
|
</div>
|
|
</div>
|
|
|
|
{paramsKeys.map((key, index) => (
|
|
<div key={index} className="flex items-center gap-4">
|
|
<div
|
|
key={index}
|
|
className="flex items-center flex-col sm:flex-row w-full gap-2"
|
|
>
|
|
<Input
|
|
value={key}
|
|
onChange={(e) =>
|
|
handleParameterChange(index, e.target.value, 'key')
|
|
}
|
|
placeholder={t('assistants:key')}
|
|
className="w-full sm:w-24"
|
|
/>
|
|
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="relative w-full sm:w-30">
|
|
<Input
|
|
value={
|
|
paramsTypes[index].charAt(0).toUpperCase() +
|
|
paramsTypes[index].slice(1)
|
|
}
|
|
readOnly
|
|
/>
|
|
<IconChevronDown
|
|
size={14}
|
|
className="text-main-view-fg/50 absolute right-2 top-1/2 -translate-y-1/2"
|
|
/>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-32" align="start">
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleParameterChange(index, 'string', 'type')
|
|
}
|
|
>
|
|
{t('assistants:stringValue')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleParameterChange(index, 'number', 'type')
|
|
}
|
|
>
|
|
{t('assistants:numberValue')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleParameterChange(index, 'boolean', 'type')
|
|
}
|
|
>
|
|
{t('assistants:booleanValue')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleParameterChange(index, 'json', 'type')
|
|
}
|
|
>
|
|
{t('assistants:jsonValue')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{paramsTypes[index] === 'boolean' ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<div className="relative sm:flex-1 w-full">
|
|
<Input
|
|
value={
|
|
paramsValues[index]
|
|
? t('assistants:trueValue')
|
|
: t('assistants:falseValue')
|
|
}
|
|
readOnly
|
|
/>
|
|
<IconChevronDown
|
|
size={14}
|
|
className="text-main-view-fg/50 absolute right-2 top-1/2 -translate-y-1/2"
|
|
/>
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-24" align="start">
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleParameterChange(index, true, 'value')
|
|
}
|
|
>
|
|
{t('assistants:trueValue')}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() =>
|
|
handleParameterChange(index, false, 'value')
|
|
}
|
|
>
|
|
{t('assistants:falseValue')}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : paramsTypes[index] === 'json' ? (
|
|
<Input
|
|
value={
|
|
typeof paramsValues[index] === 'object'
|
|
? JSON.stringify(paramsValues[index], null, 2)
|
|
: paramsValues[index]?.toString() || ''
|
|
}
|
|
onChange={(e) =>
|
|
handleParameterChange(index, e.target.value, 'value')
|
|
}
|
|
placeholder={t('assistants:jsonValuePlaceholder')}
|
|
className="sm:flex-1 h-[36px] w-full"
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={paramsValues[index]?.toString() || ''}
|
|
onChange={(e) =>
|
|
handleParameterChange(index, e.target.value, 'value')
|
|
}
|
|
type={paramsTypes[index] === 'number' ? 'number' : 'text'}
|
|
placeholder={t('assistants:value')}
|
|
className="sm:flex-1 h-[36px] w-full"
|
|
/>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="size-6 cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
|
onClick={() => handleRemoveParameter(index)}
|
|
>
|
|
<IconTrash size={18} className="text-destructive" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={handleSave}>{t('assistants:save')}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)
|
|
}
|