feat: thread organization folder
This commit is contained in:
parent
3dcf522224
commit
e7a1a06395
@ -21,4 +21,5 @@ export const localStorageKey = {
|
||||
lastUsedAssistant: 'last-used-assistant',
|
||||
favoriteModels: 'favorite-models',
|
||||
setupCompleted: 'setup-completed',
|
||||
threadManagement: 'thread-management',
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ export const route = {
|
||||
home: '/',
|
||||
appLogs: '/logs',
|
||||
assistant: '/assistant',
|
||||
project: '/project',
|
||||
projectDetail: '/project/$projectId',
|
||||
settings: {
|
||||
index: '/settings',
|
||||
model_providers: '/settings/providers',
|
||||
|
||||
@ -4,6 +4,7 @@ import TextareaAutosize from 'react-textarea-autosize'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { usePrompt } from '@/hooks/usePrompt'
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
@ -43,9 +44,15 @@ type ChatInputProps = {
|
||||
showSpeedToken?: boolean
|
||||
model?: ThreadModel
|
||||
initialMessage?: boolean
|
||||
projectId?: string
|
||||
}
|
||||
|
||||
const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
|
||||
const ChatInput = ({
|
||||
model,
|
||||
className,
|
||||
initialMessage,
|
||||
projectId,
|
||||
}: ChatInputProps) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const [rows, setRows] = useState(1)
|
||||
@ -58,6 +65,8 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
|
||||
const prompt = usePrompt((state) => state.prompt)
|
||||
const setPrompt = usePrompt((state) => state.setPrompt)
|
||||
const currentThreadId = useThreads((state) => state.currentThreadId)
|
||||
const updateThread = useThreads((state) => state.updateThread)
|
||||
const { getFolderById } = useThreadManagement()
|
||||
const { t } = useTranslation()
|
||||
const spellCheckChatInput = useGeneralSetting(
|
||||
(state) => state.spellCheckChatInput
|
||||
@ -177,6 +186,28 @@ const ChatInput = ({ model, className, initialMessage }: ChatInputProps) => {
|
||||
uploadedFiles.length > 0 ? uploadedFiles : undefined
|
||||
)
|
||||
setUploadedFiles([])
|
||||
|
||||
// Handle project assignment for new threads
|
||||
if (projectId && !currentThreadId) {
|
||||
const project = getFolderById(projectId)
|
||||
if (project) {
|
||||
// Use setTimeout to ensure the thread is created first
|
||||
setTimeout(() => {
|
||||
const newCurrentThreadId = useThreads.getState().currentThreadId
|
||||
if (newCurrentThreadId) {
|
||||
updateThread(newCurrentThreadId, {
|
||||
metadata: {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
updated_at: project.updated_at,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -4,14 +4,18 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
IconLayoutSidebar,
|
||||
IconDots,
|
||||
IconCirclePlusFilled,
|
||||
IconSettingsFilled,
|
||||
IconCirclePlus,
|
||||
IconSettings,
|
||||
IconStar,
|
||||
IconMessageFilled,
|
||||
IconAppsFilled,
|
||||
IconFolderPlus,
|
||||
IconMessage,
|
||||
IconApps,
|
||||
IconX,
|
||||
IconSearch,
|
||||
IconClipboardSmileFilled,
|
||||
IconClipboardSmile,
|
||||
IconFolder,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react'
|
||||
import { route } from '@/constants/routes'
|
||||
import ThreadList from './ThreadList'
|
||||
@ -28,6 +32,7 @@ import { UserProfileMenu } from '@/containers/auth/UserProfileMenu'
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { useMemo, useState, useEffect, useRef } from 'react'
|
||||
@ -37,38 +42,40 @@ import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||
import { useClickOutside } from '@/hooks/useClickOutside'
|
||||
|
||||
import { DeleteAllThreadsDialog } from '@/containers/dialogs'
|
||||
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
|
||||
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
|
||||
|
||||
const mainMenus = [
|
||||
{
|
||||
title: 'common:newChat',
|
||||
icon: IconCirclePlusFilled,
|
||||
icon: IconCirclePlus,
|
||||
route: route.home,
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
title: 'Projects',
|
||||
icon: IconFolderPlus,
|
||||
route: route.project,
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
title: 'common:assistants',
|
||||
icon: IconClipboardSmileFilled,
|
||||
icon: IconClipboardSmile,
|
||||
route: route.assistant,
|
||||
isEnabled: PlatformFeatures[PlatformFeature.ASSISTANTS],
|
||||
},
|
||||
{
|
||||
title: 'common:hub',
|
||||
icon: IconAppsFilled,
|
||||
icon: IconApps,
|
||||
route: route.hub.index,
|
||||
isEnabled: PlatformFeatures[PlatformFeature.MODEL_HUB],
|
||||
},
|
||||
{
|
||||
title: 'common:settings',
|
||||
icon: IconSettingsFilled,
|
||||
icon: IconSettings,
|
||||
route: route.settings.general,
|
||||
isEnabled: true,
|
||||
},
|
||||
{
|
||||
title: 'common:authentication',
|
||||
icon: null,
|
||||
route: null,
|
||||
isEnabled: PlatformFeatures[PlatformFeature.AUTHENTICATION],
|
||||
},
|
||||
]
|
||||
|
||||
const LeftPanel = () => {
|
||||
@ -152,20 +159,65 @@ const LeftPanel = () => {
|
||||
const getFilteredThreads = useThreads((state) => state.getFilteredThreads)
|
||||
const threads = useThreads((state) => state.threads)
|
||||
|
||||
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
|
||||
useThreadManagement()
|
||||
|
||||
// Project dialog states
|
||||
const [projectDialogOpen, setProjectDialogOpen] = useState(false)
|
||||
const [editingProjectKey, setEditingProjectKey] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] =
|
||||
useState(false)
|
||||
const [deletingProjectId, setDeletingProjectId] = useState<string | null>(
|
||||
null
|
||||
)
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
return getFilteredThreads(searchTerm)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [getFilteredThreads, searchTerm, threads])
|
||||
|
||||
const filteredProjects = useMemo(() => {
|
||||
if (!searchTerm) return folders
|
||||
return folders.filter((folder) =>
|
||||
folder.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}, [folders, searchTerm])
|
||||
|
||||
// Memoize categorized threads based on filteredThreads
|
||||
const favoritedThreads = useMemo(() => {
|
||||
return filteredThreads.filter((t) => t.isFavorite)
|
||||
}, [filteredThreads])
|
||||
|
||||
const unFavoritedThreads = useMemo(() => {
|
||||
return filteredThreads.filter((t) => !t.isFavorite)
|
||||
return filteredThreads.filter((t) => !t.isFavorite && !t.metadata?.project)
|
||||
}, [filteredThreads])
|
||||
|
||||
// Project handlers
|
||||
const handleProjectDelete = (id: string) => {
|
||||
setDeletingProjectId(id)
|
||||
setDeleteProjectConfirmOpen(true)
|
||||
}
|
||||
|
||||
const confirmProjectDelete = () => {
|
||||
if (deletingProjectId) {
|
||||
deleteFolder(deletingProjectId)
|
||||
setDeleteProjectConfirmOpen(false)
|
||||
setDeletingProjectId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProjectSave = (name: string) => {
|
||||
if (editingProjectKey) {
|
||||
updateFolder(editingProjectKey, name)
|
||||
} else {
|
||||
addFolder(name)
|
||||
}
|
||||
setProjectDialogOpen(false)
|
||||
setEditingProjectKey(null)
|
||||
}
|
||||
|
||||
// Disable body scroll when panel is open on small screens
|
||||
useEffect(() => {
|
||||
if (isSmallScreen && open) {
|
||||
@ -260,15 +312,12 @@ const LeftPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between overflow-hidden mt-0 !h-[calc(100%-42px)] ">
|
||||
<div className={cn('flex flex-col !h-[calc(100%-200px)]')}>
|
||||
<div className="flex flex-col gap-y-1 overflow-hidden mt-0 !h-[calc(100%-42px)]">
|
||||
<div className="space-y-1 py-1">
|
||||
{IS_MACOS && (
|
||||
<div
|
||||
ref={searchContainerMacRef}
|
||||
className={cn(
|
||||
'relative mb-4 mt-1',
|
||||
isResizableContext ? 'mx-2' : 'mx-1'
|
||||
)}
|
||||
className={cn('relative mb-2 mt-1 mx-1')}
|
||||
data-ignore-outside-clicks
|
||||
>
|
||||
<IconSearch className="absolute size-4 top-1/2 left-2 -translate-y-1/2 text-left-panel-fg/50" />
|
||||
@ -294,7 +343,151 @@ const LeftPanel = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col w-full overflow-y-auto overflow-x-hidden">
|
||||
|
||||
{mainMenus.map((menu) => {
|
||||
if (!menu.isEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle authentication menu specially
|
||||
if (menu.title === 'common:authentication') {
|
||||
return (
|
||||
<div key={menu.title}>
|
||||
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
|
||||
{isAuthenticated ? (
|
||||
<UserProfileMenu />
|
||||
) : (
|
||||
<AuthLoginButton />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular menu items must have route and icon
|
||||
if (!menu.route || !menu.icon) return null
|
||||
|
||||
const isActive = (() => {
|
||||
// Settings routes
|
||||
if (menu.route.includes(route.settings.index)) {
|
||||
return currentPath.includes(route.settings.index)
|
||||
}
|
||||
|
||||
// Default exact match for other routes
|
||||
return currentPath === menu.route
|
||||
})()
|
||||
return (
|
||||
<Link
|
||||
key={menu.title}
|
||||
to={menu.route}
|
||||
onClick={() => isSmallScreen && setLeftPanel(false)}
|
||||
data-test-id={`menu-${menu.title}`}
|
||||
activeOptions={{ exact: true }}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
|
||||
isActive && 'bg-left-panel-fg/10'
|
||||
)}
|
||||
>
|
||||
<menu.icon size={18} className="text-left-panel-fg/70" />
|
||||
<span className="font-medium text-left-panel-fg/90">
|
||||
{t(menu.title)}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredProjects.length > 0 && (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="block text-xs text-left-panel-fg/50 px-1 font-semibold">
|
||||
Projects
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{filteredProjects
|
||||
.slice()
|
||||
.sort((a, b) => b.updated_at - a.updated_at)
|
||||
.map((folder) => {
|
||||
const ProjectItem = () => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false)
|
||||
const isProjectActive =
|
||||
currentPath === `/project/${folder.id}`
|
||||
|
||||
return (
|
||||
<div key={folder.id} className="mb-1">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/project-list transition-all cursor-pointer',
|
||||
isProjectActive && 'bg-left-panel-fg/10'
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
to="/project/$projectId"
|
||||
params={{ projectId: folder.id }}
|
||||
onClick={() =>
|
||||
isSmallScreen && setLeftPanel(false)
|
||||
}
|
||||
className="py-1 pr-2 truncate flex items-center gap-2 flex-1"
|
||||
>
|
||||
<IconFolder
|
||||
size={16}
|
||||
className="text-left-panel-fg/70"
|
||||
/>
|
||||
<span className="text-sm text-left-panel-fg/90">
|
||||
{folder.name}
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu
|
||||
open={openDropdown}
|
||||
onOpenChange={(open) => setOpenDropdown(open)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconDots
|
||||
size={14}
|
||||
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/project-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingProjectKey(folder.id)
|
||||
setProjectDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil size={16} />
|
||||
<span>Edit</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleProjectDelete(folder.id)
|
||||
}}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ProjectItem key={folder.id} />
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col h-full overflow-y-scroll w-[calc(100%+6px)]">
|
||||
<div className="flex flex-col w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
{favoritedThreads.length > 0 && (
|
||||
<>
|
||||
@ -397,7 +590,7 @@ const LeftPanel = () => {
|
||||
<>
|
||||
<div className="px-1 mt-2">
|
||||
<div className="flex items-center gap-1 text-left-panel-fg/80">
|
||||
<IconMessageFilled size={18} />
|
||||
<IconMessage size={18} />
|
||||
<h6 className="font-medium text-base">
|
||||
{t('common:noThreadsYet')}
|
||||
</h6>
|
||||
@ -414,59 +607,36 @@ const LeftPanel = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{PlatformFeatures[PlatformFeature.AUTHENTICATION] && (
|
||||
<div className="space-y-1 shrink-0 py-1">
|
||||
<div>
|
||||
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
|
||||
{isAuthenticated ? <UserProfileMenu /> : <AuthLoginButton />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DownloadManagement />
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 shrink-0 py-1 mt-2">
|
||||
{mainMenus.map((menu) => {
|
||||
if (!menu.isEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle authentication menu specially
|
||||
if (menu.title === 'common:authentication') {
|
||||
return (
|
||||
<div key={menu.title}>
|
||||
<div className="mx-1 my-2 border-t border-left-panel-fg/5" />
|
||||
{isAuthenticated ? (
|
||||
<UserProfileMenu />
|
||||
) : (
|
||||
<AuthLoginButton />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Regular menu items must have route and icon
|
||||
if (!menu.route || !menu.icon) return null
|
||||
|
||||
const isActive =
|
||||
currentPath.includes(route.settings.index) &&
|
||||
menu.route.includes(route.settings.index)
|
||||
return (
|
||||
<Link
|
||||
key={menu.title}
|
||||
to={menu.route}
|
||||
onClick={() => isSmallScreen && setLeftPanel(false)}
|
||||
data-test-id={`menu-${menu.title}`}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 cursor-pointer hover:bg-left-panel-fg/10 py-1 px-1 rounded',
|
||||
isActive
|
||||
? 'bg-left-panel-fg/10'
|
||||
: '[&.active]:bg-left-panel-fg/10'
|
||||
)}
|
||||
>
|
||||
<menu.icon size={18} className="text-left-panel-fg/70" />
|
||||
<span className="font-medium text-left-panel-fg/90">
|
||||
{t(menu.title)}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DownloadManagement />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Project Dialogs */}
|
||||
<AddProjectDialog
|
||||
open={projectDialogOpen}
|
||||
onOpenChange={setProjectDialogOpen}
|
||||
editingKey={editingProjectKey}
|
||||
initialData={
|
||||
editingProjectKey ? getFolderById(editingProjectKey) : undefined
|
||||
}
|
||||
onSave={handleProjectSave}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
open={deleteProjectConfirmOpen}
|
||||
onOpenChange={setDeleteProjectConfirmOpen}
|
||||
onConfirm={confirmProjectDelete}
|
||||
projectName={deletingProjectId ? getFolderById(deletingProjectId)?.name : undefined}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -16,9 +16,13 @@ import {
|
||||
IconDots,
|
||||
IconStarFilled,
|
||||
IconStar,
|
||||
IconFolder,
|
||||
IconX,
|
||||
} from '@tabler/icons-react'
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||
import { useLeftPanel } from '@/hooks/useLeftPanel'
|
||||
import { useMessages } from '@/hooks/useMessages'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||
|
||||
@ -28,147 +32,268 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useNavigate, useMatches } from '@tanstack/react-router'
|
||||
import { RenameThreadDialog, DeleteThreadDialog } from '@/containers/dialogs'
|
||||
import { route } from '@/constants/routes'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
const SortableItem = memo(({ thread }: { thread: Thread }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: thread.id, disabled: true })
|
||||
const SortableItem = memo(
|
||||
({
|
||||
thread,
|
||||
variant,
|
||||
}: {
|
||||
thread: Thread
|
||||
variant?: 'default' | 'project'
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: thread.id, disabled: true })
|
||||
|
||||
const isSmallScreen = useSmallScreen()
|
||||
const setLeftPanel = useLeftPanel(state => state.setLeftPanel)
|
||||
const isSmallScreen = useSmallScreen()
|
||||
const setLeftPanel = useLeftPanel((state) => state.setLeftPanel)
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
const toggleFavorite = useThreads((state) => state.toggleFavorite)
|
||||
const deleteThread = useThreads((state) => state.deleteThread)
|
||||
const renameThread = useThreads((state) => state.renameThread)
|
||||
const { t } = useTranslation()
|
||||
const [openDropdown, setOpenDropdown] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
// Check if current route matches this thread's detail page
|
||||
const matches = useMatches()
|
||||
const isActive = matches.some(
|
||||
(match) =>
|
||||
match.routeId === '/threads/$threadId' &&
|
||||
'threadId' in match.params &&
|
||||
match.params.threadId === thread.id
|
||||
)
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}
|
||||
const toggleFavorite = useThreads((state) => state.toggleFavorite)
|
||||
const deleteThread = useThreads((state) => state.deleteThread)
|
||||
const renameThread = useThreads((state) => state.renameThread)
|
||||
const updateThread = useThreads((state) => state.updateThread)
|
||||
const getFolderById = useThreadManagement().getFolderById
|
||||
const { folders } = useThreadManagement()
|
||||
const getMessages = useMessages((state) => state.getMessages)
|
||||
const { t } = useTranslation()
|
||||
const [openDropdown, setOpenDropdown] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
// Check if current route matches this thread's detail page
|
||||
const matches = useMatches()
|
||||
const isActive = matches.some(
|
||||
(match) =>
|
||||
match.routeId === '/threads/$threadId' &&
|
||||
'threadId' in match.params &&
|
||||
match.params.threadId === thread.id
|
||||
)
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isDragging) {
|
||||
// Only close panel and navigate if the thread is not already active
|
||||
if (!isActive) {
|
||||
if (isSmallScreen) setLeftPanel(false)
|
||||
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
|
||||
const handleClick = () => {
|
||||
if (!isDragging) {
|
||||
// Only close panel and navigate if the thread is not already active
|
||||
if (!isActive) {
|
||||
if (isSmallScreen) setLeftPanel(false)
|
||||
navigate({ to: route.threadsDetail, params: { threadId: thread.id } })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const plainTitleForRename = useMemo(() => {
|
||||
// Basic HTML stripping for simple span tags.
|
||||
// If thread.title is undefined or null, treat as empty string before replace.
|
||||
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
|
||||
}, [thread.title])
|
||||
const plainTitleForRename = useMemo(() => {
|
||||
// Basic HTML stripping for simple span tags.
|
||||
// If thread.title is undefined or null, treat as empty string before replace.
|
||||
return (thread.title || '').replace(/<span[^>]*>|<\/span>/g, '')
|
||||
}, [thread.title])
|
||||
|
||||
const assignThreadToProject = (threadId: string, projectId: string) => {
|
||||
const project = getFolderById(projectId)
|
||||
if (project && updateThread) {
|
||||
const projectMetadata = {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
updated_at: project.updated_at,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpenDropdown(true)
|
||||
}}
|
||||
className={cn(
|
||||
'mb-1 rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
|
||||
isDragging ? 'cursor-move' : 'cursor-pointer',
|
||||
isActive && 'bg-left-panel-fg/10'
|
||||
)}
|
||||
>
|
||||
<div className="py-1 pr-2 truncate">
|
||||
<span>{thread.title || t('common:newThread')}</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu
|
||||
open={openDropdown}
|
||||
onOpenChange={(open) => setOpenDropdown(open)}
|
||||
updateThread(threadId, {
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
project: projectMetadata,
|
||||
},
|
||||
})
|
||||
|
||||
toast.success(`Thread assigned to "${project.name}" successfully`)
|
||||
}
|
||||
}
|
||||
|
||||
const getLastMessageInfo = useMemo(() => {
|
||||
const messages = getMessages(thread.id)
|
||||
if (messages.length === 0) return null
|
||||
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
return {
|
||||
date: new Date(lastMessage.created_at || 0),
|
||||
content: lastMessage.content?.[0]?.text?.value || '',
|
||||
}
|
||||
}, [getMessages, thread.id])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setOpenDropdown(true)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded hover:bg-left-panel-fg/10 flex items-center justify-between gap-2 px-1.5 group/thread-list transition-all',
|
||||
variant === 'project'
|
||||
? 'mb-2 rounded-lg px-4 border border-main-view-fg/10 bg-main-view-fg/5'
|
||||
: 'mb-1',
|
||||
isDragging ? 'cursor-move' : 'cursor-pointer',
|
||||
isActive && 'bg-left-panel-fg/10'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'pr-2 truncate',
|
||||
variant === 'project' ? 'py-2' : 'py-1'
|
||||
)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconDots
|
||||
size={14}
|
||||
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end">
|
||||
{thread.isFavorite ? (
|
||||
<DropdownMenuItem
|
||||
<span>{thread.title || t('common:newThread')}</span>
|
||||
{variant === 'project' && (
|
||||
<>
|
||||
{variant === 'project' && getLastMessageInfo?.content && (
|
||||
<div className="text-sm text-main-view-fg/60 mt-0.5 line-clamp-2">
|
||||
{getLastMessageInfo.content}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu
|
||||
open={openDropdown}
|
||||
onOpenChange={(open) => setOpenDropdown(open)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconDots
|
||||
size={14}
|
||||
className="text-left-panel-fg/60 shrink-0 cursor-pointer px-0.5 -mr-1 data-[state=open]:bg-left-panel-fg/10 rounded group-hover/thread-list:data-[state=closed]:size-5 size-5 data-[state=closed]:size-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toggleFavorite(thread.id)
|
||||
}}
|
||||
>
|
||||
<IconStarFilled />
|
||||
<span>{t('common:unstar')}</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(thread.id)
|
||||
}}
|
||||
>
|
||||
<IconStar />
|
||||
<span>{t('common:star')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<RenameThreadDialog
|
||||
thread={thread}
|
||||
plainTitleForRename={plainTitleForRename}
|
||||
onRename={renameThread}
|
||||
onDropdownClose={() => setOpenDropdown(false)}
|
||||
/>
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="bottom" align="end" className="w-44">
|
||||
{thread.isFavorite ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(thread.id)
|
||||
}}
|
||||
>
|
||||
<IconStarFilled />
|
||||
<span>{t('common:unstar')}</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFavorite(thread.id)
|
||||
}}
|
||||
>
|
||||
<IconStar />
|
||||
<span>{t('common:star')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<RenameThreadDialog
|
||||
thread={thread}
|
||||
plainTitleForRename={plainTitleForRename}
|
||||
onRename={renameThread}
|
||||
onDropdownClose={() => setOpenDropdown(false)}
|
||||
/>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DeleteThreadDialog
|
||||
thread={thread}
|
||||
onDelete={deleteThread}
|
||||
onDropdownClose={() => setOpenDropdown(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="gap-2">
|
||||
<IconFolder size={16} />
|
||||
<span>Add to project</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
{folders.length === 0 ? (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-left-panel-fg/50">
|
||||
No projects available
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
folders
|
||||
.sort((a, b) => b.updated_at - a.updated_at)
|
||||
.map((folder) => (
|
||||
<DropdownMenuItem
|
||||
key={folder.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
assignThreadToProject(thread.id, folder.id)
|
||||
}}
|
||||
>
|
||||
<IconFolder size={16} />
|
||||
<span className="truncate max-w-[200px]">
|
||||
{folder.name}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
{thread.metadata?.project && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Remove project from metadata
|
||||
const projectName = thread.metadata?.project?.name
|
||||
updateThread(thread.id, {
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
project: undefined,
|
||||
},
|
||||
})
|
||||
toast.success(
|
||||
`Thread removed from "${projectName}" successfully`
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconX size={16} />
|
||||
<span>Remove from project</span>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DeleteThreadDialog
|
||||
thread={thread}
|
||||
onDelete={deleteThread}
|
||||
onDropdownClose={() => setOpenDropdown(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type ThreadListProps = {
|
||||
threads: Thread[]
|
||||
isFavoriteSection?: boolean
|
||||
variant?: 'default' | 'project'
|
||||
showDate?: boolean
|
||||
}
|
||||
|
||||
function ThreadList({ threads }: ThreadListProps) {
|
||||
function ThreadList({ threads, variant = 'default' }: ThreadListProps) {
|
||||
const sortedThreads = useMemo(() => {
|
||||
return threads.sort((a, b) => {
|
||||
return (b.updated || 0) - (a.updated || 0)
|
||||
@ -192,7 +317,7 @@ function ThreadList({ threads }: ThreadListProps) {
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{sortedThreads.map((thread, index) => (
|
||||
<SortableItem key={index} thread={thread} />
|
||||
<SortableItem key={index} thread={thread} variant={variant} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
125
web-app/src/containers/dialogs/AddProjectDialog.tsx
Normal file
125
web-app/src/containers/dialogs/AddProjectDialog.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useState, useEffect } 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 { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
|
||||
interface AddProjectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
editingKey: string | null
|
||||
initialData?: {
|
||||
id: string
|
||||
name: string
|
||||
updated_at: number
|
||||
}
|
||||
onSave: (name: string) => void
|
||||
}
|
||||
|
||||
export default function AddProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
editingKey,
|
||||
initialData,
|
||||
onSave,
|
||||
}: AddProjectDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [name, setName] = useState(initialData?.name || '')
|
||||
const { folders } = useThreadManagement()
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialData?.name || '')
|
||||
}
|
||||
}, [open, initialData])
|
||||
|
||||
const handleSave = () => {
|
||||
if (!name.trim()) return
|
||||
|
||||
const trimmedName = name.trim()
|
||||
|
||||
// Check for duplicate names (excluding current project when editing)
|
||||
const isDuplicate = folders.some(
|
||||
(folder) =>
|
||||
folder.name.toLowerCase() === trimmedName.toLowerCase() &&
|
||||
folder.id !== editingKey
|
||||
)
|
||||
|
||||
if (isDuplicate) {
|
||||
toast.warning(t('projects.addProjectDialog.alreadyExists', { projectName: trimmedName }))
|
||||
return
|
||||
}
|
||||
|
||||
onSave(trimmedName)
|
||||
|
||||
// Show detailed success message
|
||||
if (editingKey && initialData) {
|
||||
toast.success(
|
||||
t('projects.addProjectDialog.renameSuccess', {
|
||||
oldName: initialData.name,
|
||||
newName: trimmedName
|
||||
})
|
||||
)
|
||||
} else {
|
||||
toast.success(t('projects.addProjectDialog.createSuccess', { projectName: trimmedName }))
|
||||
}
|
||||
|
||||
setName('')
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onOpenChange(false)
|
||||
setName('')
|
||||
}
|
||||
|
||||
// Check if the button should be disabled
|
||||
const isButtonDisabled =
|
||||
!name.trim() || (editingKey && name.trim() === initialData?.name)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingKey ? t('projects.addProjectDialog.editTitle') : t('projects.addProjectDialog.createTitle')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-main-view-fg/80">
|
||||
{t('projects.addProjectDialog.nameLabel')}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('projects.addProjectDialog.namePlaceholder')}
|
||||
className="mt-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isButtonDisabled) {
|
||||
handleSave()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="link" onClick={handleCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={Boolean(isButtonDisabled)}>
|
||||
{editingKey ? t('projects.addProjectDialog.updateButton') : t('projects.addProjectDialog.createButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
85
web-app/src/containers/dialogs/DeleteProjectDialog.tsx
Normal file
85
web-app/src/containers/dialogs/DeleteProjectDialog.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from 'sonner'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
|
||||
interface DeleteProjectDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void
|
||||
projectName?: string
|
||||
}
|
||||
|
||||
export function DeleteProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
projectName,
|
||||
}: DeleteProjectDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const deleteButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleConfirm = () => {
|
||||
try {
|
||||
onConfirm()
|
||||
toast.success(
|
||||
projectName
|
||||
? t('projects.deleteProjectDialog.successWithName', { projectName })
|
||||
: t('projects.deleteProjectDialog.successWithoutName')
|
||||
)
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error(t('projects.deleteProjectDialog.error'))
|
||||
console.error('Delete project error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleConfirm()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault()
|
||||
deleteButtonRef.current?.focus()
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('projects.deleteProjectDialog.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('projects.deleteProjectDialog.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="link" onClick={() => onOpenChange(false)}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
ref={deleteButtonRef}
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={t('projects.deleteProjectDialog.ariaLabel', {
|
||||
projectName: projectName || t('projects.title').toLowerCase(),
|
||||
})}
|
||||
>
|
||||
{t('projects.deleteProjectDialog.deleteButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@ -6,4 +6,5 @@ export { MessageMetadataDialog } from './MessageMetadataDialog'
|
||||
export { DeleteMessageDialog } from './DeleteMessageDialog'
|
||||
export { FactoryResetDialog } from './FactoryResetDialog'
|
||||
export { DeleteAssistantDialog } from './DeleteAssistantDialog'
|
||||
export { AddProviderDialog } from './AddProviderDialog'
|
||||
export { DeleteProjectDialog } from './DeleteProjectDialog'
|
||||
export { AddProviderDialog } from './AddProviderDialog'
|
||||
|
||||
82
web-app/src/hooks/useThreadManagement.ts
Normal file
82
web-app/src/hooks/useThreadManagement.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { ulid } from 'ulidx'
|
||||
import { localStorageKey } from '@/constants/localStorage'
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
|
||||
type ThreadFolder = {
|
||||
id: string
|
||||
name: string
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
type ThreadManagementState = {
|
||||
folders: ThreadFolder[]
|
||||
setFolders: (folders: ThreadFolder[]) => void
|
||||
addFolder: (name: string) => void
|
||||
updateFolder: (id: string, name: string) => void
|
||||
deleteFolder: (id: string) => void
|
||||
getFolderById: (id: string) => ThreadFolder | undefined
|
||||
}
|
||||
|
||||
export const useThreadManagement = create<ThreadManagementState>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
folders: [],
|
||||
|
||||
setFolders: (folders) => {
|
||||
set({ folders })
|
||||
},
|
||||
|
||||
addFolder: (name) => {
|
||||
const newFolder: ThreadFolder = {
|
||||
id: ulid(),
|
||||
name,
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
set((state) => ({
|
||||
folders: [...state.folders, newFolder],
|
||||
}))
|
||||
},
|
||||
|
||||
updateFolder: (id, name) => {
|
||||
set((state) => ({
|
||||
folders: state.folders.map((folder) =>
|
||||
folder.id === id
|
||||
? { ...folder, name, updated_at: Date.now() }
|
||||
: folder
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
deleteFolder: (id) => {
|
||||
// Remove project metadata from all threads that belong to this project
|
||||
const threadsState = useThreads.getState()
|
||||
const threadsToUpdate = Object.values(threadsState.threads).filter(
|
||||
(thread) => thread.metadata?.project?.id === id
|
||||
)
|
||||
|
||||
threadsToUpdate.forEach((thread) => {
|
||||
threadsState.updateThread(thread.id, {
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
project: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
set((state) => ({
|
||||
folders: state.folders.filter((folder) => folder.id !== id),
|
||||
}))
|
||||
},
|
||||
|
||||
getFolderById: (id) => {
|
||||
return get().folders.find((folder) => folder.id === id)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: localStorageKey.threadManagement,
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
)
|
||||
@ -20,12 +20,14 @@ type ThreadState = {
|
||||
createThread: (
|
||||
model: ThreadModel,
|
||||
title?: string,
|
||||
assistant?: Assistant
|
||||
assistant?: Assistant,
|
||||
projectMetadata?: { id: string; name: string; updated_at: number }
|
||||
) => Promise<Thread>
|
||||
updateCurrentThreadModel: (model: ThreadModel) => void
|
||||
getFilteredThreads: (searchTerm: string) => Thread[]
|
||||
updateCurrentThreadAssistant: (assistant: Assistant) => void
|
||||
updateThreadTimestamp: (threadId: string) => void
|
||||
updateThread: (threadId: string, updates: Partial<Thread>) => void
|
||||
searchIndex: Fzf<Thread[]> | null
|
||||
}
|
||||
|
||||
@ -132,20 +134,28 @@ export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
deleteAllThreads: () => {
|
||||
set((state) => {
|
||||
const allThreadIds = Object.keys(state.threads)
|
||||
const favoriteThreadIds = allThreadIds.filter(
|
||||
(threadId) => state.threads[threadId].isFavorite
|
||||
)
|
||||
const nonFavoriteThreadIds = allThreadIds.filter(
|
||||
(threadId) => !state.threads[threadId].isFavorite
|
||||
|
||||
// Identify threads to keep (favorites OR have project metadata)
|
||||
const threadsToKeepIds = allThreadIds.filter(
|
||||
(threadId) =>
|
||||
state.threads[threadId].isFavorite ||
|
||||
state.threads[threadId].metadata?.project
|
||||
)
|
||||
|
||||
// Only delete non-favorite threads
|
||||
nonFavoriteThreadIds.forEach((threadId) => {
|
||||
// Identify threads to delete (non-favorites AND no project metadata)
|
||||
const threadsToDeleteIds = allThreadIds.filter(
|
||||
(threadId) =>
|
||||
!state.threads[threadId].isFavorite &&
|
||||
!state.threads[threadId].metadata?.project
|
||||
)
|
||||
|
||||
// Delete threads that are not favorites and not in projects
|
||||
threadsToDeleteIds.forEach((threadId) => {
|
||||
getServiceHub().threads().deleteThread(threadId)
|
||||
})
|
||||
|
||||
// Keep only favorite threads
|
||||
const remainingThreads = favoriteThreadIds.reduce(
|
||||
// Keep favorite threads and threads with project metadata
|
||||
const remainingThreads = threadsToKeepIds.reduce(
|
||||
(acc, threadId) => {
|
||||
acc[threadId] = state.threads[threadId]
|
||||
return acc
|
||||
@ -208,13 +218,18 @@ export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
setCurrentThreadId: (threadId) => {
|
||||
if (threadId !== get().currentThreadId) set({ currentThreadId: threadId })
|
||||
},
|
||||
createThread: async (model, title, assistant) => {
|
||||
createThread: async (model, title, assistant, projectMetadata) => {
|
||||
const newThread: Thread = {
|
||||
id: ulid(),
|
||||
title: title ?? 'New Thread',
|
||||
model,
|
||||
updated: Date.now() / 1000,
|
||||
assistants: assistant ? [assistant] : [],
|
||||
...(projectMetadata && {
|
||||
metadata: {
|
||||
project: projectMetadata,
|
||||
},
|
||||
}),
|
||||
}
|
||||
return await getServiceHub()
|
||||
.threads()
|
||||
@ -328,4 +343,26 @@ export const useThreads = create<ThreadState>()((set, get) => ({
|
||||
}
|
||||
})
|
||||
},
|
||||
updateThread: (threadId, updates) => {
|
||||
set((state) => {
|
||||
const thread = state.threads[threadId]
|
||||
if (!thread) return state
|
||||
|
||||
const updatedThread = {
|
||||
...thread,
|
||||
...updates,
|
||||
updated: Date.now() / 1000,
|
||||
}
|
||||
|
||||
getServiceHub().threads().updateThread(updatedThread)
|
||||
|
||||
const newThreads = { ...state.threads, [threadId]: updatedThread }
|
||||
return {
|
||||
threads: newThreads,
|
||||
searchIndex: new Fzf<Thread[]>(Object.values(newThreads), {
|
||||
selector: (item: Thread) => item.title,
|
||||
}),
|
||||
}
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
@ -117,6 +117,7 @@
|
||||
"chatInput": "Frage mich etwas..."
|
||||
},
|
||||
"confirm": "Bestätige",
|
||||
"continue": "Weiter",
|
||||
"loading": "Lade...",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg",
|
||||
@ -127,6 +128,7 @@
|
||||
"createAssistant": "Assistenten anlegen",
|
||||
"enterApiKey": "API Key eingeben",
|
||||
"scrollToBottom": "Zum Ende scrollen",
|
||||
"generateAiResponse": "KI-Antwort generieren",
|
||||
"addModel": {
|
||||
"title": "Modell hinzufügen",
|
||||
"modelId": "Modell ID",
|
||||
@ -154,12 +156,12 @@
|
||||
"delete": "Löschen"
|
||||
},
|
||||
"editJson": {
|
||||
"errorParse": "Failed to parse JSON",
|
||||
"errorPaste": "Failed to paste JSON",
|
||||
"errorFormat": "Invalid JSON format",
|
||||
"titleAll": "Edit All Servers Configuration",
|
||||
"placeholder": "Enter JSON configuration...",
|
||||
"save": "Save"
|
||||
"errorParse": "JSON-Parsing fehlgeschlagen",
|
||||
"errorPaste": "JSON-Einfügen fehlgeschlagen",
|
||||
"errorFormat": "Ungültiges JSON-Format",
|
||||
"titleAll": "Alle Serverkonfigurationen bearbeiten",
|
||||
"placeholder": "JSON-Konfiguration eingeben...",
|
||||
"save": "Speichern"
|
||||
},
|
||||
"editModel": {
|
||||
"title": "Modell bearbeiten: {{modelId}}",
|
||||
@ -228,11 +230,85 @@
|
||||
"title": "Nachricht Metadaten"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projekte",
|
||||
"addProject": "Projekt hinzufügen",
|
||||
"addToProject": "Zum Projekt hinzufügen",
|
||||
"removeFromProject": "Vom Projekt entfernen",
|
||||
"createNewProject": "Neues Projekt erstellen",
|
||||
"editProject": "Projekt bearbeiten",
|
||||
"deleteProject": "Projekt löschen",
|
||||
"projectName": "Projektname",
|
||||
"enterProjectName": "Projektname eingeben...",
|
||||
"noProjectsAvailable": "Keine Projekte verfügbar",
|
||||
"noProjectsYet": "Noch keine Projekte",
|
||||
"noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.",
|
||||
"projectNotFound": "Projekt nicht gefunden",
|
||||
"projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.",
|
||||
"deleteProjectDialog": {
|
||||
"title": "Projekt löschen",
|
||||
"description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteButton": "Löschen",
|
||||
"successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht",
|
||||
"successWithoutName": "Projekt erfolgreich gelöscht",
|
||||
"error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"ariaLabel": "{{projectName}} löschen"
|
||||
},
|
||||
"addProjectDialog": {
|
||||
"createTitle": "Neues Projekt erstellen",
|
||||
"editTitle": "Projekt bearbeiten",
|
||||
"nameLabel": "Projektname",
|
||||
"namePlaceholder": "Projektname eingeben...",
|
||||
"createButton": "Erstellen",
|
||||
"updateButton": "Aktualisieren",
|
||||
"alreadyExists": "Projekt \"{{projectName}}\" existiert bereits",
|
||||
"createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt",
|
||||
"renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
|
||||
},
|
||||
"noConversationsIn": "Keine Gespräche in {{projectName}}",
|
||||
"startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten",
|
||||
"conversationsIn": "Gespräche in {{projectName}}",
|
||||
"conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.",
|
||||
"thread": "Thread",
|
||||
"threads": "Threads",
|
||||
"updated": "Aktualisiert:",
|
||||
"collapseThreads": "Threads einklappen",
|
||||
"expandThreads": "Threads ausklappen",
|
||||
"update": "Aktualisieren"
|
||||
},
|
||||
"toast": {
|
||||
"allThreadsUnfavorited": {
|
||||
"title": "Alle Threads De-Favorisieren ",
|
||||
"description": "Alle deine Threads wurden defavorisiert."
|
||||
},
|
||||
"projectCreated": {
|
||||
"title": "Projekt erstellt",
|
||||
"description": "Projekt \"{{projectName}}\" erfolgreich erstellt"
|
||||
},
|
||||
"projectRenamed": {
|
||||
"title": "Projekt umbenannt",
|
||||
"description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
|
||||
},
|
||||
"projectDeleted": {
|
||||
"title": "Projekt gelöscht",
|
||||
"description": "Projekt \"{{projectName}}\" erfolgreich gelöscht"
|
||||
},
|
||||
"projectAlreadyExists": {
|
||||
"title": "Projekt existiert bereits",
|
||||
"description": "Projekt \"{{projectName}}\" existiert bereits"
|
||||
},
|
||||
"projectDeleteFailed": {
|
||||
"title": "Löschen fehlgeschlagen",
|
||||
"description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"threadAssignedToProject": {
|
||||
"title": "Thread zugewiesen",
|
||||
"description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt"
|
||||
},
|
||||
"threadRemovedFromProject": {
|
||||
"title": "Thread entfernt",
|
||||
"description": "Thread erfolgreich von \"{{projectName}}\" entfernt"
|
||||
},
|
||||
"deleteAllThreads": {
|
||||
"title": "Alle Threads löschen",
|
||||
"description": "Alle deine Threads wurden permanent gelöscht."
|
||||
@ -280,6 +356,80 @@
|
||||
"downloadAndVerificationComplete": {
|
||||
"title": "Download abgeschlossen",
|
||||
"description": "Modell \"{{item}}\" erfolgreich heruntergeladen und verifiziert"
|
||||
},
|
||||
"projectCreated": {
|
||||
"title": "Projekt erstellt",
|
||||
"description": "Projekt \"{{projectName}}\" erfolgreich erstellt"
|
||||
},
|
||||
"projectRenamed": {
|
||||
"title": "Projekt umbenannt",
|
||||
"description": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
|
||||
},
|
||||
"projectDeleted": {
|
||||
"title": "Projekt gelöscht",
|
||||
"description": "Projekt \"{{projectName}}\" erfolgreich gelöscht"
|
||||
},
|
||||
"projectAlreadyExists": {
|
||||
"title": "Projekt existiert bereits",
|
||||
"description": "Projekt \"{{projectName}}\" existiert bereits"
|
||||
},
|
||||
"projectDeleteFailed": {
|
||||
"title": "Löschen fehlgeschlagen",
|
||||
"description": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"threadAssignedToProject": {
|
||||
"title": "Thread zugewiesen",
|
||||
"description": "Thread erfolgreich zu \"{{projectName}}\" hinzugefügt"
|
||||
},
|
||||
"threadRemovedFromProject": {
|
||||
"title": "Thread entfernt",
|
||||
"description": "Thread erfolgreich von \"{{projectName}}\" entfernt"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projekte",
|
||||
"addProject": "Projekt hinzufügen",
|
||||
"addToProject": "Zu Projekt hinzufügen",
|
||||
"removeFromProject": "Von Projekt entfernen",
|
||||
"createNewProject": "Neues Projekt erstellen",
|
||||
"editProject": "Projekt bearbeiten",
|
||||
"deleteProject": "Projekt löschen",
|
||||
"projectName": "Projektname",
|
||||
"enterProjectName": "Projektname eingeben...",
|
||||
"noProjectsAvailable": "Keine Projekte verfügbar",
|
||||
"noProjectsYet": "Noch keine Projekte",
|
||||
"noProjectsYetDesc": "Starten Sie ein neues Projekt, indem Sie auf die Schaltfläche Projekt hinzufügen klicken.",
|
||||
"projectNotFound": "Projekt nicht gefunden",
|
||||
"projectNotFoundDesc": "Das gesuchte Projekt existiert nicht oder wurde gelöscht.",
|
||||
"deleteProjectDialog": {
|
||||
"title": "Projekt löschen",
|
||||
"description": "Sind Sie sicher, dass Sie dieses Projekt löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"deleteButton": "Löschen",
|
||||
"successWithName": "Projekt \"{{projectName}}\" erfolgreich gelöscht",
|
||||
"successWithoutName": "Projekt erfolgreich gelöscht",
|
||||
"error": "Projekt konnte nicht gelöscht werden. Bitte versuchen Sie es erneut.",
|
||||
"ariaLabel": "{{projectName}} löschen"
|
||||
},
|
||||
"addProjectDialog": {
|
||||
"createTitle": "Neues Projekt erstellen",
|
||||
"editTitle": "Projekt bearbeiten",
|
||||
"nameLabel": "Projektname",
|
||||
"namePlaceholder": "Projektname eingeben...",
|
||||
"createButton": "Erstellen",
|
||||
"updateButton": "Aktualisieren",
|
||||
"alreadyExists": "Projekt \"{{projectName}}\" existiert bereits",
|
||||
"createSuccess": "Projekt \"{{projectName}}\" erfolgreich erstellt",
|
||||
"renameSuccess": "Projekt von \"{{oldName}}\" zu \"{{newName}}\" umbenannt"
|
||||
},
|
||||
"noConversationsIn": "Keine Gespräche in {{projectName}}",
|
||||
"startNewConversation": "Starten Sie ein neues Gespräch mit {{projectName}} unten",
|
||||
"conversationsIn": "Gespräche in {{projectName}}",
|
||||
"conversationsDescription": "Klicken Sie auf ein Gespräch, um weiterzuchatten, oder starten Sie unten ein neues.",
|
||||
"thread": "Thread",
|
||||
"threads": "Threads",
|
||||
"updated": "Aktualisiert:",
|
||||
"collapseThreads": "Threads einklappen",
|
||||
"expandThreads": "Threads ausklappen",
|
||||
"update": "Aktualisieren"
|
||||
}
|
||||
}
|
||||
|
||||
@ -119,6 +119,7 @@
|
||||
"chatInput": "Ask me anything..."
|
||||
},
|
||||
"confirm": "Confirm",
|
||||
"continue": "Continue",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
@ -231,6 +232,52 @@
|
||||
"title": "Message Metadata"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects",
|
||||
"addProject": "Add Project",
|
||||
"addToProject": "Add to project",
|
||||
"removeFromProject": "Remove from project",
|
||||
"createNewProject": "Create New Project",
|
||||
"editProject": "Edit Project",
|
||||
"deleteProject": "Delete Project",
|
||||
"projectName": "Project Name",
|
||||
"enterProjectName": "Enter project name...",
|
||||
"noProjectsAvailable": "No projects available",
|
||||
"noProjectsYet": "No Projects Yet",
|
||||
"noProjectsYetDesc": "Start a new project by clicking the Add Project button.",
|
||||
"projectNotFound": "Project Not Found",
|
||||
"projectNotFoundDesc": "The project you're looking for doesn't exist or has been deleted.",
|
||||
"deleteProjectDialog": {
|
||||
"title": "Delete Project",
|
||||
"description": "Are you sure you want to delete this project? This action cannot be undone.",
|
||||
"deleteButton": "Delete",
|
||||
"successWithName": "Project \"{{projectName}}\" deleted successfully",
|
||||
"successWithoutName": "Project deleted successfully",
|
||||
"error": "Failed to delete project. Please try again.",
|
||||
"ariaLabel": "Delete {{projectName}}"
|
||||
},
|
||||
"addProjectDialog": {
|
||||
"createTitle": "Create New Project",
|
||||
"editTitle": "Edit Project",
|
||||
"nameLabel": "Project Name",
|
||||
"namePlaceholder": "Enter project name...",
|
||||
"createButton": "Create",
|
||||
"updateButton": "Update",
|
||||
"alreadyExists": "Project \"{{projectName}}\" already exists",
|
||||
"createSuccess": "Project \"{{projectName}}\" created successfully",
|
||||
"renameSuccess": "Project renamed from \"{{oldName}}\" to \"{{newName}}\""
|
||||
},
|
||||
"noConversationsIn": "No Conversations in {{projectName}}",
|
||||
"startNewConversation": "Start a new conversation with {{projectName}} below",
|
||||
"conversationsIn": "Conversations in {{projectName}}",
|
||||
"conversationsDescription": "Click on any conversation to continue chatting, or start a new one below.",
|
||||
"thread": "thread",
|
||||
"threads": "threads",
|
||||
"updated": "Updated:",
|
||||
"collapseThreads": "Collapse threads",
|
||||
"expandThreads": "Expand threads",
|
||||
"update": "Update"
|
||||
},
|
||||
"toast": {
|
||||
"allThreadsUnfavorited": {
|
||||
"title": "All Threads Unfavorited",
|
||||
@ -283,6 +330,34 @@
|
||||
"downloadAndVerificationComplete": {
|
||||
"title": "Download Complete",
|
||||
"description": "Model \"{{item}}\" downloaded and verified successfully"
|
||||
},
|
||||
"projectCreated": {
|
||||
"title": "Project Created",
|
||||
"description": "Project \"{{projectName}}\" created successfully"
|
||||
},
|
||||
"projectRenamed": {
|
||||
"title": "Project Renamed",
|
||||
"description": "Project renamed from \"{{oldName}}\" to \"{{newName}}\""
|
||||
},
|
||||
"projectDeleted": {
|
||||
"title": "Project Deleted",
|
||||
"description": "Project \"{{projectName}}\" deleted successfully"
|
||||
},
|
||||
"projectAlreadyExists": {
|
||||
"title": "Project Already Exists",
|
||||
"description": "Project \"{{projectName}}\" already exists"
|
||||
},
|
||||
"projectDeleteFailed": {
|
||||
"title": "Delete Failed",
|
||||
"description": "Failed to delete project. Please try again."
|
||||
},
|
||||
"threadAssignedToProject": {
|
||||
"title": "Thread Assigned",
|
||||
"description": "Thread assigned to \"{{projectName}}\" successfully"
|
||||
},
|
||||
"threadRemovedFromProject": {
|
||||
"title": "Thread Removed",
|
||||
"description": "Thread removed from \"{{projectName}}\" successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,6 +117,7 @@
|
||||
"chatInput": "Tanyakan apa saja padaku..."
|
||||
},
|
||||
"confirm": "Konfirmasi",
|
||||
"continue": "Lanjutkan",
|
||||
"loading": "Memuat...",
|
||||
"error": "Kesalahan",
|
||||
"success": "Sukses",
|
||||
@ -127,6 +128,7 @@
|
||||
"createAssistant": "Buat Asisten",
|
||||
"enterApiKey": "Masukkan Kunci API",
|
||||
"scrollToBottom": "Gulir ke bawah",
|
||||
"generateAiResponse": "Hasilkan Respons AI",
|
||||
"addModel": {
|
||||
"title": "Tambah Model",
|
||||
"modelId": "ID Model",
|
||||
@ -170,6 +172,13 @@
|
||||
"embeddings": "Embedding",
|
||||
"notAvailable": "Belum tersedia"
|
||||
},
|
||||
"outOfContextError": {
|
||||
"truncateInput": "Potong Input",
|
||||
"title": "Kesalahan konteks habis",
|
||||
"description": "Obrolan ini mencapai batas memori AI, seperti papan tulis yang penuh. Kami dapat memperluas jendela memori (disebut ukuran konteks) agar lebih mengingat, tetapi mungkin akan menggunakan lebih banyak memori komputer Anda. Kami juga dapat memotong input, yang berarti akan melupakan sebagian riwayat obrolan untuk memberi ruang bagi pesan baru.",
|
||||
"increaseContextSizeDescription": "Apakah Anda ingin meningkatkan ukuran konteks?",
|
||||
"increaseContextSize": "Tingkatkan Ukuran Konteks"
|
||||
},
|
||||
"toolApproval": {
|
||||
"title": "Permintaan Izin Alat",
|
||||
"description": "Asisten ingin menggunakan <strong>{{toolName}}</strong>",
|
||||
@ -273,6 +282,80 @@
|
||||
"downloadAndVerificationComplete": {
|
||||
"title": "Unduhan Selesai",
|
||||
"description": "Model \"{{item}}\" berhasil diunduh dan diverifikasi"
|
||||
},
|
||||
"projectCreated": {
|
||||
"title": "Proyek Dibuat",
|
||||
"description": "Proyek \"{{projectName}}\" berhasil dibuat"
|
||||
},
|
||||
"projectRenamed": {
|
||||
"title": "Proyek Diganti Nama",
|
||||
"description": "Proyek diganti nama dari \"{{oldName}}\" ke \"{{newName}}\""
|
||||
},
|
||||
"projectDeleted": {
|
||||
"title": "Proyek Dihapus",
|
||||
"description": "Proyek \"{{projectName}}\" berhasil dihapus"
|
||||
},
|
||||
"projectAlreadyExists": {
|
||||
"title": "Proyek Sudah Ada",
|
||||
"description": "Proyek \"{{projectName}}\" sudah ada"
|
||||
},
|
||||
"projectDeleteFailed": {
|
||||
"title": "Penghapusan Gagal",
|
||||
"description": "Gagal menghapus proyek. Silakan coba lagi."
|
||||
},
|
||||
"threadAssignedToProject": {
|
||||
"title": "Thread Ditugaskan",
|
||||
"description": "Thread berhasil ditugaskan ke \"{{projectName}}\""
|
||||
},
|
||||
"threadRemovedFromProject": {
|
||||
"title": "Thread Dihapus",
|
||||
"description": "Thread berhasil dihapus dari \"{{projectName}}\""
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Proyek",
|
||||
"addProject": "Tambah Proyek",
|
||||
"addToProject": "Tambahkan ke proyek",
|
||||
"removeFromProject": "Hapus dari proyek",
|
||||
"createNewProject": "Buat Proyek Baru",
|
||||
"editProject": "Edit Proyek",
|
||||
"deleteProject": "Hapus Proyek",
|
||||
"projectName": "Nama Proyek",
|
||||
"enterProjectName": "Masukkan nama proyek...",
|
||||
"noProjectsAvailable": "Tidak ada proyek tersedia",
|
||||
"noProjectsYet": "Belum Ada Proyek",
|
||||
"noProjectsYetDesc": "Mulai proyek baru dengan mengklik tombol Tambah Proyek.",
|
||||
"projectNotFound": "Proyek Tidak Ditemukan",
|
||||
"projectNotFoundDesc": "Proyek yang Anda cari tidak ada atau telah dihapus.",
|
||||
"deleteProjectDialog": {
|
||||
"title": "Hapus Proyek",
|
||||
"description": "Apakah Anda yakin ingin menghapus proyek ini? Tindakan ini tidak dapat dibatalkan.",
|
||||
"deleteButton": "Hapus",
|
||||
"successWithName": "Proyek \"{{projectName}}\" berhasil dihapus",
|
||||
"successWithoutName": "Proyek berhasil dihapus",
|
||||
"error": "Gagal menghapus proyek. Silakan coba lagi.",
|
||||
"ariaLabel": "Hapus {{projectName}}"
|
||||
},
|
||||
"addProjectDialog": {
|
||||
"createTitle": "Buat Proyek Baru",
|
||||
"editTitle": "Edit Proyek",
|
||||
"nameLabel": "Nama Proyek",
|
||||
"namePlaceholder": "Masukkan nama proyek...",
|
||||
"createButton": "Buat",
|
||||
"updateButton": "Perbarui",
|
||||
"alreadyExists": "Proyek \"{{projectName}}\" sudah ada",
|
||||
"createSuccess": "Proyek \"{{projectName}}\" berhasil dibuat",
|
||||
"renameSuccess": "Proyek diubah dari \"{{oldName}}\" menjadi \"{{newName}}\""
|
||||
},
|
||||
"noConversationsIn": "Tidak Ada Percakapan di {{projectName}}",
|
||||
"startNewConversation": "Mulai percakapan baru dengan {{projectName}} di bawah",
|
||||
"conversationsIn": "Percakapan di {{projectName}}",
|
||||
"conversationsDescription": "Klik percakapan mana pun untuk melanjutkan chatting, atau mulai yang baru di bawah.",
|
||||
"thread": "utas",
|
||||
"threads": "utas",
|
||||
"updated": "Diperbarui:",
|
||||
"collapseThreads": "Tutup utas",
|
||||
"expandThreads": "Buka utas",
|
||||
"update": "Perbarui"
|
||||
}
|
||||
}
|
||||
|
||||
@ -117,6 +117,7 @@
|
||||
"chatInput": "Zapytaj mnie o cokolwiek…"
|
||||
},
|
||||
"confirm": "Potwierdź",
|
||||
"continue": "Kontynuuj",
|
||||
"loading": "Wczytywanie…",
|
||||
"error": "Błąd",
|
||||
"success": "Sukces",
|
||||
@ -229,6 +230,52 @@
|
||||
"title": "Metadane Wiadomości"
|
||||
}
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projekty",
|
||||
"addProject": "Dodaj Projekt",
|
||||
"addToProject": "Dodaj do projektu",
|
||||
"removeFromProject": "Usuń z projektu",
|
||||
"createNewProject": "Utwórz Nowy Projekt",
|
||||
"editProject": "Edytuj Projekt",
|
||||
"deleteProject": "Usuń Projekt",
|
||||
"projectName": "Nazwa Projektu",
|
||||
"enterProjectName": "Wprowadź nazwę projektu...",
|
||||
"noProjectsAvailable": "Brak dostępnych projektów",
|
||||
"noProjectsYet": "Brak Projektów",
|
||||
"noProjectsYetDesc": "Rozpocznij nowy projekt klikając przycisk Dodaj Projekt.",
|
||||
"projectNotFound": "Projekt Nie Znaleziony",
|
||||
"projectNotFoundDesc": "Projekt, którego szukasz nie istnieje lub został usunięty.",
|
||||
"deleteProjectDialog": {
|
||||
"title": "Usuń Projekt",
|
||||
"description": "Na pewno chcesz usunąć ten projekt? Tej operacji nie można cofnąć.",
|
||||
"deleteButton": "Usuń",
|
||||
"successWithName": "Projekt \"{{projectName}}\" został pomyślnie usunięty",
|
||||
"successWithoutName": "Projekt został pomyślnie usunięty",
|
||||
"error": "Nie udało się usunąć projektu. Spróbuj ponownie.",
|
||||
"ariaLabel": "Usuń {{projectName}}"
|
||||
},
|
||||
"addProjectDialog": {
|
||||
"createTitle": "Utwórz Nowy Projekt",
|
||||
"editTitle": "Edytuj Projekt",
|
||||
"nameLabel": "Nazwa Projektu",
|
||||
"namePlaceholder": "Wprowadź nazwę projektu...",
|
||||
"createButton": "Utwórz",
|
||||
"updateButton": "Aktualizuj",
|
||||
"alreadyExists": "Projekt \"{{projectName}}\" już istnieje",
|
||||
"createSuccess": "Projekt \"{{projectName}}\" został pomyślnie utworzony",
|
||||
"renameSuccess": "Projekt zmieniono z \"{{oldName}}\" na \"{{newName}}\""
|
||||
},
|
||||
"noConversationsIn": "Brak Rozmów w {{projectName}}",
|
||||
"startNewConversation": "Rozpocznij nową rozmowę z {{projectName}} poniżej",
|
||||
"conversationsIn": "Rozmowy w {{projectName}}",
|
||||
"conversationsDescription": "Kliknij na dowolną rozmowę aby kontynuować czat, lub rozpocznij nową poniżej.",
|
||||
"thread": "wątek",
|
||||
"threads": "wątki",
|
||||
"updated": "Zaktualizowano:",
|
||||
"collapseThreads": "Zwiń wątki",
|
||||
"expandThreads": "Rozwiń wątki",
|
||||
"update": "Aktualizuj"
|
||||
},
|
||||
"toast": {
|
||||
"allThreadsUnfavorited": {
|
||||
"title": "Wszystkie Wątki Usunięte z Ulubionych",
|
||||
|
||||
@ -117,6 +117,7 @@
|
||||
"chatInput": "Hỏi tôi bất cứ điều gì..."
|
||||
},
|
||||
"confirm": "Xác nhận",
|
||||
"continue": "Tiếp tục",
|
||||
"loading": "Đang tải...",
|
||||
"error": "Lỗi",
|
||||
"success": "Thành công",
|
||||
|
||||
@ -117,6 +117,7 @@
|
||||
"chatInput": "随便问我什么..."
|
||||
},
|
||||
"confirm": "确认",
|
||||
"continue": "继续",
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
"success": "成功",
|
||||
|
||||
@ -117,6 +117,7 @@
|
||||
"chatInput": "問我任何事..."
|
||||
},
|
||||
"confirm": "確認",
|
||||
"continue": "繼續",
|
||||
"loading": "載入中...",
|
||||
"error": "錯誤",
|
||||
"success": "成功",
|
||||
|
||||
@ -15,6 +15,7 @@ import { Route as SystemMonitorImport } from './routes/system-monitor'
|
||||
import { Route as LogsImport } from './routes/logs'
|
||||
import { Route as AssistantImport } from './routes/assistant'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as ProjectIndexImport } from './routes/project/index'
|
||||
import { Route as HubIndexImport } from './routes/hub/index'
|
||||
import { Route as ThreadsThreadIdImport } from './routes/threads/$threadId'
|
||||
import { Route as SettingsShortcutsImport } from './routes/settings/shortcuts'
|
||||
@ -26,6 +27,7 @@ import { Route as SettingsHardwareImport } from './routes/settings/hardware'
|
||||
import { Route as SettingsGeneralImport } from './routes/settings/general'
|
||||
import { Route as SettingsExtensionsImport } from './routes/settings/extensions'
|
||||
import { Route as SettingsAppearanceImport } from './routes/settings/appearance'
|
||||
import { Route as ProjectProjectIdImport } from './routes/project/$projectId'
|
||||
import { Route as LocalApiServerLogsImport } from './routes/local-api-server/logs'
|
||||
import { Route as HubModelIdImport } from './routes/hub/$modelId'
|
||||
import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index'
|
||||
@ -58,6 +60,12 @@ const IndexRoute = IndexImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ProjectIndexRoute = ProjectIndexImport.update({
|
||||
id: '/project/',
|
||||
path: '/project/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const HubIndexRoute = HubIndexImport.update({
|
||||
id: '/hub/',
|
||||
path: '/hub/',
|
||||
@ -124,6 +132,12 @@ const SettingsAppearanceRoute = SettingsAppearanceImport.update({
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const ProjectProjectIdRoute = ProjectProjectIdImport.update({
|
||||
id: '/project/$projectId',
|
||||
path: '/project/$projectId',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({
|
||||
id: '/local-api-server/logs',
|
||||
path: '/local-api-server/logs',
|
||||
@ -201,6 +215,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LocalApiServerLogsImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/project/$projectId': {
|
||||
id: '/project/$projectId'
|
||||
path: '/project/$projectId'
|
||||
fullPath: '/project/$projectId'
|
||||
preLoaderRoute: typeof ProjectProjectIdImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/settings/appearance': {
|
||||
id: '/settings/appearance'
|
||||
path: '/settings/appearance'
|
||||
@ -278,6 +299,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof HubIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/project/': {
|
||||
id: '/project/'
|
||||
path: '/project'
|
||||
fullPath: '/project'
|
||||
preLoaderRoute: typeof ProjectIndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/auth/google/callback': {
|
||||
id: '/auth/google/callback'
|
||||
path: '/auth/google/callback'
|
||||
@ -311,6 +339,7 @@ export interface FileRoutesByFullPath {
|
||||
'/system-monitor': typeof SystemMonitorRoute
|
||||
'/hub/$modelId': typeof HubModelIdRoute
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
'/settings/appearance': typeof SettingsAppearanceRoute
|
||||
'/settings/extensions': typeof SettingsExtensionsRoute
|
||||
'/settings/general': typeof SettingsGeneralRoute
|
||||
@ -322,6 +351,7 @@ export interface FileRoutesByFullPath {
|
||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/hub': typeof HubIndexRoute
|
||||
'/project': typeof ProjectIndexRoute
|
||||
'/auth/google/callback': typeof AuthGoogleCallbackRoute
|
||||
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
||||
'/settings/providers': typeof SettingsProvidersIndexRoute
|
||||
@ -334,6 +364,7 @@ export interface FileRoutesByTo {
|
||||
'/system-monitor': typeof SystemMonitorRoute
|
||||
'/hub/$modelId': typeof HubModelIdRoute
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
'/settings/appearance': typeof SettingsAppearanceRoute
|
||||
'/settings/extensions': typeof SettingsExtensionsRoute
|
||||
'/settings/general': typeof SettingsGeneralRoute
|
||||
@ -345,19 +376,21 @@ export interface FileRoutesByTo {
|
||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/hub': typeof HubIndexRoute
|
||||
'/project': typeof ProjectIndexRoute
|
||||
'/auth/google/callback': typeof AuthGoogleCallbackRoute
|
||||
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
||||
'/settings/providers': typeof SettingsProvidersIndexRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'__root__': typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/assistant': typeof AssistantRoute
|
||||
'/logs': typeof LogsRoute
|
||||
'/system-monitor': typeof SystemMonitorRoute
|
||||
'/hub/$modelId': typeof HubModelIdRoute
|
||||
'/local-api-server/logs': typeof LocalApiServerLogsRoute
|
||||
'/project/$projectId': typeof ProjectProjectIdRoute
|
||||
'/settings/appearance': typeof SettingsAppearanceRoute
|
||||
'/settings/extensions': typeof SettingsExtensionsRoute
|
||||
'/settings/general': typeof SettingsGeneralRoute
|
||||
@ -369,6 +402,7 @@ export interface FileRoutesById {
|
||||
'/settings/shortcuts': typeof SettingsShortcutsRoute
|
||||
'/threads/$threadId': typeof ThreadsThreadIdRoute
|
||||
'/hub/': typeof HubIndexRoute
|
||||
'/project/': typeof ProjectIndexRoute
|
||||
'/auth/google/callback': typeof AuthGoogleCallbackRoute
|
||||
'/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute
|
||||
'/settings/providers/': typeof SettingsProvidersIndexRoute
|
||||
@ -383,6 +417,7 @@ export interface FileRouteTypes {
|
||||
| '/system-monitor'
|
||||
| '/hub/$modelId'
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/extensions'
|
||||
| '/settings/general'
|
||||
@ -394,6 +429,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/shortcuts'
|
||||
| '/threads/$threadId'
|
||||
| '/hub'
|
||||
| '/project'
|
||||
| '/auth/google/callback'
|
||||
| '/settings/providers/$providerName'
|
||||
| '/settings/providers'
|
||||
@ -405,6 +441,7 @@ export interface FileRouteTypes {
|
||||
| '/system-monitor'
|
||||
| '/hub/$modelId'
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/extensions'
|
||||
| '/settings/general'
|
||||
@ -416,6 +453,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/shortcuts'
|
||||
| '/threads/$threadId'
|
||||
| '/hub'
|
||||
| '/project'
|
||||
| '/auth/google/callback'
|
||||
| '/settings/providers/$providerName'
|
||||
| '/settings/providers'
|
||||
@ -427,6 +465,7 @@ export interface FileRouteTypes {
|
||||
| '/system-monitor'
|
||||
| '/hub/$modelId'
|
||||
| '/local-api-server/logs'
|
||||
| '/project/$projectId'
|
||||
| '/settings/appearance'
|
||||
| '/settings/extensions'
|
||||
| '/settings/general'
|
||||
@ -438,6 +477,7 @@ export interface FileRouteTypes {
|
||||
| '/settings/shortcuts'
|
||||
| '/threads/$threadId'
|
||||
| '/hub/'
|
||||
| '/project/'
|
||||
| '/auth/google/callback'
|
||||
| '/settings/providers/$providerName'
|
||||
| '/settings/providers/'
|
||||
@ -451,6 +491,7 @@ export interface RootRouteChildren {
|
||||
SystemMonitorRoute: typeof SystemMonitorRoute
|
||||
HubModelIdRoute: typeof HubModelIdRoute
|
||||
LocalApiServerLogsRoute: typeof LocalApiServerLogsRoute
|
||||
ProjectProjectIdRoute: typeof ProjectProjectIdRoute
|
||||
SettingsAppearanceRoute: typeof SettingsAppearanceRoute
|
||||
SettingsExtensionsRoute: typeof SettingsExtensionsRoute
|
||||
SettingsGeneralRoute: typeof SettingsGeneralRoute
|
||||
@ -462,6 +503,7 @@ export interface RootRouteChildren {
|
||||
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
|
||||
ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute
|
||||
HubIndexRoute: typeof HubIndexRoute
|
||||
ProjectIndexRoute: typeof ProjectIndexRoute
|
||||
AuthGoogleCallbackRoute: typeof AuthGoogleCallbackRoute
|
||||
SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute
|
||||
SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute
|
||||
@ -474,6 +516,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
SystemMonitorRoute: SystemMonitorRoute,
|
||||
HubModelIdRoute: HubModelIdRoute,
|
||||
LocalApiServerLogsRoute: LocalApiServerLogsRoute,
|
||||
ProjectProjectIdRoute: ProjectProjectIdRoute,
|
||||
SettingsAppearanceRoute: SettingsAppearanceRoute,
|
||||
SettingsExtensionsRoute: SettingsExtensionsRoute,
|
||||
SettingsGeneralRoute: SettingsGeneralRoute,
|
||||
@ -485,6 +528,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
SettingsShortcutsRoute: SettingsShortcutsRoute,
|
||||
ThreadsThreadIdRoute: ThreadsThreadIdRoute,
|
||||
HubIndexRoute: HubIndexRoute,
|
||||
ProjectIndexRoute: ProjectIndexRoute,
|
||||
AuthGoogleCallbackRoute: AuthGoogleCallbackRoute,
|
||||
SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute,
|
||||
SettingsProvidersIndexRoute: SettingsProvidersIndexRoute,
|
||||
@ -506,6 +550,7 @@ export const routeTree = rootRoute
|
||||
"/system-monitor",
|
||||
"/hub/$modelId",
|
||||
"/local-api-server/logs",
|
||||
"/project/$projectId",
|
||||
"/settings/appearance",
|
||||
"/settings/extensions",
|
||||
"/settings/general",
|
||||
@ -517,6 +562,7 @@ export const routeTree = rootRoute
|
||||
"/settings/shortcuts",
|
||||
"/threads/$threadId",
|
||||
"/hub/",
|
||||
"/project/",
|
||||
"/auth/google/callback",
|
||||
"/settings/providers/$providerName",
|
||||
"/settings/providers/"
|
||||
@ -540,6 +586,9 @@ export const routeTree = rootRoute
|
||||
"/local-api-server/logs": {
|
||||
"filePath": "local-api-server/logs.tsx"
|
||||
},
|
||||
"/project/$projectId": {
|
||||
"filePath": "project/$projectId.tsx"
|
||||
},
|
||||
"/settings/appearance": {
|
||||
"filePath": "settings/appearance.tsx"
|
||||
},
|
||||
@ -573,6 +622,9 @@ export const routeTree = rootRoute
|
||||
"/hub/": {
|
||||
"filePath": "hub/index.tsx"
|
||||
},
|
||||
"/project/": {
|
||||
"filePath": "project/index.tsx"
|
||||
},
|
||||
"/auth/google/callback": {
|
||||
"filePath": "auth.google.callback.tsx"
|
||||
},
|
||||
|
||||
@ -12,6 +12,7 @@ import { AvatarEmoji } from '@/containers/AvatarEmoji'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
import { PlatformGuard } from '@/lib/platform/PlatformGuard'
|
||||
import { PlatformFeature } from '@/lib/platform/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const Route = createFileRoute(route.assistant as any)({
|
||||
@ -61,72 +62,71 @@ function AssistantContent() {
|
||||
return (
|
||||
<div className="flex h-full flex-col flex-justify-center">
|
||||
<HeaderPage>
|
||||
<span>{t('assistants:title')}</span>
|
||||
<div className="flex items-center justify-between w-full mr-2">
|
||||
<span>{t('assistants:title')}</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingKey(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
size="sm"
|
||||
className="relative z-50"
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
Add Assistant
|
||||
</Button>
|
||||
</div>
|
||||
</HeaderPage>
|
||||
<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="h-full p-4 w-3/4 mx-auto overflow-y-auto mt-2">
|
||||
<div className="space-y-3">
|
||||
{assistants
|
||||
.slice()
|
||||
.sort((a, b) => a.created_at - b.created_at)
|
||||
.map((assistant) => (
|
||||
<div
|
||||
className="bg-main-view-fg/3 p-3 rounded-md"
|
||||
className="bg-main-view-fg/3 py-2 px-4 rounded-lg flex items-center gap-4"
|
||||
key={assistant.id}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-base font-medium text-main-view-fg/80">
|
||||
<div className="flex items-center gap-1">
|
||||
{assistant?.avatar && (
|
||||
<span className="shrink-0 w-4 h-4 relative flex items-center justify-center">
|
||||
<AvatarEmoji
|
||||
avatar={assistant?.avatar}
|
||||
imageClassName="object-cover"
|
||||
textClassName="text-sm"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className="line-clamp-1">{assistant.name}</span>
|
||||
</div>
|
||||
</h3>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<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"
|
||||
title={t('assistants:editAssistant')}
|
||||
onClick={() => {
|
||||
setEditingKey(assistant.id)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil size={18} className="text-main-view-fg/50" />
|
||||
</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"
|
||||
title={t('assistants:deleteAssistant')}
|
||||
onClick={() => handleDelete(assistant.id)}
|
||||
>
|
||||
<IconTrash size={18} className="text-main-view-fg/50" />
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{assistant?.avatar && (
|
||||
<div className="shrink-0 w-8 h-8 relative flex items-center justify-center bg-main-view-fg/4 rounded-md">
|
||||
<AvatarEmoji
|
||||
avatar={assistant?.avatar}
|
||||
imageClassName="w-5 h-5 object-contain"
|
||||
textClassName="text-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-medium text-main-view-fg/80 line-clamp-1">
|
||||
{assistant.name}
|
||||
</h3>
|
||||
<p className="text-main-view-fg/50 text-sm line-clamp-2 mt-0.5">
|
||||
{assistant.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-main-view-fg/50 mt-1 line-clamp-2"
|
||||
title={assistant.description}
|
||||
>
|
||||
{assistant.description}
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||
title={t('assistants:editAssistant')}
|
||||
onClick={() => {
|
||||
setEditingKey(assistant.id)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil size={16} className="text-main-view-fg/50" />
|
||||
</button>
|
||||
<button
|
||||
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||
title={t('assistants:deleteAssistant')}
|
||||
onClick={() => handleDelete(assistant.id)}
|
||||
>
|
||||
<IconTrash size={16} className="text-main-view-fg/50" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="bg-main-view p-3 min-h-[88px] rounded-md border border-dashed border-main-view-fg/10 flex items-center justify-center cursor-pointer hover:bg-main-view-fg/1 transition-all duration-200 ease-in-out"
|
||||
key="new-assistant"
|
||||
onClick={() => {
|
||||
setEditingKey(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconCirclePlus className="text-main-view-fg/50" />
|
||||
</div>
|
||||
</div>
|
||||
<AddEditAssistant
|
||||
open={open}
|
||||
|
||||
143
web-app/src/routes/project/$projectId.tsx
Normal file
143
web-app/src/routes/project/$projectId.tsx
Normal file
@ -0,0 +1,143 @@
|
||||
import { createFileRoute, useParams } from '@tanstack/react-router'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
import { useModelProvider } from '@/hooks/useModelProvider'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
|
||||
import ChatInput from '@/containers/ChatInput'
|
||||
import HeaderPage from '@/containers/HeaderPage'
|
||||
import SetupScreen from '@/containers/SetupScreen'
|
||||
import ThreadList from '@/containers/ThreadList'
|
||||
import DropdownAssistant from '@/containers/DropdownAssistant'
|
||||
|
||||
import { PlatformFeatures } from '@/lib/platform/const'
|
||||
import { PlatformFeature } from '@/lib/platform/types'
|
||||
import { IconMessage } from '@tabler/icons-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAppearance } from '@/hooks/useAppearance'
|
||||
import { useSmallScreen } from '@/hooks/useMediaQuery'
|
||||
|
||||
export const Route = createFileRoute('/project/$projectId')({
|
||||
component: ProjectPage,
|
||||
})
|
||||
|
||||
function ProjectPage() {
|
||||
const { t } = useTranslation()
|
||||
const { projectId } = useParams({ from: '/project/$projectId' })
|
||||
const { providers } = useModelProvider()
|
||||
const { getFolderById } = useThreadManagement()
|
||||
const threads = useThreads((state) => state.threads)
|
||||
|
||||
const chatWidth = useAppearance((state) => state.chatWidth)
|
||||
const isSmallScreen = useSmallScreen()
|
||||
|
||||
// Find the project
|
||||
const project = getFolderById(projectId)
|
||||
|
||||
// Get threads for this project
|
||||
const projectThreads = useMemo(() => {
|
||||
return Object.values(threads)
|
||||
.filter((thread) => thread.metadata?.project?.id === projectId)
|
||||
.sort((a, b) => (b.updated || 0) - (a.updated || 0))
|
||||
}, [threads, projectId])
|
||||
|
||||
// Conditional to check if there are any valid providers
|
||||
const hasValidProviders = providers.some(
|
||||
(provider) =>
|
||||
provider.api_key?.length ||
|
||||
(provider.provider === 'llamacpp' && provider.models.length) ||
|
||||
(provider.provider === 'jan' && provider.models.length)
|
||||
)
|
||||
|
||||
if (!hasValidProviders) {
|
||||
return <SetupScreen />
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-main-view-fg mb-2">
|
||||
{t('projects.projectNotFound')}
|
||||
</h1>
|
||||
<p className="text-main-view-fg/70">
|
||||
{t('projects.projectNotFoundDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<HeaderPage>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{PlatformFeatures[PlatformFeature.ASSISTANTS] && (
|
||||
<DropdownAssistant />
|
||||
)}
|
||||
</div>
|
||||
</HeaderPage>
|
||||
|
||||
<div className="h-full relative flex flex-col justify-between px-4 md:px-8 py-4 overflow-y-auto">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto flex h-full flex-col justify-between',
|
||||
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
|
||||
isSmallScreen && 'w-full'
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="mb-6 mt-2">
|
||||
{projectThreads.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold text-main-view-fg mb-2">
|
||||
{t('projects.conversationsIn', { projectName: project.name })}
|
||||
</h2>
|
||||
<p className="text-main-view-fg/70">
|
||||
{t('projects.conversationsDescription')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Thread List or Empty State */}
|
||||
<div className="mb-0">
|
||||
{projectThreads.length > 0 ? (
|
||||
<ThreadList threads={projectThreads} variant="project" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<IconMessage
|
||||
size={48}
|
||||
className="text-main-view-fg/30 mb-4"
|
||||
/>
|
||||
<h3 className="text-lg font-medium text-main-view-fg/60 mb-2">
|
||||
{t('projects.noConversationsIn', { projectName: project.name })}
|
||||
</h3>
|
||||
<p className="text-main-view-fg/50 text-sm">
|
||||
{t('projects.startNewConversation', { projectName: project.name })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* New Chat Input */}
|
||||
<div
|
||||
className={cn(
|
||||
'mx-auto pt-2 pb-3 shrink-0 relative px-2',
|
||||
chatWidth === 'compact' ? 'w-full md:w-4/6' : 'w-full',
|
||||
isSmallScreen && 'w-full'
|
||||
)}
|
||||
>
|
||||
<ChatInput
|
||||
showSpeedToken={false}
|
||||
initialMessage={true}
|
||||
projectId={projectId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
244
web-app/src/routes/project/index.tsx
Normal file
244
web-app/src/routes/project/index.tsx
Normal file
@ -0,0 +1,244 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useState, useMemo } from 'react'
|
||||
|
||||
import { useThreadManagement } from '@/hooks/useThreadManagement'
|
||||
import { useThreads } from '@/hooks/useThreads'
|
||||
import { useTranslation } from '@/i18n/react-i18next-compat'
|
||||
|
||||
import HeaderPage from '@/containers/HeaderPage'
|
||||
import ThreadList from '@/containers/ThreadList'
|
||||
import {
|
||||
IconCirclePlus,
|
||||
IconPencil,
|
||||
IconTrash,
|
||||
IconFolder,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
} from '@tabler/icons-react'
|
||||
import AddProjectDialog from '@/containers/dialogs/AddProjectDialog'
|
||||
import { DeleteProjectDialog } from '@/containers/dialogs/DeleteProjectDialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { formatDate } from '@/utils/formatDate'
|
||||
|
||||
export const Route = createFileRoute('/project/')({
|
||||
component: Project,
|
||||
})
|
||||
|
||||
function Project() {
|
||||
return <ProjectContent />
|
||||
}
|
||||
|
||||
function ProjectContent() {
|
||||
const { t } = useTranslation()
|
||||
const { folders, addFolder, updateFolder, deleteFolder, getFolderById } =
|
||||
useThreadManagement()
|
||||
const threads = useThreads((state) => state.threads)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [expandedProjects, setExpandedProjects] = useState<Set<string>>(
|
||||
new Set()
|
||||
)
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDeletingId(id)
|
||||
setDeleteConfirmOpen(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deletingId) {
|
||||
deleteFolder(deletingId)
|
||||
setDeleteConfirmOpen(false)
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = (name: string) => {
|
||||
if (editingKey) {
|
||||
updateFolder(editingKey, name)
|
||||
} else {
|
||||
addFolder(name)
|
||||
}
|
||||
setOpen(false)
|
||||
setEditingKey(null)
|
||||
}
|
||||
|
||||
const formatProjectDate = (timestamp: number) => {
|
||||
return formatDate(new Date(timestamp), { includeTime: false })
|
||||
}
|
||||
|
||||
// Get threads for a specific project
|
||||
const getThreadsForProject = useMemo(() => {
|
||||
return (projectId: string) => {
|
||||
return Object.values(threads)
|
||||
.filter((thread) => thread.metadata?.project?.id === projectId)
|
||||
.sort((a, b) => (b.updated || 0) - (a.updated || 0))
|
||||
}
|
||||
}, [threads])
|
||||
|
||||
const toggleProjectExpansion = (projectId: string) => {
|
||||
setExpandedProjects((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(projectId)) {
|
||||
newSet.delete(projectId)
|
||||
} else {
|
||||
newSet.add(projectId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col flex-justify-center">
|
||||
<HeaderPage>
|
||||
<div className="flex items-center justify-between w-full mr-2">
|
||||
<span>{t('projects.title')}</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEditingKey(null)
|
||||
setOpen(true)
|
||||
}}
|
||||
size="sm"
|
||||
className="relative z-50"
|
||||
>
|
||||
<IconCirclePlus size={16} />
|
||||
{t('projects.addProject')}
|
||||
</Button>
|
||||
</div>
|
||||
</HeaderPage>
|
||||
<div className="h-full overflow-y-auto flex flex-col">
|
||||
<div className="p-4 w-full md:w-3/4 mx-auto mt-2">
|
||||
{folders.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<IconFolder size={48} className="text-main-view-fg/30 mb-4" />
|
||||
<h3 className="text-lg font-medium text-main-view-fg/60 mb-2">
|
||||
{t('projects.noProjectsYet')}
|
||||
</h3>
|
||||
<p className="text-main-view-fg/50 text-sm">
|
||||
{t('projects.noProjectsYetDesc')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{folders
|
||||
.slice()
|
||||
.sort((a, b) => a.updated_at - b.updated_at)
|
||||
.map((folder) => {
|
||||
const projectThreads = getThreadsForProject(folder.id)
|
||||
const isExpanded = expandedProjects.has(folder.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-main-view-fg/3 py-2 px-4 rounded-lg"
|
||||
key={folder.id}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="shrink-0 w-8 h-8 relative flex items-center justify-center bg-main-view-fg/4 rounded-md">
|
||||
<IconFolder
|
||||
size={16}
|
||||
className="text-main-view-fg/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-medium text-main-view-fg/80 line-clamp-1">
|
||||
{folder.name}
|
||||
</h3>
|
||||
<span className="text-xs bg-main-view-fg/10 text-main-view-fg/60 px-2 py-0.5 rounded-full">
|
||||
{projectThreads.length}{' '}
|
||||
{projectThreads.length === 1
|
||||
? t('projects.thread')
|
||||
: t('projects.threads')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-main-view-fg/50 text-xs line-clamp-2 mt-0.5">
|
||||
{t('projects.updated')}{' '}
|
||||
{formatProjectDate(folder.updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{projectThreads.length > 0 && (
|
||||
<button
|
||||
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out mr-1"
|
||||
title={
|
||||
isExpanded
|
||||
? t('projects.collapseThreads')
|
||||
: t('projects.expandThreads')
|
||||
}
|
||||
onClick={() => toggleProjectExpansion(folder.id)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<IconChevronDown
|
||||
size={16}
|
||||
className="text-main-view-fg/50"
|
||||
/>
|
||||
) : (
|
||||
<IconChevronRight
|
||||
size={16}
|
||||
className="text-main-view-fg/50"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||
title={t('projects.editProject')}
|
||||
onClick={() => {
|
||||
setEditingKey(folder.id)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<IconPencil
|
||||
size={16}
|
||||
className="text-main-view-fg/50"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
className="size-8 cursor-pointer flex items-center justify-center rounded-md hover:bg-main-view-fg/10 transition-all duration-200 ease-in-out"
|
||||
title={t('projects.deleteProject')}
|
||||
onClick={() => handleDelete(folder.id)}
|
||||
>
|
||||
<IconTrash
|
||||
size={16}
|
||||
className="text-main-view-fg/50"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Thread List */}
|
||||
{isExpanded && projectThreads.length > 0 && (
|
||||
<div className="mt-3 pl-2">
|
||||
<ThreadList
|
||||
threads={projectThreads}
|
||||
variant="project"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AddProjectDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
editingKey={editingKey}
|
||||
initialData={editingKey ? getFolderById(editingKey) : undefined}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
<DeleteProjectDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
onConfirm={confirmDelete}
|
||||
projectName={deletingId ? getFolderById(deletingId)?.name : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -30,6 +30,12 @@ export class DefaultThreadsService implements ThreadsService {
|
||||
provider: e.assistants?.[0]?.model?.engine,
|
||||
},
|
||||
assistants: e.assistants ?? [defaultAssistant],
|
||||
metadata: {
|
||||
...e.metadata,
|
||||
// Override extracted fields to avoid duplication
|
||||
order: e.metadata?.order,
|
||||
is_favorite: e.metadata?.is_favorite,
|
||||
},
|
||||
} as Thread
|
||||
})
|
||||
})
|
||||
@ -101,6 +107,7 @@ export class DefaultThreadsService implements ThreadsService {
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
...thread.metadata,
|
||||
is_favorite: thread.isFavorite,
|
||||
order: thread.order,
|
||||
},
|
||||
@ -115,4 +122,4 @@ export class DefaultThreadsService implements ThreadsService {
|
||||
.get<ConversationalExtension>(ExtensionTypeEnum.Conversational)
|
||||
?.deleteThread(threadId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
web-app/src/types/threads.d.ts
vendored
10
web-app/src/types/threads.d.ts
vendored
@ -44,6 +44,14 @@ type Thread = {
|
||||
model?: ThreadModel
|
||||
updated: number
|
||||
order?: number
|
||||
metadata?: {
|
||||
project?: {
|
||||
id: string
|
||||
name: string
|
||||
updated_at: number
|
||||
}
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
type Assistant = {
|
||||
@ -62,4 +70,4 @@ type TokenSpeed = {
|
||||
tokenSpeed: number
|
||||
tokenCount: number
|
||||
lastTimestamp: number
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user