Merge pull request #6381 from menloresearch/enhancement/dialog-modal-responsive

enhancement: responsive dialog modals
This commit is contained in:
Faisal Amir 2025-09-08 22:24:47 +07:00 committed by GitHub
commit cd85ae062e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1049 additions and 597 deletions

View File

@ -1,4 +1,4 @@
import { Link, useNavigate, useRouterState } from '@tanstack/react-router' import { Link, useRouterState } from '@tanstack/react-router'
import { useLeftPanel } from '@/hooks/useLeftPanel' import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
@ -6,7 +6,6 @@ import {
IconDots, IconDots,
IconCirclePlusFilled, IconCirclePlusFilled,
IconSettingsFilled, IconSettingsFilled,
IconTrash,
IconStar, IconStar,
IconMessageFilled, IconMessageFilled,
IconAppsFilled, IconAppsFilled,
@ -27,17 +26,6 @@ import { useThreads } from '@/hooks/useThreads'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useMemo, useState, useEffect, useRef } from 'react' import { useMemo, useState, useEffect, useRef } from 'react'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { toast } from 'sonner' import { toast } from 'sonner'
import { DownloadManagement } from '@/containers/DownloadManegement' import { DownloadManagement } from '@/containers/DownloadManegement'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen } from '@/hooks/useMediaQuery'
@ -45,6 +33,7 @@ import { useClickOutside } from '@/hooks/useClickOutside'
import { useDownloadStore } from '@/hooks/useDownloadStore' import { useDownloadStore } from '@/hooks/useDownloadStore'
import { PlatformFeatures } from '@/lib/platform/const' import { PlatformFeatures } from '@/lib/platform/const'
import { PlatformFeature } from '@/lib/platform/types' import { PlatformFeature } from '@/lib/platform/types'
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
const mainMenus = [ const mainMenus = [
{ {
@ -76,7 +65,6 @@ const mainMenus = [
const LeftPanel = () => { const LeftPanel = () => {
const { open, setLeftPanel } = useLeftPanel() const { open, setLeftPanel } = useLeftPanel()
const { t } = useTranslation() const { t } = useTranslation()
const navigate = useNavigate()
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const isSmallScreen = useSmallScreen() const isSmallScreen = useSmallScreen()
@ -362,80 +350,25 @@ const LeftPanel = () => {
{t('common:recents')} {t('common:recents')}
</span> </span>
<div className="relative"> <div className="relative">
<Dialog> <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <button
<button className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10"
className="size-6 flex cursor-pointer items-center justify-center rounded hover:bg-left-panel-fg/10 transition-all duration-200 ease-in-out data-[state=open]:bg-left-panel-fg/10" onClick={(e) => {
onClick={(e) => { e.preventDefault()
e.preventDefault() e.stopPropagation()
e.stopPropagation() }}
}} >
> <IconDots
<IconDots size={18}
size={18} className="text-left-panel-fg/60"
className="text-left-panel-fg/60" />
/> </button>
</button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent side="bottom" align="end">
<DropdownMenuContent side="bottom" align="end"> <DeleteAllThreadsDialog onDeleteAll={deleteAllThreads} />
<DialogTrigger asChild> </DropdownMenuContent>
<DropdownMenuItem </DropdownMenu>
onSelect={(e) => e.preventDefault()}
>
<IconTrash size={16} />
<span>{t('common:deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.deleteAllThreads.title')}
</DialogTitle>
<DialogDescription>
{t(
'common:dialogs.deleteAllThreads.description'
)}
</DialogDescription>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
size="sm"
onClick={() => {
deleteAllThreads()
toast.success(
t(
'common:toast.deleteAllThreads.title'
),
{
id: 'delete-all-thread',
description: t(
'common:toast.deleteAllThreads.description'
),
}
)
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:deleteAll')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</DropdownMenuContent>
</DropdownMenu>
</Dialog>
</div> </div>
</div> </div>
)} )}

View File

@ -2,14 +2,7 @@
import { ThreadMessage } from '@janhq/core' import { ThreadMessage } from '@janhq/core'
import { RenderMarkdown } from './RenderMarkdown' import { RenderMarkdown } from './RenderMarkdown'
import React, { Fragment, memo, useCallback, useMemo, useState } from 'react' import React, { Fragment, memo, useCallback, useMemo, useState } from 'react'
import { import { IconCopy, IconCopyCheck, IconRefresh } from '@tabler/icons-react'
IconCopy,
IconCopyCheck,
IconRefresh,
IconTrash,
IconPencil,
IconInfoCircle,
} from '@tabler/icons-react'
import { useAppState } from '@/hooks/useAppState' import { useAppState } from '@/hooks/useAppState'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useMessages } from '@/hooks/useMessages' import { useMessages } from '@/hooks/useMessages'
@ -17,16 +10,10 @@ import ThinkingBlock from '@/containers/ThinkingBlock'
import ToolCallBlock from '@/containers/ToolCallBlock' import ToolCallBlock from '@/containers/ToolCallBlock'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
import { import {
Dialog, EditMessageDialog,
DialogClose, MessageMetadataDialog,
DialogContent, DeleteMessageDialog,
DialogFooter, } from '@/containers/dialogs'
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -37,8 +24,6 @@ import { AvatarEmoji } from '@/containers/AvatarEmoji'
import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator' import TokenSpeedIndicator from '@/containers/TokenSpeedIndicator'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
@ -76,69 +61,6 @@ const CopyButton = ({ text }: { text: string }) => {
) )
} }
const EditDialog = ({
message,
setMessage,
}: {
message: string
setMessage: (message: string) => void
}) => {
const { t } = useTranslation()
const [draft, setDraft] = useState(message)
const handleSave = () => {
if (draft !== message) {
setMessage(draft)
}
}
return (
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconPencil size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('edit')}</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent className="w-3/4">
<DialogHeader>
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="mt-2 resize-none w-full"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button variant="link" size="sm" className="hover:no-underline">
Cancel
</Button>
</DialogClose>
<DialogClose asChild>
<Button
disabled={draft === message || !draft}
onClick={handleSave}
>
Save
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}
// Use memo to prevent unnecessary re-renders, but allow re-renders when props change // Use memo to prevent unnecessary re-renders, but allow re-renders when props change
export const ThreadContent = memo( export const ThreadContent = memo(
( (
@ -349,32 +271,20 @@ export const ThreadContent = memo(
)} )}
<div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2"> <div className="flex items-center justify-end gap-2 text-main-view-fg/60 text-xs mt-2">
<EditDialog <EditMessageDialog
message={ message={
item.content?.find((c) => c.type === 'text')?.text?.value || item.content?.find((c) => c.type === 'text')?.text?.value ||
'' ''
} }
setMessage={(message) => { onSave={(message) => {
if (item.updateMessage) { if (item.updateMessage) {
item.updateMessage(item, message) item.updateMessage(item, message)
} }
}} }}
/> />
<Tooltip> <DeleteMessageDialog
<TooltipTrigger asChild> onDelete={() => deleteMessage(item.thread_id, item.id)}
<button />
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
deleteMessage(item.thread_id, item.id)
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
</div> </div>
</div> </div>
)} )}
@ -456,68 +366,15 @@ export const ThreadContent = memo(
'hidden' 'hidden'
)} )}
> >
<EditDialog <EditMessageDialog
message={item.content?.[0]?.text.value} message={item.content?.[0]?.text.value || ''}
setMessage={(message) => onSave={(message) =>
item.updateMessage && item.updateMessage(item, message) item.updateMessage && item.updateMessage(item, message)
} }
/> />
<CopyButton text={item.content?.[0]?.text.value || ''} /> <CopyButton text={item.content?.[0]?.text.value || ''} />
<Tooltip> <DeleteMessageDialog onDelete={removeMessage} />
<TooltipTrigger asChild> <MessageMetadataDialog metadata={item.metadata} />
<button
className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative"
onClick={() => {
removeMessage()
}}
>
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
<Dialog>
<DialogTrigger>
<Tooltip>
<TooltipTrigger asChild>
<div className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{t('metadata')}</p>
</TooltipContent>
</Tooltip>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.messageMetadata.title')}
</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 !text-sm"
/>
</div>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
{item.isLastMessage && selectedModel && ( {item.isLastMessage && selectedModel && (
<Tooltip> <Tooltip>

View File

@ -15,14 +15,11 @@ import { CSS } from '@dnd-kit/utilities'
import { import {
IconDots, IconDots,
IconStarFilled, IconStarFilled,
IconTrash,
IconEdit,
IconStar, IconStar,
} from '@tabler/icons-react' } from '@tabler/icons-react'
import { useThreads } from '@/hooks/useThreads' import { useThreads } from '@/hooks/useThreads'
import { useLeftPanel } from '@/hooks/useLeftPanel' import { useLeftPanel } from '@/hooks/useLeftPanel'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { route } from '@/constants/routes'
import { useSmallScreen } from '@/hooks/useMediaQuery' import { useSmallScreen } from '@/hooks/useMediaQuery'
import { import {
@ -33,19 +30,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import { DialogClose, DialogFooter, DialogHeader } from '@/components/ui/dialog'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { memo, useMemo, useState } from 'react' import { memo, useMemo, useState } from 'react'
import { useNavigate, useMatches } from '@tanstack/react-router' import { useNavigate, useMatches } from '@tanstack/react-router'
import { toast } from 'sonner' import { RenameThreadDialog, DeleteThreadDialog } from '@/containers/dialogs'
import { Input } from '@/components/ui/input' import { route } from '@/constants/routes'
const SortableItem = memo(({ thread }: { thread: Thread }) => { const SortableItem = memo(({ thread }: { thread: Thread }) => {
const { const {
@ -94,9 +82,6 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '') return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
}, [thread.title]) }, [thread.title])
const [title, setTitle] = useState(
plainTitleForRename || t('common:newThread')
)
return ( return (
<div <div
@ -156,116 +141,19 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => {
<span>{t('common:star')}</span> <span>{t('common:star')}</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<Dialog <RenameThreadDialog
onOpenChange={(open) => { thread={thread}
if (open) { plainTitleForRename={plainTitleForRename}
setTitle(plainTitleForRename || t('common:newThread')) onRename={renameThread}
} else { onDropdownClose={() => setOpenDropdown(false)}
setOpenDropdown(false) />
}
}}
>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconEdit />
<span>{t('common:rename')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:threadTitle')}</DialogTitle>
<Input
value={title}
onChange={(e) => {
setTitle(e.target.value)
}}
className="mt-2"
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={!title}
onClick={() => {
renameThread(thread.id, title)
setOpenDropdown(false)
toast.success(t('common:toast.renameThread.title'), {
id: 'rename-thread',
description: t(
'common:toast.renameThread.description',
{ title }
),
})
}}
>
{t('common:rename')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Dialog <DeleteThreadDialog
onOpenChange={(open) => { thread={thread}
if (!open) setOpenDropdown(false) onDelete={deleteThread}
}} onDropdownClose={() => setOpenDropdown(false)}
> />
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash />
<span>{t('common:delete')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:deleteThread')}</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteThread.description')}
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
deleteThread(thread.id)
setOpenDropdown(false)
toast.success(t('common:toast.deleteThread.title'), {
id: 'delete-thread',
description: t(
'common:toast.deleteThread.description'
),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@ -1,16 +1,11 @@
import { ChevronDown, ChevronUp, Loader } from 'lucide-react' import { ChevronDown, ChevronUp, Loader } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { create } from 'zustand' import { create } from 'zustand'
import { RenderMarkdown } from './RenderMarkdown' import { RenderMarkdown } from '@/containers/RenderMarkdown'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { twMerge } from 'tailwind-merge' import { twMerge } from 'tailwind-merge'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
import ImageModal from '@/containers/dialogs/ImageModal'
interface Props { interface Props {
result: string result: string
@ -108,14 +103,6 @@ const ContentItemRenderer = ({
) )
} }
// if (item.type === 'text' && item.text) {
// return (
// <div key={index} className="mt-3">
// <RenderMarkdown content={item.text} />
// </div>
// )
// }
// For any other types, render as JSON // For any other types, render as JSON
return ( return (
<div key={index} className="mt-3"> <div key={index} className="mt-3">
@ -243,29 +230,7 @@ const ToolCallBlock = ({ id, name, result, loading, args }: Props) => {
</div> </div>
</div> </div>
{/* Image Modal */} <ImageModal image={modalImage} onClose={closeModal} />
<Dialog
open={!!modalImage}
onOpenChange={(open) => !open && closeModal()}
>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle>{modalImage?.alt || t('common:image')}</DialogTitle>
</DialogHeader>
<div className="flex justify-center items-center p-6 pt-2">
{modalImage && (
<img
src={modalImage.url}
alt={modalImage.alt}
className="max-w-full max-h-[70vh] object-contain rounded-md"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</div>
</DialogContent>
</Dialog>
</div> </div>
) )
} }

View File

@ -45,7 +45,7 @@ export function TrustedHostsInput({
onBlur={handleBlur} onBlur={handleBlur}
placeholder={t('common:enterTrustedHosts')} placeholder={t('common:enterTrustedHosts')}
className={cn( className={cn(
'w-24 h-8 text-sm', 'h-8 text-sm',
isServerRunning && 'opacity-50 pointer-events-none' isServerRunning && 'opacity-50 pointer-events-none'
)} )}
/> />

View File

@ -0,0 +1,105 @@
import { useState, useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
interface AddProviderDialogProps {
onCreateProvider: (name: string) => void
children: React.ReactNode
}
export function AddProviderDialog({
onCreateProvider,
children,
}: AddProviderDialogProps) {
const { t } = useTranslation()
const [name, setName] = useState('')
const [isOpen, setIsOpen] = useState(false)
const createButtonRef = useRef<HTMLButtonElement>(null)
const handleCreate = () => {
if (name.trim()) {
onCreateProvider(name.trim())
setName('')
setIsOpen(false)
}
}
const handleCancel = () => {
setName('')
setIsOpen(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && name.trim()) {
e.preventDefault()
handleCreate()
}
// Prevent key from being captured by parent components
e.stopPropagation()
}
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) {
setName('')
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="sm:max-w-[425px] max-w-[90vw]"
onOpenAutoFocus={(e) => {
e.preventDefault()
createButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('provider:addOpenAIProvider')}</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={handleKeyDown}
/>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline w-full sm:w-auto"
onClick={handleCancel}
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button
ref={createButtonRef}
disabled={!name.trim()}
onClick={handleCreate}
className="w-full sm:w-auto"
size="sm"
aria-label={t('common:create')}
>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -33,7 +33,7 @@ export default function ChangeDataFolderLocation({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<IconFolder size={20} /> <IconFolder size={20} />

View File

@ -0,0 +1,103 @@
import { useState, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { route } from '@/constants/routes'
interface DeleteAllThreadsDialogProps {
onDeleteAll: () => void
onDropdownClose?: () => void
}
export function DeleteAllThreadsDialog({
onDeleteAll,
onDropdownClose,
}: DeleteAllThreadsDialogProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [isOpen, setIsOpen] = useState(false)
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open && onDropdownClose) {
onDropdownClose()
}
}
const handleDeleteAll = () => {
onDeleteAll()
setIsOpen(false)
if (onDropdownClose) onDropdownClose()
toast.success(t('common:toast.deleteAllThreads.title'), {
id: 'delete-all-threads',
description: t('common:toast.deleteAllThreads.description'),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleDeleteAll()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash size={16} />
<span>{t('common:deleteAll')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>
{t('common:dialogs.deleteAllThreads.title')}
</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteAllThreads.description')}
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleDeleteAll}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={t('common:deleteAll')}
>
{t('common:deleteAll')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,78 @@
import { useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogContent,
DialogTitle,
DialogDescription,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface DeleteAssistantDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onConfirm: () => void
}
export function DeleteAssistantDialog({
open,
onOpenChange,
onConfirm,
}: DeleteAssistantDialogProps) {
const { t } = useTranslation()
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleConfirm = () => {
onConfirm()
}
const handleCancel = () => {
onOpenChange(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleConfirm()
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-[425px] max-w-[90vw]"
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('assistants:deleteConfirmation')}</DialogTitle>
<DialogDescription>
{t('assistants:deleteConfirmationDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button
variant="link"
onClick={handleCancel}
className="w-full sm:w-auto"
>
{t('assistants:cancel')}
</Button>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleConfirm}
onKeyDown={handleKeyDown}
className="w-full sm:w-auto"
aria-label={t('assistants:delete')}
>
{t('assistants:delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@ -30,12 +30,15 @@ export default function DeleteMCPServerConfirm({
<DialogTitle>{t('mcp-servers:deleteServer.title')}</DialogTitle> <DialogTitle>{t('mcp-servers:deleteServer.title')}</DialogTitle>
<DialogDescription> <DialogDescription>
{t('mcp-servers:deleteServer.description', { serverName })} {t('mcp-servers:deleteServer.description', { serverName })}
<span className="font-medium text-main-view-fg">{serverName}</span>?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="link" onClick={() => onOpenChange(false)}>
{t('common:cancel')}
</Button>
<Button <Button
variant="destructive" variant="destructive"
autoFocus
onClick={() => { onClick={() => {
onConfirm() onConfirm()
onOpenChange(false) onOpenChange(false)

View File

@ -0,0 +1,91 @@
import { useState, useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface DeleteMessageDialogProps {
onDelete: () => void
}
export function DeleteMessageDialog({ onDelete }: DeleteMessageDialogProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleDelete = () => {
onDelete()
setIsOpen(false)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleDelete()
}
}
const trigger = (
<Tooltip>
<TooltipTrigger asChild>
<button className="flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconTrash size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('delete')}</p>
</TooltipContent>
</Tooltip>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>{trigger}</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('common:deleteMessage')}</DialogTitle>
<DialogDescription>
Are you sure you want to delete this message? This action cannot be
undone.
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleDelete}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={t('common:deleteMessage')}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -37,29 +37,35 @@ export const DialogDeleteModel = ({
const removeModel = async () => { const removeModel = async () => {
// Remove model from favorites if it exists // Remove model from favorites if it exists
removeFavorite(selectedModelId) removeFavorite(selectedModelId)
deleteModelCache(selectedModelId) deleteModelCache(selectedModelId)
serviceHub.models().deleteModel(selectedModelId).then(() => { serviceHub
serviceHub.providers().getProviders().then((providers) => { .models()
// Filter out the deleted model from all providers .deleteModel(selectedModelId)
const filteredProviders = providers.map((provider) => ({ .then(() => {
...provider, serviceHub
models: provider.models.filter( .providers()
(model) => model.id !== selectedModelId .getProviders()
), .then((providers) => {
})) // Filter out the deleted model from all providers
setProviders(filteredProviders) const filteredProviders = providers.map((provider) => ({
...provider,
models: provider.models.filter(
(model) => model.id !== selectedModelId
),
}))
setProviders(filteredProviders)
})
toast.success(
t('providers:deleteModel.title', { modelId: selectedModel?.id }),
{
id: `delete-model-${selectedModel?.id}`,
description: t('providers:deleteModel.success', {
modelId: selectedModel?.id,
}),
}
)
}) })
toast.success(
t('providers:deleteModel.title', { modelId: selectedModel?.id }),
{
id: `delete-model-${selectedModel?.id}`,
description: t('providers:deleteModel.success', {
modelId: selectedModel?.id,
}),
}
)
})
} }
// Initialize with the provided model ID or the first model if available // Initialize with the provided model ID or the first model if available
@ -105,7 +111,7 @@ export const DialogDeleteModel = ({
</Button> </Button>
</DialogClose> </DialogClose>
<DialogClose asChild> <DialogClose asChild>
<Button variant="destructive" size="sm" onClick={removeModel}> <Button variant="destructive" size="sm" onClick={removeModel} autoFocus>
{t('providers:deleteModel.delete')} {t('providers:deleteModel.delete')}
</Button> </Button>
</DialogClose> </DialogClose>

View File

@ -0,0 +1,103 @@
import { useState, useRef } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { IconTrash } from '@tabler/icons-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
import { route } from '@/constants/routes'
interface DeleteThreadDialogProps {
thread: Thread
onDelete: (threadId: string) => void
onDropdownClose: () => void
}
export function DeleteThreadDialog({
thread,
onDelete,
onDropdownClose,
}: DeleteThreadDialogProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const [isOpen, setIsOpen] = useState(false)
const deleteButtonRef = useRef<HTMLButtonElement>(null)
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (!open) {
onDropdownClose()
}
}
const handleDelete = () => {
onDelete(thread.id)
setIsOpen(false)
onDropdownClose()
toast.success(t('common:toast.deleteThread.title'), {
id: 'delete-thread',
description: t('common:toast.deleteThread.description'),
})
setTimeout(() => {
navigate({ to: route.home })
}, 0)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleDelete()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconTrash />
<span>{t('common:delete')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
deleteButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('common:deleteThread')}</DialogTitle>
<DialogDescription>
{t('common:dialogs.deleteThread.description')}
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
ref={deleteButtonRef}
variant="destructive"
onClick={handleDelete}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={`${t('common:delete')} ${thread.title || t('common:newThread')}`}
>
{t('common:delete')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,111 @@
import { useState, useEffect, useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { IconPencil } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
interface EditMessageDialogProps {
message: string
onSave: (message: string) => void
triggerElement?: React.ReactNode
}
export function EditMessageDialog({
message,
onSave,
triggerElement,
}: EditMessageDialogProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const [draft, setDraft] = useState(message)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
setDraft(message)
}, [message])
useEffect(() => {
if (isOpen && textareaRef.current) {
setTimeout(() => {
textareaRef.current?.focus()
textareaRef.current?.select()
}, 100)
}
}, [isOpen])
const handleSave = () => {
if (draft !== message && draft.trim()) {
onSave(draft)
setIsOpen(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
e.stopPropagation()
if (e.key === 'Enter' && e.ctrlKey) {
handleSave()
}
}
const defaultTrigger = (
<Tooltip>
<TooltipTrigger asChild>
<button className="flex outline-0 items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconPencil size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('edit')}</p>
</TooltipContent>
</Tooltip>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>{triggerElement || defaultTrigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:dialogs.editMessage.title')}</DialogTitle>
<Textarea
ref={textareaRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="mt-2 resize-none w-full min-h-24"
onKeyDown={handleKeyDown}
placeholder={t('common:dialogs.editMessage.title')}
aria-label={t('common:dialogs.editMessage.title')}
/>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={draft === message || !draft.trim()}
onClick={handleSave}
size="sm"
className="w-full sm:w-auto"
>
{t('common:save')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,80 @@
import { useRef } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface FactoryResetDialogProps {
onReset: () => void
children: React.ReactNode
}
export function FactoryResetDialog({
onReset,
children,
}: FactoryResetDialogProps) {
const { t } = useTranslation()
const resetButtonRef = useRef<HTMLButtonElement>(null)
const handleReset = () => {
onReset()
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleReset()
}
}
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent
className="sm:max-w-[425px] max-w-[90vw]"
onOpenAutoFocus={(e) => {
e.preventDefault()
resetButtonRef.current?.focus()
}}
>
<DialogHeader>
<DialogTitle>{t('settings:general.factoryResetTitle')}</DialogTitle>
<DialogDescription>
{t('settings:general.factoryResetDesc')}
</DialogDescription>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline w-full sm:w-auto"
>
{t('settings:general.cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button
ref={resetButtonRef}
variant="destructive"
onClick={handleReset}
onKeyDown={handleKeyDown}
size="sm"
className="w-full sm:w-auto"
aria-label={t('settings:general.reset')}
>
{t('settings:general.reset')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,40 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { useTranslation } from '@/i18n/react-i18next-compat'
interface ImageModalProps {
image: { url: string; alt: string } | null
onClose: () => void
}
const ImageModal = ({ image, onClose }: ImageModalProps) => {
const { t } = useTranslation()
return (
<Dialog open={!!image} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] p-0">
<DialogHeader className="p-6 pb-2">
<DialogTitle>{image?.alt || t('common:image')}</DialogTitle>
</DialogHeader>
<div className="flex justify-center items-center p-6 pt-2">
{image && (
<img
src={image.url}
alt={image.alt}
className="max-w-full max-h-[70vh] object-contain rounded-md"
onError={(e) => {
e.currentTarget.style.display = 'none'
}}
/>
)}
</div>
</DialogContent>
</Dialog>
)
}
export default ImageModal

View File

@ -0,0 +1,71 @@
import { useState } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogHeader,
} from '@/components/ui/dialog'
import { IconInfoCircle } from '@tabler/icons-react'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip'
import CodeEditor from '@uiw/react-textarea-code-editor'
import '@uiw/react-textarea-code-editor/dist.css'
interface MessageMetadataDialogProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata: any
triggerElement?: React.ReactNode
}
export function MessageMetadataDialog({
metadata,
triggerElement,
}: MessageMetadataDialogProps) {
const { t } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const defaultTrigger = (
<Tooltip>
<TooltipTrigger asChild>
<button className="outline-0 focus:outline-0 flex items-center gap-1 hover:text-accent transition-colors cursor-pointer group relative">
<IconInfoCircle size={16} />
</button>
</TooltipTrigger>
<TooltipContent>
<p>{t('metadata')}</p>
</TooltipContent>
</Tooltip>
)
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger>{triggerElement || defaultTrigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:dialogs.messageMetadata.title')}</DialogTitle>
<div className="space-y-2 mt-4">
<div className="border border-main-view-fg/10 rounded-md">
<CodeEditor
value={JSON.stringify(metadata || {}, null, 2)}
language="json"
readOnly
data-color-mode="dark"
style={{
fontSize: 12,
backgroundColor: 'transparent',
fontFamily: 'monospace',
}}
className="w-full h-full !text-sm "
/>
</div>
</div>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from '@/i18n/react-i18next-compat'
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogClose,
DialogFooter,
DialogHeader,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { IconEdit } from '@tabler/icons-react'
import { DropdownMenuItem } from '@/components/ui/dropdown-menu'
import { toast } from 'sonner'
interface RenameThreadDialogProps {
thread: Thread
plainTitleForRename: string
onRename: (threadId: string, title: string) => void
onDropdownClose: () => void
}
export function RenameThreadDialog({
thread,
plainTitleForRename,
onRename,
onDropdownClose,
}: RenameThreadDialogProps) {
const { t } = useTranslation()
const [title, setTitle] = useState('')
const [isOpen, setIsOpen] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isOpen && inputRef.current) {
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 100)
}
}, [isOpen])
const handleOpenChange = (open: boolean) => {
setIsOpen(open)
if (open) {
setTitle(plainTitleForRename || t('common:newThread'))
} else {
onDropdownClose()
}
}
const handleRename = () => {
if (title.trim()) {
onRename(thread.id, title.trim())
setIsOpen(false)
onDropdownClose()
toast.success(t('common:toast.renameThread.title'), {
id: 'rename-thread',
description: t('common:toast.renameThread.description', { title }),
})
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
e.stopPropagation()
if (e.key === 'Enter' && title.trim()) {
handleRename()
}
}
return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<IconEdit />
<span>{t('common:rename')}</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('common:threadTitle')}</DialogTitle>
<Input
ref={inputRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-2"
onKeyDown={handleKeyDown}
placeholder={t('common:threadTitle')}
aria-label={t('common:threadTitle')}
/>
<DialogFooter className="mt-4 flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<DialogClose asChild>
<Button variant="link" size="sm" className="w-full sm:w-auto">
{t('common:cancel')}
</Button>
</DialogClose>
<Button
disabled={!title.trim()}
onClick={handleRename}
size="sm"
className="w-full sm:w-auto"
>
{t('common:rename')}
</Button>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,9 @@
export { RenameThreadDialog } from './RenameThreadDialog'
export { DeleteThreadDialog } from './DeleteThreadDialog'
export { DeleteAllThreadsDialog } from './DeleteAllThreadsDialog'
export { EditMessageDialog } from './EditMessageDialog'
export { MessageMetadataDialog } from './MessageMetadataDialog'
export { DeleteMessageDialog } from './DeleteMessageDialog'
export { FactoryResetDialog } from './FactoryResetDialog'
export { DeleteAssistantDialog } from './DeleteAssistantDialog'
export { AddProviderDialog } from './AddProviderDialog'

View File

@ -7,15 +7,7 @@ import { useAssistant } from '@/hooks/useAssistant'
import HeaderPage from '@/containers/HeaderPage' import HeaderPage from '@/containers/HeaderPage'
import { IconCirclePlus, IconPencil, IconTrash } from '@tabler/icons-react' import { IconCirclePlus, IconPencil, IconTrash } from '@tabler/icons-react'
import AddEditAssistant from '@/containers/dialogs/AddEditAssistant' import AddEditAssistant from '@/containers/dialogs/AddEditAssistant'
import { import { DeleteAssistantDialog } from '@/containers/dialogs'
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AvatarEmoji } from '@/containers/AvatarEmoji' import { AvatarEmoji } from '@/containers/AvatarEmoji'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
@ -64,7 +56,8 @@ function Assistant() {
<div className="h-full p-4 overflow-y-auto"> <div className="h-full p-4 overflow-y-auto">
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{assistants {assistants
.slice().sort((a, b) => a.created_at - b.created_at) .slice()
.sort((a, b) => a.created_at - b.created_at)
.map((assistant) => ( .map((assistant) => (
<div <div
className="bg-main-view-fg/3 p-3 rounded-md" className="bg-main-view-fg/3 p-3 rounded-md"
@ -134,27 +127,11 @@ function Assistant() {
} }
onSave={handleSave} onSave={handleSave}
/> />
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <DeleteAssistantDialog
<DialogContent> open={deleteConfirmOpen}
<DialogHeader> onOpenChange={setDeleteConfirmOpen}
<DialogTitle>{t('assistants:deleteConfirmation')}</DialogTitle> onConfirm={confirmDelete}
<DialogDescription> />
{t('assistants:deleteConfirmationDesc')}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="link"
onClick={() => setDeleteConfirmOpen(false)}
>
{t('assistants:cancel')}
</Button>
<Button variant="destructive" onClick={confirmDelete} autoFocus>
{t('assistants:delete')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
) )

View File

@ -10,17 +10,7 @@ import { useGeneralSetting } from '@/hooks/useGeneralSetting'
import { useAppUpdater } from '@/hooks/useAppUpdater' import { useAppUpdater } from '@/hooks/useAppUpdater'
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import ChangeDataFolderLocation from '@/containers/dialogs/ChangeDataFolderLocation' import ChangeDataFolderLocation from '@/containers/dialogs/ChangeDataFolderLocation'
import { FactoryResetDialog } from '@/containers/dialogs'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { useServiceHub } from '@/hooks/useServiceHub' import { useServiceHub } from '@/hooks/useServiceHub'
import { import {
IconBrandDiscord, IconBrandDiscord,
@ -372,42 +362,11 @@ function General() {
ns: 'settings', ns: 'settings',
})} })}
actions={ actions={
<Dialog> <FactoryResetDialog onReset={resetApp}>
<DialogTrigger asChild> <Button variant="destructive" size="sm">
<Button variant="destructive" size="sm"> {t('common:reset')}
{t('common:reset')} </Button>
</Button> </FactoryResetDialog>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('settings:general.factoryResetTitle')}
</DialogTitle>
<DialogDescription>
{t('settings:general.factoryResetDesc')}
</DialogDescription>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('settings:general.cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
onClick={() => resetApp()}
>
{t('settings:general.reset')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
} }
/> />
</Card> </Card>

View File

@ -10,18 +10,9 @@ import { useNavigate } from '@tanstack/react-router'
import { IconCirclePlus, IconSettings } from '@tabler/icons-react' import { IconCirclePlus, IconSettings } from '@tabler/icons-react'
import { getProviderTitle } from '@/lib/utils' import { getProviderTitle } from '@/lib/utils'
import ProvidersAvatar from '@/containers/ProvidersAvatar' import ProvidersAvatar from '@/containers/ProvidersAvatar'
import { import { AddProviderDialog } from '@/containers/dialogs'
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { useCallback, useState } from 'react' import { useCallback } from 'react'
import { openAIProviderSettings } from '@/consts/providers' import { openAIProviderSettings } from '@/consts/providers'
import cloneDeep from 'lodash/cloneDeep' import cloneDeep from 'lodash/cloneDeep'
import { toast } from 'sonner' import { toast } from 'sonner'
@ -39,33 +30,35 @@ function ModelProviders() {
const serviceHub = useServiceHub() const serviceHub = useServiceHub()
const { providers, addProvider, updateProvider } = useModelProvider() const { providers, addProvider, updateProvider } = useModelProvider()
const navigate = useNavigate() const navigate = useNavigate()
const [name, setName] = useState('')
const createProvider = useCallback(() => { const createProvider = useCallback(
if ( (name: string) => {
providers.some((e) => e.provider.toLowerCase() === name.toLowerCase()) if (
) { providers.some((e) => e.provider.toLowerCase() === name.toLowerCase())
toast.error(t('providerAlreadyExists', { name })) ) {
return toast.error(t('providerAlreadyExists', { name }))
} return
const newProvider = { }
provider: name, const newProvider = {
active: true, provider: name,
models: [], active: true,
settings: cloneDeep(openAIProviderSettings) as ProviderSetting[], models: [],
api_key: '', settings: cloneDeep(openAIProviderSettings) as ProviderSetting[],
base_url: 'https://api.openai.com/v1', api_key: '',
} base_url: 'https://api.openai.com/v1',
addProvider(newProvider) }
setTimeout(() => { addProvider(newProvider)
navigate({ setTimeout(() => {
to: route.settings.providers, navigate({
params: { to: route.settings.providers,
providerName: name, params: {
}, providerName: name,
}) },
}, 0) })
}, [providers, name, addProvider, t, navigate]) }, 0)
},
[providers, addProvider, t, navigate]
)
// Check if model provider settings are enabled for this platform // Check if model provider settings are enabled for this platform
if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) { if (!PlatformFeatures[PlatformFeature.MODEL_PROVIDER_SETTINGS]) {
@ -107,53 +100,18 @@ function ModelProviders() {
<span className="text-main-view-fg font-medium text-base"> <span className="text-main-view-fg font-medium text-base">
{t('common:modelProviders')} {t('common:modelProviders')}
</span> </span>
<Dialog> <AddProviderDialog onCreateProvider={createProvider}>
<DialogTrigger asChild> <Button
<Button variant="link"
variant="link" size="sm"
size="sm" className="flex items-center gap-2"
className="flex items-center gap-2" >
> <div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2">
<div className="cursor-pointer flex items-center justify-center rounded hover:bg-main-view-fg/15 bg-main-view-fg/10 transition-all duration-200 ease-in-out p-1.5 py-1 gap-1 -mr-2"> <IconCirclePlus size={16} />
<IconCirclePlus size={16} /> <span>{t('provider:addProvider')}</span>
<span>{t('provider:addProvider')}</span> </div>
</div> </Button>
</Button> </AddProviderDialog>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t('provider:addOpenAIProvider')}
</DialogTitle>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2"
placeholder={t('provider:enterNameForProvider')}
onKeyDown={(e) => {
// Prevent key from being captured by parent components
e.stopPropagation()
}}
/>
<DialogFooter className="mt-2 flex items-center">
<DialogClose asChild>
<Button
variant="link"
size="sm"
className="hover:no-underline"
>
{t('common:cancel')}
</Button>
</DialogClose>
<DialogClose asChild>
<Button disabled={!name} onClick={createProvider}>
{t('common:create')}
</Button>
</DialogClose>
</DialogFooter>
</DialogHeader>
</DialogContent>
</Dialog>
</div> </div>
} }
> >
@ -198,7 +156,10 @@ function ModelProviders() {
<Switch <Switch
checked={provider.active} checked={provider.active}
onCheckedChange={async (e) => { onCheckedChange={async (e) => {
if (!e && provider.provider.toLowerCase() === 'llamacpp') { if (
!e &&
provider.provider.toLowerCase() === 'llamacpp'
) {
await serviceHub.models().stopAllModels() await serviceHub.models().stopAllModels()
} }
updateProvider(provider.provider, { updateProvider(provider.provider, {