diff --git a/web-app/src/constants/localStorage.ts b/web-app/src/constants/localStorage.ts index ae744837b..f13f5fcab 100644 --- a/web-app/src/constants/localStorage.ts +++ b/web-app/src/constants/localStorage.ts @@ -21,4 +21,5 @@ export const localStorageKey = { lastUsedAssistant: 'last-used-assistant', favoriteModels: 'favorite-models', setupCompleted: 'setup-completed', + threadManagement: 'thread-management', } diff --git a/web-app/src/constants/routes.ts b/web-app/src/constants/routes.ts index 97f95631d..f1f870dd5 100644 --- a/web-app/src/constants/routes.ts +++ b/web-app/src/constants/routes.ts @@ -3,6 +3,8 @@ export const route = { home: '/', appLogs: '/logs', assistant: '/assistant', + project: '/project', + projectDetail: '/project/$projectId', settings: { index: '/settings', model_providers: '/settings/providers', diff --git a/web-app/src/containers/ChatInput.tsx b/web-app/src/containers/ChatInput.tsx index c5743647b..cba580ebd 100644 --- a/web-app/src/containers/ChatInput.tsx +++ b/web-app/src/containers/ChatInput.tsx @@ -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(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(() => { diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 67e35fab2..cef872119 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -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( + null + ) + const [deleteProjectConfirmOpen, setDeleteProjectConfirmOpen] = + useState(false) + const [deletingProjectId, setDeletingProjectId] = useState( + 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 = () => { )} -
-
+
+
{IS_MACOS && (
@@ -294,7 +343,151 @@ const LeftPanel = () => { )}
)} -
+ + {mainMenus.map((menu) => { + if (!menu.isEnabled) { + return null + } + + // Handle authentication menu specially + if (menu.title === 'common:authentication') { + return ( +
+
+ {isAuthenticated ? ( + + ) : ( + + )} +
+ ) + } + + // 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 ( + 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' + )} + > + + + {t(menu.title)} + + + ) + })} +
+ + {filteredProjects.length > 0 && ( +
+
+ + Projects + +
+
+ {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 ( +
+
+ + isSmallScreen && setLeftPanel(false) + } + className="py-1 pr-2 truncate flex items-center gap-2 flex-1" + > + + + {folder.name} + + +
+ setOpenDropdown(open)} + > + + { + e.preventDefault() + e.stopPropagation() + }} + /> + + + { + e.stopPropagation() + setEditingProjectKey(folder.id) + setProjectDialogOpen(true) + }} + > + + Edit + + { + e.stopPropagation() + handleProjectDelete(folder.id) + }} + > + + Delete + + + +
+
+
+ ) + } + + return + })} +
+
+ )} + +
+
{favoritedThreads.length > 0 && ( <> @@ -397,7 +590,7 @@ const LeftPanel = () => { <>
- +
{t('common:noThreadsYet')}
@@ -414,59 +607,36 @@ const LeftPanel = () => {
+ {PlatformFeatures[PlatformFeature.AUTHENTICATION] && ( +
+
+
+ {isAuthenticated ? : } +
+
+ )} + +
- -
- {mainMenus.map((menu) => { - if (!menu.isEnabled) { - return null - } - - // Handle authentication menu specially - if (menu.title === 'common:authentication') { - return ( -
-
- {isAuthenticated ? ( - - ) : ( - - )} -
- ) - } - - // 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 ( - 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' - )} - > - - - {t(menu.title)} - - - ) - })} -
- -
+ + {/* Project Dialogs */} + + ) } diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index 672fc3ebc..40f0e0216 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -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>/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>/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 ( -
{ - 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' - )} - > -
- {thread.title || t('common:newThread')} -
-
- 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 ( +
{ + 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' + )} + > +
- - { - e.preventDefault() - e.stopPropagation() - }} - /> - - - {thread.isFavorite ? ( - {thread.title || t('common:newThread')} + {variant === 'project' && ( + <> + {variant === 'project' && getLastMessageInfo?.content && ( +
+ {getLastMessageInfo.content} +
+ )} + + )} +
+
+ setOpenDropdown(open)} + > + + { + e.preventDefault() e.stopPropagation() - toggleFavorite(thread.id) }} - > - - {t('common:unstar')} - - ) : ( - { - e.stopPropagation() - toggleFavorite(thread.id) - }} - > - - {t('common:star')} - - )} - setOpenDropdown(false)} - /> + /> + + + {thread.isFavorite ? ( + { + e.stopPropagation() + toggleFavorite(thread.id) + }} + > + + {t('common:unstar')} + + ) : ( + { + e.stopPropagation() + toggleFavorite(thread.id) + }} + > + + {t('common:star')} + + )} + setOpenDropdown(false)} + /> - - setOpenDropdown(false)} - /> - - + + + + Add to project + + + {folders.length === 0 ? ( + + + No projects available + + + ) : ( + folders + .sort((a, b) => b.updated_at - a.updated_at) + .map((folder) => ( + { + e.stopPropagation() + assignThreadToProject(thread.id, folder.id) + }} + > + + + {folder.name} + + + )) + )} + {thread.metadata?.project && ( + <> + + { + 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` + ) + }} + > + + Remove from project + + + )} + + + + setOpenDropdown(false)} + /> + + +
-
- ) -}) + ) + } +) 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) => ( - + ))} diff --git a/web-app/src/containers/dialogs/AddProjectDialog.tsx b/web-app/src/containers/dialogs/AddProjectDialog.tsx new file mode 100644 index 000000000..f0fda648c --- /dev/null +++ b/web-app/src/containers/dialogs/AddProjectDialog.tsx @@ -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 ( + + + + + {editingKey ? t('projects.addProjectDialog.editTitle') : t('projects.addProjectDialog.createTitle')} + + +
+
+ + setName(e.target.value)} + placeholder={t('projects.addProjectDialog.namePlaceholder')} + className="mt-1" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter' && !isButtonDisabled) { + handleSave() + } + }} + /> +
+
+ + + + +
+
+ ) +} diff --git a/web-app/src/containers/dialogs/DeleteProjectDialog.tsx b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx new file mode 100644 index 000000000..f8c86a3b4 --- /dev/null +++ b/web-app/src/containers/dialogs/DeleteProjectDialog.tsx @@ -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(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 ( + + { + e.preventDefault() + deleteButtonRef.current?.focus() + }} + > + + {t('projects.deleteProjectDialog.title')} + + {t('projects.deleteProjectDialog.description')} + + + + + + + + + ) +} diff --git a/web-app/src/containers/dialogs/index.ts b/web-app/src/containers/dialogs/index.ts index b3c640200..3f96e5d17 100644 --- a/web-app/src/containers/dialogs/index.ts +++ b/web-app/src/containers/dialogs/index.ts @@ -6,4 +6,5 @@ export { MessageMetadataDialog } from './MessageMetadataDialog' export { DeleteMessageDialog } from './DeleteMessageDialog' export { FactoryResetDialog } from './FactoryResetDialog' export { DeleteAssistantDialog } from './DeleteAssistantDialog' -export { AddProviderDialog } from './AddProviderDialog' \ No newline at end of file +export { DeleteProjectDialog } from './DeleteProjectDialog' +export { AddProviderDialog } from './AddProviderDialog' diff --git a/web-app/src/hooks/useThreadManagement.ts b/web-app/src/hooks/useThreadManagement.ts new file mode 100644 index 000000000..84e5b0e34 --- /dev/null +++ b/web-app/src/hooks/useThreadManagement.ts @@ -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()( + 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), + } + ) +) diff --git a/web-app/src/hooks/useThreads.ts b/web-app/src/hooks/useThreads.ts index cce11c027..b450874cd 100644 --- a/web-app/src/hooks/useThreads.ts +++ b/web-app/src/hooks/useThreads.ts @@ -20,12 +20,14 @@ type ThreadState = { createThread: ( model: ThreadModel, title?: string, - assistant?: Assistant + assistant?: Assistant, + projectMetadata?: { id: string; name: string; updated_at: number } ) => Promise updateCurrentThreadModel: (model: ThreadModel) => void getFilteredThreads: (searchTerm: string) => Thread[] updateCurrentThreadAssistant: (assistant: Assistant) => void updateThreadTimestamp: (threadId: string) => void + updateThread: (threadId: string, updates: Partial) => void searchIndex: Fzf | null } @@ -132,20 +134,28 @@ export const useThreads = create()((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()((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()((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(Object.values(newThreads), { + selector: (item: Thread) => item.title, + }), + } + }) + }, })) diff --git a/web-app/src/locales/de-DE/common.json b/web-app/src/locales/de-DE/common.json index c0a55e1d9..4ce743b46 100644 --- a/web-app/src/locales/de-DE/common.json +++ b/web-app/src/locales/de-DE/common.json @@ -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" } } diff --git a/web-app/src/locales/en/common.json b/web-app/src/locales/en/common.json index ce6628000..c829dbdf8 100644 --- a/web-app/src/locales/en/common.json +++ b/web-app/src/locales/en/common.json @@ -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" } } } \ No newline at end of file diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index c1f9838c6..aa0c83fd9 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -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 {{toolName}}", @@ -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" } } diff --git a/web-app/src/locales/pl/common.json b/web-app/src/locales/pl/common.json index 14fd6519e..ca6f6b6b7 100644 --- a/web-app/src/locales/pl/common.json +++ b/web-app/src/locales/pl/common.json @@ -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", diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index 8a107a9a2..4c2d95101 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -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", diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index ccabb6071..6da4a83fa 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -117,6 +117,7 @@ "chatInput": "随便问我什么..." }, "confirm": "确认", + "continue": "继续", "loading": "加载中...", "error": "错误", "success": "成功", diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index cb0a60510..4b9d1e7f6 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -117,6 +117,7 @@ "chatInput": "問我任何事..." }, "confirm": "確認", + "continue": "繼續", "loading": "載入中...", "error": "錯誤", "success": "成功", diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 4322b0fd1..0eb2bbf13 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -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" }, diff --git a/web-app/src/routes/assistant.tsx b/web-app/src/routes/assistant.tsx index bf4fd928c..d96080f7d 100644 --- a/web-app/src/routes/assistant.tsx +++ b/web-app/src/routes/assistant.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 (
- {t('assistants:title')} +
+ {t('assistants:title')} + +
-
-
+
+
{assistants .slice() .sort((a, b) => a.created_at - b.created_at) .map((assistant) => (
-
-

-
- {assistant?.avatar && ( - - - - )} - {assistant.name} -
-

-
-
{ - setEditingKey(assistant.id) - setOpen(true) - }} - > - -
-
handleDelete(assistant.id)} - > - +
+ {assistant?.avatar && ( +
+
+ )} +
+

+ {assistant.name} +

+

+ {assistant.description} +

-

- {assistant.description} -

+
+ + +
))} - -
{ - setEditingKey(null) - setOpen(true) - }} - > - -
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 + } + + if (!project) { + return ( +
+
+

+ {t('projects.projectNotFound')} +

+

+ {t('projects.projectNotFoundDesc')} +

+
+
+ ) + } + + return ( +
+ +
+ {PlatformFeatures[PlatformFeature.ASSISTANTS] && ( + + )} +
+
+ +
+
+
+
+ {projectThreads.length > 0 && ( + <> +

+ {t('projects.conversationsIn', { projectName: project.name })} +

+

+ {t('projects.conversationsDescription')} +

+ + )} +
+ + {/* Thread List or Empty State */} +
+ {projectThreads.length > 0 ? ( + + ) : ( +
+ +

+ {t('projects.noConversationsIn', { projectName: project.name })} +

+

+ {t('projects.startNewConversation', { projectName: project.name })} +

+
+ )} +
+
+
+
+ {/* New Chat Input */} +
+ +
+
+ ) +} diff --git a/web-app/src/routes/project/index.tsx b/web-app/src/routes/project/index.tsx new file mode 100644 index 000000000..b4cbb6618 --- /dev/null +++ b/web-app/src/routes/project/index.tsx @@ -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 +} + +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(null) + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) + const [deletingId, setDeletingId] = useState(null) + const [expandedProjects, setExpandedProjects] = useState>( + 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 ( +
+ +
+ {t('projects.title')} + +
+
+
+
+ {folders.length === 0 ? ( +
+ +

+ {t('projects.noProjectsYet')} +

+

+ {t('projects.noProjectsYetDesc')} +

+
+ ) : ( +
+ {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 ( +
+
+
+
+ +
+
+
+

+ {folder.name} +

+ + {projectThreads.length}{' '} + {projectThreads.length === 1 + ? t('projects.thread') + : t('projects.threads')} + +
+

+ {t('projects.updated')}{' '} + {formatProjectDate(folder.updated_at)} +

+
+
+
+ {projectThreads.length > 0 && ( + + )} + + +
+
+ + {/* Thread List */} + {isExpanded && projectThreads.length > 0 && ( +
+ +
+ )} +
+ ) + })} +
+ )} +
+
+ + +
+ ) +} diff --git a/web-app/src/services/threads/default.ts b/web-app/src/services/threads/default.ts index af5f213d5..72c66841a 100644 --- a/web-app/src/services/threads/default.ts +++ b/web-app/src/services/threads/default.ts @@ -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(ExtensionTypeEnum.Conversational) ?.deleteThread(threadId) } -} \ No newline at end of file +} diff --git a/web-app/src/types/threads.d.ts b/web-app/src/types/threads.d.ts index 657b7e651..35238687a 100644 --- a/web-app/src/types/threads.d.ts +++ b/web-app/src/types/threads.d.ts @@ -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 -} \ No newline at end of file +}