From f46d45e7866956e6c0e919002ad66b0cdc6b8fc6 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 26 Jun 2025 15:01:44 +0700 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=90=9Bfix:=20disabled=20backdrop=20on?= =?UTF-8?q?boarding=20on=20linux=20(#5533)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web-app/src/routes/hub.tsx | 1 + web-app/src/routes/settings/providers/$providerName.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/web-app/src/routes/hub.tsx b/web-app/src/routes/hub.tsx index c0e12130a..20f057073 100644 --- a/web-app/src/routes/hub.tsx +++ b/web-app/src/routes/hub.tsx @@ -372,6 +372,7 @@ function Hub() { showSkipButton={!isLastStep} hideCloseButton={true} spotlightClicks={true} + disableOverlay={IS_LINUX} disableOverlayClose={true} callback={handleJoyrideCallback} locale={{ diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index ac92967d7..a1cbd9149 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -213,6 +213,7 @@ function ProviderDetail() { showSkipButton={true} hideCloseButton={true} spotlightClicks={true} + disableOverlay={IS_LINUX} disableOverlayClose={true} callback={handleJoyrideCallback} locale={{ From 9bbf9a590c235c7554feca3c1123279124078dc1 Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Thu, 26 Jun 2025 15:01:50 +0700 Subject: [PATCH 2/8] =?UTF-8?q?=E2=9C=A8enhancement:=20support=20base=20la?= =?UTF-8?q?yout=20responsive=20UI=20(#5472)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨enhancement: support base layout responsive UI * Update web-app/src/containers/LeftPanel.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update web-app/src/containers/ThreadList.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: responsive assistant screen (#5502) * ✨enhancement: support base layout responsive UI * Update web-app/src/containers/LeftPanel.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update web-app/src/containers/ThreadList.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: responsive assistant screen * Update web-app/src/containers/dialogs/AddEditAssistant.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: sort assistant * Update web-app/src/routes/assistant.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: responsive hub screen (#5507) * ✨enhancement: support base layout responsive UI * Update web-app/src/containers/LeftPanel.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Update web-app/src/containers/ThreadList.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: responsive assistant screen * Update web-app/src/containers/dialogs/AddEditAssistant.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: sort assistant * Update web-app/src/routes/assistant.tsx Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * ✨enhancement: responsive hub screen * 🧹cleanup: multiple key and useless for hub translation --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- src-tauri/tauri.conf.json | 2 + web-app/src/components/ui/dialog.tsx | 2 +- web-app/src/containers/LeftPanel.tsx | 510 +++++++++++------- web-app/src/containers/ThreadList.tsx | 24 +- .../containers/dialogs/AddEditAssistant.tsx | 186 +++---- web-app/src/hooks/useClickOutside.ts | 42 ++ web-app/src/hooks/useMediaQuery.ts | 90 ++++ web-app/src/locales/en/hub.json | 3 +- web-app/src/locales/id/hub.json | 3 +- web-app/src/locales/vn/hub.json | 3 +- web-app/src/locales/zh-CN/hub.json | 3 +- web-app/src/locales/zh-TW/hub.json | 3 +- web-app/src/routes/__root.tsx | 4 +- web-app/src/routes/assistant.tsx | 97 ++-- web-app/src/routes/hub.tsx | 97 ++-- web-app/src/routes/index.tsx | 4 +- web-app/src/routes/threads/$threadId.tsx | 10 +- 17 files changed, 690 insertions(+), 393 deletions(-) create mode 100644 web-app/src/hooks/useClickOutside.ts create mode 100644 web-app/src/hooks/useMediaQuery.ts diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 04191e842..210322297 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,6 +17,8 @@ "label": "main", "title": "Jan", "width": 1024, + "minWidth": 375, + "minHeight": 667, "height": 800, "resizable": true, "fullscreen": false, diff --git a/web-app/src/components/ui/dialog.tsx b/web-app/src/components/ui/dialog.tsx index 98f4da71c..ed95f00b4 100644 --- a/web-app/src/components/ui/dialog.tsx +++ b/web-app/src/components/ui/dialog.tsx @@ -67,7 +67,7 @@ function DialogContent({ data-slot="dialog-content" aria-describedby={ariaDescribedBy} className={cn( - 'bg-main-view max-h-[calc(100%-48px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', + 'bg-main-view max-h-[calc(100%-80px)] overflow-auto border-main-view-fg/10 text-main-view-fg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg', className )} {...props} diff --git a/web-app/src/containers/LeftPanel.tsx b/web-app/src/containers/LeftPanel.tsx index 5d0b1542e..19dc30972 100644 --- a/web-app/src/containers/LeftPanel.tsx +++ b/web-app/src/containers/LeftPanel.tsx @@ -26,7 +26,7 @@ import { import { useThreads } from '@/hooks/useThreads' import { useTranslation } from '@/i18n/react-i18next-compat' -import { useMemo, useState } from 'react' +import { useMemo, useState, useEffect, useRef } from 'react' import { Dialog, DialogClose, @@ -40,6 +40,8 @@ import { import { Button } from '@/components/ui/button' import { toast } from 'sonner' import { DownloadManagement } from '@/containers/DownloadManegement' +import { useSmallScreen } from '@/hooks/useMediaQuery' +import { useClickOutside } from '@/hooks/useClickOutside' const mainMenus = [ { @@ -70,6 +72,68 @@ const LeftPanel = () => { const navigate = useNavigate() const [searchTerm, setSearchTerm] = useState('') + const isSmallScreen = useSmallScreen() + const prevScreenSizeRef = useRef(null) + const isInitialMountRef = useRef(true) + const panelRef = useRef(null) + const searchContainerRef = useRef(null) + const searchContainerMacRef = useRef(null) + + // Use click outside hook for panel with debugging + useClickOutside( + () => { + if (isSmallScreen && open) { + setLeftPanel(false) + } + }, + null, + [ + panelRef.current, + searchContainerRef.current, + searchContainerMacRef.current, + ] + ) + + // Auto-collapse panel only when window is resized + useEffect(() => { + const handleResize = () => { + const currentIsSmallScreen = window.innerWidth <= 768 + + // Skip on initial mount + if (isInitialMountRef.current) { + isInitialMountRef.current = false + prevScreenSizeRef.current = currentIsSmallScreen + return + } + + // Only trigger if the screen size actually changed + if ( + prevScreenSizeRef.current !== null && + prevScreenSizeRef.current !== currentIsSmallScreen + ) { + if (currentIsSmallScreen) { + setLeftPanel(false) + } else { + setLeftPanel(true) + } + prevScreenSizeRef.current = currentIsSmallScreen + } + } + + // Add resize listener + window.addEventListener('resize', handleResize) + + // Initialize the previous screen size on mount + if (isInitialMountRef.current) { + prevScreenSizeRef.current = window.innerWidth <= 768 + isInitialMountRef.current = false + } + + return () => { + window.removeEventListener('resize', handleResize) + } + }, [setLeftPanel]) + const currentPath = useRouterState({ select: (state) => state.location.pathname, }) @@ -91,50 +155,63 @@ const LeftPanel = () => { return filteredThreads.filter((t) => !t.isFavorite) }, [filteredThreads]) - return ( - + ) } diff --git a/web-app/src/containers/ThreadList.tsx b/web-app/src/containers/ThreadList.tsx index e4ed8aa93..bd3825eee 100644 --- a/web-app/src/containers/ThreadList.tsx +++ b/web-app/src/containers/ThreadList.tsx @@ -20,8 +20,10 @@ import { IconStar, } from '@tabler/icons-react' import { useThreads } from '@/hooks/useThreads' +import { useLeftPanel } from '@/hooks/useLeftPanel' import { cn } from '@/lib/utils' import { route } from '@/constants/routes' +import { useSmallScreen } from '@/hooks/useMediaQuery' import { DropdownMenu, @@ -55,6 +57,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { isDragging, } = useSortable({ id: thread.id, disabled: true }) + const isSmallScreen = useSmallScreen() + const { setLeftPanel } = useLeftPanel() + const style = { transform: CSS.Transform.toString(transform), transition, @@ -75,7 +80,11 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { const handleClick = () => { if (!isDragging) { - navigate({ to: route.threadsDetail, params: { threadId: thread.id } }) + // 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 } }) + } } } @@ -85,7 +94,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { return (thread.title || '').replace(/]*>|<\/span>/g, '') }, [thread.title]) - const [title, setTitle] = useState(plainTitleForRename || t('common:newThread')) + const [title, setTitle] = useState( + plainTitleForRename || t('common:newThread') + ) return (
{ setOpenDropdown(false) toast.success(t('common:toast.renameThread.title'), { id: 'rename-thread', - description: t('common:toast.renameThread.description', { title }), + description: t( + 'common:toast.renameThread.description', + { title } + ), }) }} > @@ -231,7 +245,9 @@ const SortableItem = memo(({ thread }: { thread: Thread }) => { setOpenDropdown(false) toast.success(t('common:toast.deleteThread.title'), { id: 'delete-thread', - description: t('common:toast.deleteThread.description'), + description: t( + 'common:toast.deleteThread.description' + ), }) setTimeout(() => { navigate({ to: route.home }) diff --git a/web-app/src/containers/dialogs/AddEditAssistant.tsx b/web-app/src/containers/dialogs/AddEditAssistant.tsx index 7a1242027..f4e327e35 100644 --- a/web-app/src/containers/dialogs/AddEditAssistant.tsx +++ b/web-app/src/containers/dialogs/AddEditAssistant.tsx @@ -378,73 +378,27 @@ export default function AddEditAssistant({
{paramsKeys.map((key, index) => ( -
- - handleParameterChange(index, e.target.value, 'key') - } - placeholder={t('assistants:key')} - className="w-24" - /> +
+
+ + handleParameterChange(index, e.target.value, 'key') + } + placeholder={t('assistants:key')} + className="w-full sm:w-24" + /> - - -
- - -
-
- - - handleParameterChange(index, 'string', 'type') - } - > - {t('assistants:stringValue')} - - - handleParameterChange(index, 'number', 'type') - } - > - {t('assistants:numberValue')} - - - handleParameterChange(index, 'boolean', 'type') - } - > - {t('assistants:booleanValue')} - - - handleParameterChange(index, 'json', 'type') - } - > - {t('assistants:jsonValue')} - - -
- - {paramsTypes[index] === 'boolean' ? ( -
+
@@ -454,48 +408,98 @@ export default function AddEditAssistant({ />
- + - handleParameterChange(index, true, 'value') + handleParameterChange(index, 'string', 'type') } > - {t('assistants:trueValue')} + {t('assistants:stringValue')} - handleParameterChange(index, false, 'value') + handleParameterChange(index, 'number', 'type') } > - {t('assistants:falseValue')} + {t('assistants:numberValue')} + + + handleParameterChange(index, 'boolean', 'type') + } + > + {t('assistants:booleanValue')} + + + handleParameterChange(index, 'json', 'type') + } + > + {t('assistants:jsonValue')} - ) : paramsTypes[index] === 'json' ? ( - - handleParameterChange(index, e.target.value, 'value') - } - placeholder={t('assistants:jsonValuePlaceholder')} - className="flex-1" - /> - ) : ( - - handleParameterChange(index, e.target.value, 'value') - } - type={paramsTypes[index] === 'number' ? 'number' : 'text'} - placeholder={t('assistants:value')} - className="flex-1" - /> - )} + {paramsTypes[index] === 'boolean' ? ( + + +
+ + +
+
+ + + handleParameterChange(index, true, 'value') + } + > + {t('assistants:trueValue')} + + + handleParameterChange(index, false, 'value') + } + > + {t('assistants:falseValue')} + + +
+ ) : paramsTypes[index] === 'json' ? ( + + handleParameterChange(index, e.target.value, 'value') + } + placeholder={t('assistants:jsonValuePlaceholder')} + className="sm:flex-1 h-[36px] w-full" + /> + ) : ( + + handleParameterChange(index, e.target.value, 'value') + } + type={paramsTypes[index] === 'number' ? 'number' : 'text'} + placeholder={t('assistants:value')} + className="sm:flex-1 h-[36px] w-full" + /> + )} +
handleRemoveParameter(index)} diff --git a/web-app/src/hooks/useClickOutside.ts b/web-app/src/hooks/useClickOutside.ts new file mode 100644 index 000000000..237b9dff1 --- /dev/null +++ b/web-app/src/hooks/useClickOutside.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { useEffect, useRef } from 'react' + +const DEFAULT_EVENTS = ['mousedown', 'touchstart'] + +export function useClickOutside( + handler: () => void, + events?: string[] | null, + nodes?: (HTMLElement | null)[] +) { + const ref = useRef(null) + + useEffect(() => { + const listener = (event: any) => { + const { target } = event ?? {} + if (Array.isArray(nodes)) { + const shouldIgnore = + target?.hasAttribute('data-ignore-outside-clicks') || + (!document.body.contains(target) && target.tagName !== 'HTML') + const shouldTrigger = nodes.every( + (node) => !!node && !event.composedPath().includes(node) + ) + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + shouldTrigger && !shouldIgnore && handler() + } else if (ref.current && !ref.current.contains(target)) { + handler() + } + } + + ;(events || DEFAULT_EVENTS).forEach((fn) => + document.addEventListener(fn, listener) + ) + + return () => { + ;(events || DEFAULT_EVENTS).forEach((fn) => + document.removeEventListener(fn, listener) + ) + } + }, [ref, handler, nodes, events]) + + return ref +} diff --git a/web-app/src/hooks/useMediaQuery.ts b/web-app/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..9e479eec4 --- /dev/null +++ b/web-app/src/hooks/useMediaQuery.ts @@ -0,0 +1,90 @@ +import { useEffect, useRef, useState } from 'react' +import { create } from 'zustand' + +export interface UseMediaQueryOptions { + getInitialValueInEffect: boolean +} + +type MediaQueryCallback = (event: { matches: boolean; media: string }) => void + +// Zustand store for small screen state +type SmallScreenState = { + isSmallScreen: boolean + setIsSmallScreen: (isSmall: boolean) => void +} + +export const useSmallScreenStore = create((set) => ({ + isSmallScreen: false, + setIsSmallScreen: (isSmall) => set({ isSmallScreen: isSmall }), +})) + +/** + * Older versions of Safari (shipped withCatalina and before) do not support addEventListener on matchMedia + * https://stackoverflow.com/questions/56466261/matchmedia-addlistener-marked-as-deprecated-addeventlistener-equivalent + * */ +function attachMediaListener( + query: MediaQueryList, + callback: MediaQueryCallback +) { + try { + query.addEventListener('change', callback) + return () => query.removeEventListener('change', callback) + } catch (e) { + console.warn(e) + // eslint-disable @typescript-eslint/no-deprecated + query.addListener(callback) + return () => query.removeListener(callback) + // eslint-enable @typescript-eslint/no-deprecated + } +} + +function getInitialValue(query: string, initialValue?: boolean) { + if (typeof initialValue === 'boolean') { + return initialValue + } + + if (typeof window !== 'undefined' && 'matchMedia' in window) { + return window.matchMedia(query).matches + } + + return false +} + +export function useMediaQuery( + query: string, + initialValue?: boolean, + { getInitialValueInEffect }: UseMediaQueryOptions = { + getInitialValueInEffect: true, + } +): boolean { + const [matches, setMatches] = useState( + getInitialValueInEffect ? initialValue : getInitialValue(query) + ) + const queryRef = useRef(null) + + useEffect(() => { + if ('matchMedia' in window) { + queryRef.current = window.matchMedia(query) + setMatches(queryRef.current.matches) + return attachMediaListener(queryRef.current, (event) => + setMatches(event.matches) + ) + } + + return undefined + }, [query]) + + return matches || false +} + +// Specific hook for small screen detection with state management +export const useSmallScreen = (): boolean => { + const { isSmallScreen, setIsSmallScreen } = useSmallScreenStore() + const mediaQuery = useMediaQuery('(max-width: 768px)') + + useEffect(() => { + setIsSmallScreen(mediaQuery) + }, [mediaQuery, setIsSmallScreen]) + + return isSmallScreen +} diff --git a/web-app/src/locales/en/hub.json b/web-app/src/locales/en/hub.json index bdc83bd50..e082c05b5 100644 --- a/web-app/src/locales/en/hub.json +++ b/web-app/src/locales/en/hub.json @@ -13,7 +13,6 @@ "useModel": "Use this model", "downloadModel": "Download model", "searchPlaceholder": "Search for models on Hugging Face...", - "editTheme": "Edit Theme", "joyride": { "recommendedModelTitle": "Recommended Model", "recommendedModelContent": "Browse and download powerful AI models from various providers, all in one place. We suggest starting with Jan-Nano - a model optimized for function calling, tool integration, and research capabilities. It's ideal for building interactive AI agents.", @@ -28,4 +27,4 @@ "next": "Next", "skip": "Skip" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/id/hub.json b/web-app/src/locales/id/hub.json index 7d9fe733f..5aa1e7d1c 100644 --- a/web-app/src/locales/id/hub.json +++ b/web-app/src/locales/id/hub.json @@ -13,7 +13,6 @@ "useModel": "Gunakan model ini", "downloadModel": "Unduh model", "searchPlaceholder": "Cari model di Hugging Face...", - "editTheme": "Edit Tema", "joyride": { "recommendedModelTitle": "Model yang Direkomendasikan", "recommendedModelContent": "Jelajahi dan unduh model AI yang kuat dari berbagai penyedia, semuanya di satu tempat. Kami sarankan memulai dengan Jan-Nano - model yang dioptimalkan untuk pemanggilan fungsi, integrasi alat, dan kemampuan penelitian. Ini ideal untuk membangun agen AI interaktif.", @@ -28,4 +27,4 @@ "next": "Berikutnya", "skip": "Lewati" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/vn/hub.json b/web-app/src/locales/vn/hub.json index 34f6b485b..8b38d84cc 100644 --- a/web-app/src/locales/vn/hub.json +++ b/web-app/src/locales/vn/hub.json @@ -13,7 +13,6 @@ "useModel": "Sử dụng mô hình này", "downloadModel": "Tải xuống mô hình", "searchPlaceholder": "Tìm kiếm các mô hình trên Hugging Face...", - "editTheme": "Chỉnh sửa chủ đề", "joyride": { "recommendedModelTitle": "Mô hình được đề xuất", "recommendedModelContent": "Duyệt và tải xuống các mô hình AI mạnh mẽ từ nhiều nhà cung cấp khác nhau, tất cả ở cùng một nơi. Chúng tôi khuyên bạn nên bắt đầu với Jan-Nano - một mô hình được tối ưu hóa cho các khả năng gọi hàm, tích hợp công cụ và nghiên cứu. Nó lý tưởng để xây dựng các tác nhân AI tương tác.", @@ -28,4 +27,4 @@ "next": "Tiếp theo", "skip": "Bỏ qua" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/zh-CN/hub.json b/web-app/src/locales/zh-CN/hub.json index 39231c2fb..dc005611a 100644 --- a/web-app/src/locales/zh-CN/hub.json +++ b/web-app/src/locales/zh-CN/hub.json @@ -13,7 +13,6 @@ "useModel": "使用此模型", "downloadModel": "下载模型", "searchPlaceholder": "在 Hugging Face 上搜索模型...", - "editTheme": "编辑主题", "joyride": { "recommendedModelTitle": "推荐模型", "recommendedModelContent": "在一个地方浏览和下载来自不同提供商的强大 AI 模型。我们建议从 Jan-Nano 开始 - 这是一个针对函数调用、工具集成和研究功能进行优化的模型。它非常适合构建交互式 AI 代理。", @@ -28,4 +27,4 @@ "next": "下一步", "skip": "跳过" } -} \ No newline at end of file +} diff --git a/web-app/src/locales/zh-TW/hub.json b/web-app/src/locales/zh-TW/hub.json index e4a4df0d5..f35a4485a 100644 --- a/web-app/src/locales/zh-TW/hub.json +++ b/web-app/src/locales/zh-TW/hub.json @@ -13,7 +13,6 @@ "useModel": "使用此模型", "downloadModel": "下載模型", "searchPlaceholder": "在 Hugging Face 上搜尋模型...", - "editTheme": "編輯主題", "joyride": { "recommendedModelTitle": "推薦模型", "recommendedModelContent": "在一個地方瀏覽和下載來自不同提供商的強大 AI 模型。我們建議從 Jan-Nano 開始 - 這是一個針對函數調用、工具整合和研究功能進行優化的模型。它非常適合構建互動式 AI 代理。", @@ -28,4 +27,4 @@ "next": "下一步", "skip": "略過" } -} \ No newline at end of file +} diff --git a/web-app/src/routes/__root.tsx b/web-app/src/routes/__root.tsx index 1014c2aa1..8c09addf5 100644 --- a/web-app/src/routes/__root.tsx +++ b/web-app/src/routes/__root.tsx @@ -43,8 +43,8 @@ const AppLayout = () => { {/* Main content panel */}
diff --git a/web-app/src/routes/assistant.tsx b/web-app/src/routes/assistant.tsx index 031003766..fe0cf6fe7 100644 --- a/web-app/src/routes/assistant.tsx +++ b/web-app/src/routes/assistant.tsx @@ -62,57 +62,60 @@ function Assistant() { {t('assistants:title')}
-
- {assistants.map((assistant) => ( -
-
-

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

-
-
{ - setEditingKey(assistant.id) - setOpen(true) - }} - > - -
-
handleDelete(assistant.id)} - > - +
+ {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.description} +

-

- {assistant.description} -

-
- ))} + ))} +
{ setEditingKey(null) diff --git a/web-app/src/routes/hub.tsx b/web-app/src/routes/hub.tsx index 20f057073..5ac51b1af 100644 --- a/web-app/src/routes/hub.tsx +++ b/web-app/src/routes/hub.tsx @@ -358,6 +358,46 @@ function Hub() { // Check if we're on the last step const isLastStep = currentStepIndex === steps.length - 1 + const renderFilter = () => { + return ( + <> + + + + { + sortOptions.find((option) => option.value === sortSelected) + ?.name + } + + + + {sortOptions.map((option) => ( + setSortSelected(option.value)} + > + {option.name} + + ))} + + +
+ + + {t('hub:downloaded')} + +
+ + ) + } + return ( <>
{isSearching ? ( - + ) : ( - + )}
-
- - - - { - sortOptions.find( - (option) => option.value === sortSelected - )?.name - } - - - - {sortOptions.map((option) => ( - setSortSelected(option.value)} - > - {option.name} - - ))} - - -
- - - {t('hub:downloaded')} - -
+
+ {renderFilter()}
-
+
{loading ? (
@@ -459,6 +466,9 @@ function Hub() {
) : (
+
+ {renderFilter()} +
{filteredModels.map((model) => (

{extractModelName(model.metadata?.id) || ''}

diff --git a/web-app/src/routes/index.tsx b/web-app/src/routes/index.tsx index cc072fb75..89a9172c3 100644 --- a/web-app/src/routes/index.tsx +++ b/web-app/src/routes/index.tsx @@ -53,8 +53,8 @@ function Index() { -
-
+
+

{t('chat:welcome')} diff --git a/web-app/src/routes/threads/$threadId.tsx b/web-app/src/routes/threads/$threadId.tsx index 3984ace00..d2c4a3e3e 100644 --- a/web-app/src/routes/threads/$threadId.tsx +++ b/web-app/src/routes/threads/$threadId.tsx @@ -20,6 +20,7 @@ import { useAssistant } from '@/hooks/useAssistant' import { useAppearance } from '@/hooks/useAppearance' import { useOutOfContextPromiseModal } from '@/containers/dialogs/OutOfContextDialog' import { useTranslation } from '@/i18n/react-i18next-compat' +import { useSmallScreen } from '@/hooks/useMediaQuery' // as route.threadsDetail export const Route = createFileRoute('/threads/$threadId')({ @@ -38,6 +39,7 @@ function ThreadDetail() { const { setMessages } = useMessages() const { streamingContent } = useAppState() const { appMainViewBgColor, chatWidth } = useAppearance() + const isSmallScreen = useSmallScreen() const { messages } = useMessages( useShallow((state) => ({ @@ -218,7 +220,8 @@ function ThreadDetail() {
{messages && @@ -256,8 +259,9 @@ function ThreadDetail() {
Date: Thu, 26 Jun 2025 23:59:53 +0700 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=90=9Bfix:=20translation=20fallback?= =?UTF-8?q?=20(#5554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛fix: translation fallback * 🧹cleanup: remove duplicate key translation --- .../containers/dialogs/CortexFailureDialog.tsx | 17 +++++++---------- web-app/src/locales/id/common.json | 7 ------- web-app/src/locales/vn/common.json | 7 ------- web-app/src/locales/zh-CN/common.json | 7 ------- web-app/src/locales/zh-TW/common.json | 7 ------- 5 files changed, 7 insertions(+), 38 deletions(-) diff --git a/web-app/src/containers/dialogs/CortexFailureDialog.tsx b/web-app/src/containers/dialogs/CortexFailureDialog.tsx index b28281f54..48d08569d 100644 --- a/web-app/src/containers/dialogs/CortexFailureDialog.tsx +++ b/web-app/src/containers/dialogs/CortexFailureDialog.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { listen } from '@tauri-apps/api/event' import { invoke } from '@tauri-apps/api/core' -import { t } from 'i18next' + import { Dialog, DialogContent, @@ -11,8 +11,10 @@ import { DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import { useTranslation } from '@/i18n' export function CortexFailureDialog() { + const { t } = useTranslation() const [showDialog, setShowDialog] = useState(false) useEffect(() => { @@ -52,15 +54,10 @@ export function CortexFailureDialog() { - - {t('cortexFailureDialog.title', 'Local AI Engine Issue')} - + {t('cortexFailureDialog.title')} - {t( - 'cortexFailureDialog.description', - 'The local AI engine (Cortex) failed to start after multiple attempts. This might prevent some features from working correctly.' - )} + {t('cortexFailureDialog.description')} diff --git a/web-app/src/locales/id/common.json b/web-app/src/locales/id/common.json index 8aa327253..5ba59011b 100644 --- a/web-app/src/locales/id/common.json +++ b/web-app/src/locales/id/common.json @@ -256,12 +256,5 @@ "description": "Cortex gagal dimulai. Silakan periksa log untuk detail lebih lanjut.", "contactSupport": "Hubungi Dukungan", "restartJan": "Restart Jan" - }, - "outOfContextError": { - "title": "Kesalahan di luar konteks", - "description": "Obrolan ini mencapai batas memori AI, seperti papan tulis yang penuh. Kami dapat memperluas jendela memori (disebut ukuran konteks) sehingga mengingat lebih banyak, tetapi mungkin 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?", - "truncateInput": "Potong Input", - "increaseContextSize": "Tingkatkan Ukuran Konteks" } } diff --git a/web-app/src/locales/vn/common.json b/web-app/src/locales/vn/common.json index f970b9b4d..654b2814c 100644 --- a/web-app/src/locales/vn/common.json +++ b/web-app/src/locales/vn/common.json @@ -256,12 +256,5 @@ "description": "Cortex không khởi động được. Vui lòng kiểm tra log để biết thêm chi tiết.", "contactSupport": "Liên hệ Hỗ trợ", "restartJan": "Khởi động lại Jan" - }, - "outOfContextError": { - "title": "Lỗi ngoài ngữ cảnh", - "description": "Cuộc trò chuyện này đang đạt đến giới hạn bộ nhớ của AI, giống như một bảng trắng đang đầy. Chúng ta có thể mở rộng cửa sổ bộ nhớ (gọi là kích thước ngữ cảnh) để nó nhớ nhiều hơn, nhưng có thể sử dụng nhiều bộ nhớ máy tính của bạn hơn. Chúng ta cũng có thể cắt bớt đầu vào, có nghĩa là nó sẽ quên một phần lịch sử trò chuyện để nhường chỗ cho tin nhắn mới.", - "increaseContextSizeDescription": "Bạn có muốn tăng kích thước ngữ cảnh không?", - "truncateInput": "Cắt bớt Đầu vào", - "increaseContextSize": "Tăng Kích thước Ngữ cảnh" } } diff --git a/web-app/src/locales/zh-CN/common.json b/web-app/src/locales/zh-CN/common.json index 67a3f002a..40e392c97 100644 --- a/web-app/src/locales/zh-CN/common.json +++ b/web-app/src/locales/zh-CN/common.json @@ -256,12 +256,5 @@ "description": "Cortex 启动失败。请检查日志以获取更多详细信息。", "contactSupport": "联系支持", "restartJan": "重启 Jan" - }, - "outOfContextError": { - "title": "超出上下文错误", - "description": "此聊天正在达到AI的内存限制,就像白板填满了一样。我们可以扩展内存窗口(称为上下文大小),使其记住更多内容,但可能会使用更多计算机内存。我们也可以截断输入,这意味着它会忘记一些聊天历史记录,为新消息腾出空间。", - "increaseContextSizeDescription": "您想要增加上下文大小吗?", - "truncateInput": "截断输入", - "increaseContextSize": "增加上下文大小" } } diff --git a/web-app/src/locales/zh-TW/common.json b/web-app/src/locales/zh-TW/common.json index 04ae212da..92b4a3c0d 100644 --- a/web-app/src/locales/zh-TW/common.json +++ b/web-app/src/locales/zh-TW/common.json @@ -256,12 +256,5 @@ "description": "Cortex 啟動失敗。請檢查日誌以獲取更多詳細信息。", "contactSupport": "聯繫支援", "restartJan": "重啟 Jan" - }, - "outOfContextError": { - "title": "超出上下文錯誤", - "description": "此聊天正在達到AI的記憶體限制,就像白板填滿了一樣。我們可以擴展記憶體視窗(稱為上下文大小),使其記住更多內容,但可能會使用更多電腦記憶體。我們也可以截斷輸入,這意味著它會忘記一些聊天歷史記錄,為新訊息騰出空間。", - "increaseContextSizeDescription": "您想要增加上下文大小嗎?", - "truncateInput": "截斷輸入", - "increaseContextSize": "增加上下文大小" } } From ba4a36dfb3a805424ca330da7df66091f3730041 Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 27 Jun 2025 10:34:43 +0700 Subject: [PATCH 4/8] fix: min-p-validation-on-model-load --- core/src/browser/models/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/core/src/browser/models/utils.ts b/core/src/browser/models/utils.ts index 192b838da..f2db40241 100644 --- a/core/src/browser/models/utils.ts +++ b/core/src/browser/models/utils.ts @@ -17,6 +17,7 @@ export const validationRules: { [key: string]: (value: any) => boolean } = { presence_penalty: (value: any) => typeof value === 'number' && value >= 0 && value <= 1, repeat_last_n: (value: any) => typeof value === 'number', repeat_penalty: (value: any) => typeof value === 'number', + min_p: (value: any) => typeof value === 'number', ctx_len: (value: any) => Number.isInteger(value) && value >= 0, ngl: (value: any) => Number.isInteger(value), From b49619b389d4008e9b72354d17a3d2e52bc6328b Mon Sep 17 00:00:00 2001 From: Louis Date: Fri, 27 Jun 2025 10:41:34 +0700 Subject: [PATCH 5/8] test: cover new changes --- core/src/browser/models/manager.test.ts | 133 +++++++++++++++++++++ core/src/browser/models/utils.test.ts | 152 ++++++++++++++++++++++-- core/src/browser/models/utils.ts | 2 +- 3 files changed, 277 insertions(+), 10 deletions(-) create mode 100644 core/src/browser/models/manager.test.ts diff --git a/core/src/browser/models/manager.test.ts b/core/src/browser/models/manager.test.ts new file mode 100644 index 000000000..189ca1209 --- /dev/null +++ b/core/src/browser/models/manager.test.ts @@ -0,0 +1,133 @@ +import { ModelManager } from './manager' +import { Model, ModelEvent } from '../../types' +import { events } from '../events' + +jest.mock('../events', () => ({ + events: { + emit: jest.fn(), + }, +})) + +Object.defineProperty(global, 'window', { + value: { + core: {}, + }, + writable: true, +}) + +describe('ModelManager', () => { + let modelManager: ModelManager + let mockModel: Model + + beforeEach(() => { + jest.clearAllMocks() + ;(global.window as any).core = {} + modelManager = new ModelManager() + mockModel = { + id: 'test-model-1', + name: 'Test Model', + version: '1.0.0', + } as Model + }) + + describe('constructor', () => { + it('should set itself on window.core.modelManager when window exists', () => { + expect((global.window as any).core.modelManager).toBe(modelManager) + }) + }) + + describe('register', () => { + it('should register a new model', () => { + modelManager.register(mockModel) + + expect(modelManager.models.has('test-model-1')).toBe(true) + expect(modelManager.models.get('test-model-1')).toEqual(mockModel) + expect(events.emit).toHaveBeenCalledWith(ModelEvent.OnModelsUpdate, {}) + }) + + it('should merge existing model with new model data', () => { + const existingModel: Model = { + id: 'test-model-1', + name: 'Existing Model', + description: 'Existing description', + } as Model + + const updatedModel: Model = { + id: 'test-model-1', + name: 'Updated Model', + version: '2.0.0', + } as Model + + modelManager.register(existingModel) + modelManager.register(updatedModel) + + const registeredModel = modelManager.models.get('test-model-1') + expect(registeredModel).toEqual({ + id: 'test-model-1', + name: 'Existing Model', + description: 'Existing description', + version: '2.0.0', + }) + expect(events.emit).toHaveBeenCalledTimes(2) + }) + }) + + describe('get', () => { + it('should retrieve a registered model by id', () => { + modelManager.register(mockModel) + + const retrievedModel = modelManager.get('test-model-1') + expect(retrievedModel).toEqual(mockModel) + }) + + it('should return undefined for non-existent model', () => { + const retrievedModel = modelManager.get('non-existent-model') + expect(retrievedModel).toBeUndefined() + }) + + it('should return correctly typed model', () => { + modelManager.register(mockModel) + + const retrievedModel = modelManager.get('test-model-1') + expect(retrievedModel?.id).toBe('test-model-1') + expect(retrievedModel?.name).toBe('Test Model') + }) + }) + + describe('instance', () => { + it('should create a new instance when none exists on window.core', () => { + ;(global.window as any).core = {} + + const instance = ModelManager.instance() + expect(instance).toBeInstanceOf(ModelManager) + expect((global.window as any).core.modelManager).toBe(instance) + }) + + it('should return existing instance when it exists on window.core', () => { + const existingManager = new ModelManager() + ;(global.window as any).core.modelManager = existingManager + + const instance = ModelManager.instance() + expect(instance).toBe(existingManager) + }) + }) + + describe('models property', () => { + it('should initialize with empty Map', () => { + expect(modelManager.models).toBeInstanceOf(Map) + expect(modelManager.models.size).toBe(0) + }) + + it('should maintain multiple models', () => { + const model1: Model = { id: 'model-1', name: 'Model 1' } as Model + const model2: Model = { id: 'model-2', name: 'Model 2' } as Model + + modelManager.register(model1) + modelManager.register(model2) + + expect(modelManager.models.size).toBe(2) + expect(modelManager.models.get('model-1')).toEqual(model1) + expect(modelManager.models.get('model-2')).toEqual(model2) + }) + }) +}) \ No newline at end of file diff --git a/core/src/browser/models/utils.test.ts b/core/src/browser/models/utils.test.ts index ac876c3dc..313ad0969 100644 --- a/core/src/browser/models/utils.test.ts +++ b/core/src/browser/models/utils.test.ts @@ -152,6 +152,33 @@ describe('validationRules', () => { expect(validationRules.text_model('true')).toBe(false) expect(validationRules.text_model(1)).toBe(false) }) + + it('should validate repeat_last_n correctly', () => { + expect(validationRules.repeat_last_n(5)).toBe(true) + expect(validationRules.repeat_last_n(-5)).toBe(true) + expect(validationRules.repeat_last_n(0)).toBe(true) + expect(validationRules.repeat_last_n(1.5)).toBe(true) + expect(validationRules.repeat_last_n('5')).toBe(false) + expect(validationRules.repeat_last_n(null)).toBe(false) + }) + + it('should validate repeat_penalty correctly', () => { + expect(validationRules.repeat_penalty(1.1)).toBe(true) + expect(validationRules.repeat_penalty(0.9)).toBe(true) + expect(validationRules.repeat_penalty(0)).toBe(true) + expect(validationRules.repeat_penalty(-1)).toBe(true) + expect(validationRules.repeat_penalty('1.1')).toBe(false) + expect(validationRules.repeat_penalty(null)).toBe(false) + }) + + it('should validate min_p correctly', () => { + expect(validationRules.min_p(0.1)).toBe(true) + expect(validationRules.min_p(0)).toBe(true) + expect(validationRules.min_p(-0.1)).toBe(true) + expect(validationRules.min_p(1.5)).toBe(true) + expect(validationRules.min_p('0.1')).toBe(false) + expect(validationRules.min_p(null)).toBe(false) + }) }) it('should normalize invalid values for keys not listed in validationRules', () => { @@ -192,18 +219,125 @@ describe('normalizeValue', () => { expect(normalizeValue('cpu_threads', '4')).toBe(4) expect(normalizeValue('cpu_threads', 0)).toBe(0) }) + + it('should handle edge cases for normalization', () => { + expect(normalizeValue('ctx_len', -5.7)).toBe(-6) + expect(normalizeValue('token_limit', 'abc')).toBeNaN() + expect(normalizeValue('max_tokens', null)).toBe(0) + expect(normalizeValue('ngl', undefined)).toBeNaN() + expect(normalizeValue('n_parallel', Infinity)).toBe(Infinity) + expect(normalizeValue('cpu_threads', -Infinity)).toBe(-Infinity) + }) + + it('should not normalize non-integer parameters', () => { + expect(normalizeValue('temperature', 1.5)).toBe(1.5) + expect(normalizeValue('top_p', 0.9)).toBe(0.9) + expect(normalizeValue('stream', true)).toBe(true) + expect(normalizeValue('prompt_template', 'template')).toBe('template') + }) }) -it('should handle invalid values correctly by falling back to originParams', () => { - const modelParams = { temperature: 'invalid', token_limit: -1 } - const originParams = { temperature: 0.5, token_limit: 100 } - expect(extractInferenceParams(modelParams as any, originParams)).toEqual(originParams) +describe('extractInferenceParams', () => { + it('should handle invalid values correctly by falling back to originParams', () => { + const modelParams = { temperature: 'invalid', token_limit: -1 } + const originParams = { temperature: 0.5, token_limit: 100 } + expect(extractInferenceParams(modelParams as any, originParams)).toEqual(originParams) + }) + + it('should return an empty object when no modelParams are provided', () => { + expect(extractInferenceParams()).toEqual({}) + }) + + it('should extract and normalize valid inference parameters', () => { + const modelParams = { + temperature: 1.5, + token_limit: 100.7, + top_p: 0.9, + stream: true, + max_tokens: 50.3, + invalid_param: 'should_be_ignored' + } + + const result = extractInferenceParams(modelParams as any) + expect(result).toEqual({ + temperature: 1.5, + token_limit: 100, + top_p: 0.9, + stream: true, + max_tokens: 50 + }) + }) + + it('should handle parameters without validation rules', () => { + const modelParams = { engine: 'llama' } + const result = extractInferenceParams(modelParams as any) + expect(result).toEqual({ engine: 'llama' }) + }) + + it('should skip invalid values when no origin params provided', () => { + const modelParams = { temperature: 'invalid', top_p: 0.8 } + const result = extractInferenceParams(modelParams as any) + expect(result).toEqual({ top_p: 0.8 }) + }) }) -it('should return an empty object when no modelParams are provided', () => { - expect(extractModelLoadParams()).toEqual({}) -}) +describe('extractModelLoadParams', () => { + it('should return an empty object when no modelParams are provided', () => { + expect(extractModelLoadParams()).toEqual({}) + }) -it('should return an empty object when no modelParams are provided', () => { - expect(extractInferenceParams()).toEqual({}) + it('should extract and normalize valid model load parameters', () => { + const modelParams = { + ctx_len: 2048.5, + ngl: 12.7, + embedding: true, + n_parallel: 4.2, + cpu_threads: 8.9, + prompt_template: 'template', + llama_model_path: '/path/to/model', + vision_model: false, + invalid_param: 'should_be_ignored' + } + + const result = extractModelLoadParams(modelParams as any) + expect(result).toEqual({ + ctx_len: 2048, + ngl: 12, + embedding: true, + n_parallel: 4, + cpu_threads: 8, + prompt_template: 'template', + llama_model_path: '/path/to/model', + vision_model: false + }) + }) + + it('should handle parameters without validation rules', () => { + const modelParams = { + engine: 'llama', + pre_prompt: 'System:', + system_prompt: 'You are helpful', + model_path: '/path' + } + const result = extractModelLoadParams(modelParams as any) + expect(result).toEqual({ + engine: 'llama', + pre_prompt: 'System:', + system_prompt: 'You are helpful', + model_path: '/path' + }) + }) + + it('should fall back to origin params for invalid values', () => { + const modelParams = { ctx_len: -1, ngl: 'invalid' } + const originParams = { ctx_len: 2048, ngl: 12 } + const result = extractModelLoadParams(modelParams as any, originParams) + expect(result).toEqual({}) + }) + + it('should skip invalid values when no origin params provided', () => { + const modelParams = { ctx_len: -1, embedding: true } + const result = extractModelLoadParams(modelParams as any) + expect(result).toEqual({ embedding: true }) + }) }) diff --git a/core/src/browser/models/utils.ts b/core/src/browser/models/utils.ts index f2db40241..d3fe0cb01 100644 --- a/core/src/browser/models/utils.ts +++ b/core/src/browser/models/utils.ts @@ -20,7 +20,7 @@ export const validationRules: { [key: string]: (value: any) => boolean } = { min_p: (value: any) => typeof value === 'number', ctx_len: (value: any) => Number.isInteger(value) && value >= 0, - ngl: (value: any) => Number.isInteger(value), + ngl: (value: any) => Number.isInteger(value) && value >= 0, embedding: (value: any) => typeof value === 'boolean', n_parallel: (value: any) => Number.isInteger(value) && value >= 0, cpu_threads: (value: any) => Number.isInteger(value) && value >= 0, From 50f3563a96cb2b571bcce150ebda75b7b3a4c48c Mon Sep 17 00:00:00 2001 From: DistractionRectangle <48034372+DistractionRectangle@users.noreply.github.com> Date: Fri, 27 Jun 2025 09:16:25 -0700 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9Bfix:=20Tauri=20AppImage=20faili?= =?UTF-8?q?ng=20to=20render=20on=20wayland=20+=20mesa=20(#5463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/template-tauri-build-linux-x64.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/template-tauri-build-linux-x64.yml b/.github/workflows/template-tauri-build-linux-x64.yml index 9356c3f28..14cf6d6b8 100644 --- a/.github/workflows/template-tauri-build-linux-x64.yml +++ b/.github/workflows/template-tauri-build-linux-x64.yml @@ -151,6 +151,12 @@ jobs: fi - name: Build app run: | + # Pin linuxdeploy version to prevent @tauri-apps/cli-linux-x64-gnu from pulling in an outdated version + TAURI_TOOLKIT_PATH="${XDG_CACHE_HOME:-$HOME/.cache}/tauri" + mkdir -p "$TAURI_TOOLKIT_PATH" + wget https://github.com/linuxdeploy/linuxdeploy/releases/download/1-alpha-20250213-2/linuxdeploy-x86_64.AppImage -O "$TAURI_TOOLKIT_PATH/linuxdeploy-x86_64.AppImage" + chmod +x "$TAURI_TOOLKIT_PATH/linuxdeploy-x86_64.AppImage" + make build-tauri # Copy engines and bun to appimage wget https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage -O ./appimagetool From 5918c9cd6fcbaf90817ab681f1efb19889f2143b Mon Sep 17 00:00:00 2001 From: Sam Hoang Van Date: Mon, 30 Jun 2025 10:56:23 +0700 Subject: [PATCH 7/8] fix: custom based url and header by upgrade token.js version (#5596) --- web-app/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web-app/package.json b/web-app/package.json index 0272d1151..171966560 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -66,7 +66,7 @@ "remark-math": "^6.0.0", "sonner": "^2.0.3", "tailwindcss": "^4.1.4", - "token.js": "npm:token.js-fork@0.7.9", + "token.js": "npm:token.js-fork@0.7.12", "tw-animate-css": "^1.2.7", "ulidx": "^2.4.1", "unified": "^11.0.5", From 662879bb5dbef0d4ae662bebbce33a8e4f8561aa Mon Sep 17 00:00:00 2001 From: Faisal Amir Date: Tue, 1 Jul 2025 09:44:32 +0700 Subject: [PATCH 8/8] =?UTF-8?q?=E2=9C=A8enhancement:=20setting=20responsiv?= =?UTF-8?q?e=20(#5615)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨feat: setting responsive * 🧹cleanup: feeback PR * 🧹cleanup: unused className * 🧹cleanup: unused props --- web-app/src/constants/routes.ts | 1 + web-app/src/containers/Card.tsx | 58 +++-- web-app/src/containers/ChatWidthSwitcher.tsx | 26 ++- web-app/src/containers/ProvidersMenu.tsx | 152 ------------- web-app/src/containers/SettingsMenu.tsx | 204 +++++++++++++----- web-app/src/routeTree.gen.ts | 28 ++- web-app/src/routes/settings/appearance.tsx | 7 +- web-app/src/routes/settings/general.tsx | 21 +- .../src/routes/settings/local-api-server.tsx | 4 + web-app/src/routes/settings/mcp-servers.tsx | 2 +- .../settings/providers/$providerName.tsx | 22 +- .../src/routes/settings/providers/index.tsx | 187 ++++++++++++++++ 12 files changed, 456 insertions(+), 256 deletions(-) delete mode 100644 web-app/src/containers/ProvidersMenu.tsx create mode 100644 web-app/src/routes/settings/providers/index.tsx diff --git a/web-app/src/constants/routes.ts b/web-app/src/constants/routes.ts index db741e9e6..e9997590a 100644 --- a/web-app/src/constants/routes.ts +++ b/web-app/src/constants/routes.ts @@ -5,6 +5,7 @@ export const route = { assistant: '/assistant', settings: { index: '/settings', + model_providers: '/settings/providers', providers: '/settings/providers/$providerName', general: '/settings/general', appearance: '/settings/appearance', diff --git a/web-app/src/containers/Card.tsx b/web-app/src/containers/Card.tsx index 324b98b2d..7d68550db 100644 --- a/web-app/src/containers/Card.tsx +++ b/web-app/src/containers/Card.tsx @@ -10,43 +10,63 @@ type CardProps = { type CardItemProps = { title?: string | ReactNode description?: string | ReactNode + descriptionOutside?: string | ReactNode align?: 'start' | 'center' | 'end' actions?: ReactNode column?: boolean className?: string + classNameWrapperAction?: string } export function CardItem({ title, description, + descriptionOutside, className, + classNameWrapperAction, align = 'center', column, actions, }: CardItemProps) { return ( -
-
-

{title}

- {description && ( - - {description} - + <> +
+
+

{title}

+ {description && ( + + {description} + + )} +
+ {actions && ( +
+ {actions} +
)}
- {actions && ( -
{actions}
+ {descriptionOutside && ( + + {descriptionOutside} + )} -
+ ) } diff --git a/web-app/src/containers/ChatWidthSwitcher.tsx b/web-app/src/containers/ChatWidthSwitcher.tsx index 27cc3c69d..10417200e 100644 --- a/web-app/src/containers/ChatWidthSwitcher.tsx +++ b/web-app/src/containers/ChatWidthSwitcher.tsx @@ -9,27 +9,31 @@ export function ChatWidthSwitcher() { const { t } = useTranslation() return ( -
+
- - - - - - - -
-
-
- ) -} - -export default ProvidersMenu diff --git a/web-app/src/containers/SettingsMenu.tsx b/web-app/src/containers/SettingsMenu.tsx index 5d5e3ef5b..8aea3b501 100644 --- a/web-app/src/containers/SettingsMenu.tsx +++ b/web-app/src/containers/SettingsMenu.tsx @@ -1,20 +1,56 @@ -import { Link, useMatches } from '@tanstack/react-router' +import { Link } from '@tanstack/react-router' import { route } from '@/constants/routes' import { useTranslation } from '@/i18n/react-i18next-compat' -import { useModelProvider } from '@/hooks/useModelProvider' +import { useState, useEffect } from 'react' +import { + IconChevronDown, + IconChevronRight, + IconMenu2, + IconX, +} from '@tabler/icons-react' +import { useMatches, useNavigate } from '@tanstack/react-router' +import { cn } from '@/lib/utils' + import { useGeneralSetting } from '@/hooks/useGeneralSetting' +import { useModelProvider } from '@/hooks/useModelProvider' +import { getProviderTitle } from '@/lib/utils' +import ProvidersAvatar from '@/containers/ProvidersAvatar' const SettingsMenu = () => { const { t } = useTranslation() - const { providers } = useModelProvider() - const { experimentalFeatures } = useGeneralSetting() - const firstItemProvider = - providers.length > 0 ? providers[0].provider : 'llama.cpp' + const [expandedProviders, setExpandedProviders] = useState(false) + const [isMenuOpen, setIsMenuOpen] = useState(false) const matches = useMatches() - const isActive = matches.some( + const navigate = useNavigate() + + const { experimentalFeatures } = useGeneralSetting() + const { providers } = useModelProvider() + + // Filter providers that have active API keys (or are llama.cpp which doesn't need one) + const activeProviders = providers.filter((provider) => provider.active) + + // Check if current route has a providerName parameter and expand providers submenu + useEffect(() => { + const hasProviderName = matches.some( + (match) => + match.routeId === '/settings/providers/$providerName' && + 'providerName' in match.params + ) + const isProvidersRoute = matches.some( + (match) => match.routeId === '/settings/providers/' + ) + if (hasProviderName || isProvidersRoute) { + setExpandedProviders(true) + } + }, [matches]) + + // Check if we're in the setup remote provider step + const stepSetupRemoteProvider = matches.some( (match) => - match.routeId === '/settings/providers/$providerName' && - 'providerName' in match.params + match.search && + typeof match.search === 'object' && + 'step' in match.search && + match.search.step === 'setup_remote_provider' ) const menuSettings = [ @@ -30,6 +66,11 @@ const SettingsMenu = () => { title: 'common:privacy', route: route.settings.privacy, }, + { + title: 'common:modelProviders', + route: route.settings.model_providers, + hasSubMenu: activeProviders.length > 0, + }, { title: 'common:keyboardShortcuts', route: route.settings.shortcuts, @@ -61,52 +102,113 @@ const SettingsMenu = () => { }, ] + const toggleProvidersExpansion = () => { + setExpandedProviders(!expandedProviders) + } + + const toggleMenu = () => { + setIsMenuOpen(!isMenuOpen) + } + return ( -
-
- {menuSettings.map((menu, index) => { - // Render the menu item - const menuItem = ( - - {t(menu.title)} - - ) + <> + +
+
+ {menuSettings.map((menu) => ( +
+ +
+ {t(menu.title)} + {menu.hasSubMenu && ( + + )} +
+ - if (index === 2) { - return ( -
- {menuItem} + {/* Sub-menu for model providers */} + {menu.hasSubMenu && expandedProviders && ( +
+ {activeProviders.map((provider) => { + const isActive = matches.some( + (match) => + match.routeId === '/settings/providers/$providerName' && + 'providerName' in match.params && + match.params.providerName === provider.provider + ) - {/* Model Providers Link with default parameter */} - {isActive ? ( -
- {t('common:modelProviders')} -
- ) : ( - - - {t('common:modelProviders')} - - - )} -
- ) - } - - // For other menu items, just render them normally - return menuItem - })} + return ( +
+ +
+ ) + })} +
+ )} +
+ ))} +
-
+ ) } diff --git a/web-app/src/routeTree.gen.ts b/web-app/src/routeTree.gen.ts index 52782cb2e..bbd3db391 100644 --- a/web-app/src/routeTree.gen.ts +++ b/web-app/src/routeTree.gen.ts @@ -27,6 +27,7 @@ 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 LocalApiServerLogsImport } from './routes/local-api-server/logs' +import { Route as SettingsProvidersIndexImport } from './routes/settings/providers/index' import { Route as SettingsProvidersProviderNameImport } from './routes/settings/providers/$providerName' // Create/Update Routes @@ -127,6 +128,12 @@ const LocalApiServerLogsRoute = LocalApiServerLogsImport.update({ getParentRoute: () => rootRoute, } as any) +const SettingsProvidersIndexRoute = SettingsProvidersIndexImport.update({ + id: '/settings/providers/', + path: '/settings/providers/', + getParentRoute: () => rootRoute, +} as any) + const SettingsProvidersProviderNameRoute = SettingsProvidersProviderNameImport.update({ id: '/settings/providers/$providerName', @@ -257,6 +264,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersProviderNameImport parentRoute: typeof rootRoute } + '/settings/providers/': { + id: '/settings/providers/' + path: '/settings/providers' + fullPath: '/settings/providers' + preLoaderRoute: typeof SettingsProvidersIndexImport + parentRoute: typeof rootRoute + } } } @@ -280,6 +294,7 @@ export interface FileRoutesByFullPath { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute + '/settings/providers': typeof SettingsProvidersIndexRoute } export interface FileRoutesByTo { @@ -300,6 +315,7 @@ export interface FileRoutesByTo { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute + '/settings/providers': typeof SettingsProvidersIndexRoute } export interface FileRoutesById { @@ -321,6 +337,7 @@ export interface FileRoutesById { '/settings/shortcuts': typeof SettingsShortcutsRoute '/threads/$threadId': typeof ThreadsThreadIdRoute '/settings/providers/$providerName': typeof SettingsProvidersProviderNameRoute + '/settings/providers/': typeof SettingsProvidersIndexRoute } export interface FileRouteTypes { @@ -343,6 +360,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/settings/providers/$providerName' + | '/settings/providers' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -362,6 +380,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/settings/providers/$providerName' + | '/settings/providers' id: | '__root__' | '/' @@ -381,6 +400,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/threads/$threadId' | '/settings/providers/$providerName' + | '/settings/providers/' fileRoutesById: FileRoutesById } @@ -402,6 +422,7 @@ export interface RootRouteChildren { SettingsShortcutsRoute: typeof SettingsShortcutsRoute ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute SettingsProvidersProviderNameRoute: typeof SettingsProvidersProviderNameRoute + SettingsProvidersIndexRoute: typeof SettingsProvidersIndexRoute } const rootRouteChildren: RootRouteChildren = { @@ -422,6 +443,7 @@ const rootRouteChildren: RootRouteChildren = { SettingsShortcutsRoute: SettingsShortcutsRoute, ThreadsThreadIdRoute: ThreadsThreadIdRoute, SettingsProvidersProviderNameRoute: SettingsProvidersProviderNameRoute, + SettingsProvidersIndexRoute: SettingsProvidersIndexRoute, } export const routeTree = rootRoute @@ -450,7 +472,8 @@ export const routeTree = rootRoute "/settings/privacy", "/settings/shortcuts", "/threads/$threadId", - "/settings/providers/$providerName" + "/settings/providers/$providerName", + "/settings/providers/" ] }, "/": { @@ -503,6 +526,9 @@ export const routeTree = rootRoute }, "/settings/providers/$providerName": { "filePath": "settings/providers/$providerName.tsx" + }, + "/settings/providers/": { + "filePath": "settings/providers/index.tsx" } } } diff --git a/web-app/src/routes/settings/appearance.tsx b/web-app/src/routes/settings/appearance.tsx index df0da98fa..3cba3eed5 100644 --- a/web-app/src/routes/settings/appearance.tsx +++ b/web-app/src/routes/settings/appearance.tsx @@ -35,7 +35,7 @@ function Appareances() {

{t('common:settings')}

-
+
@@ -55,26 +55,31 @@ function Appareances() { } /> } /> } /> } /> } />

{t('common:settings')}

-
+
@@ -222,6 +222,7 @@ function General() { @@ -273,13 +275,15 @@ function General() { })}   -
- - {janDataFolder} - +
+
+ + {janDataFolder} + +

} - description={ + descriptionOutside={
{t('mcp-servers:command')}: {config.command} diff --git a/web-app/src/routes/settings/providers/$providerName.tsx b/web-app/src/routes/settings/providers/$providerName.tsx index 60254cf65..d15260908 100644 --- a/web-app/src/routes/settings/providers/$providerName.tsx +++ b/web-app/src/routes/settings/providers/$providerName.tsx @@ -1,9 +1,8 @@ import { Card, CardItem } from '@/containers/Card' import HeaderPage from '@/containers/HeaderPage' -import ProvidersMenu from '@/containers/ProvidersMenu' +import SettingsMenu from '@/containers/SettingsMenu' import { useModelProvider } from '@/hooks/useModelProvider' import { cn, getProviderTitle } from '@/lib/utils' -import { Switch } from '@/components/ui/switch' import { open } from '@tauri-apps/plugin-dialog' import { getActiveModels, @@ -228,23 +227,13 @@ function ProviderDetail() {

{t('common:settings')}

-
- -
+

{getProviderTitle(providerName)}

- { - if (provider) { - updateProvider(providerName, { ...provider, active: e }) - } - }} - />
-

{model.id}

+

+ {model.id} +

} diff --git a/web-app/src/routes/settings/providers/index.tsx b/web-app/src/routes/settings/providers/index.tsx new file mode 100644 index 000000000..94c01865b --- /dev/null +++ b/web-app/src/routes/settings/providers/index.tsx @@ -0,0 +1,187 @@ +import { createFileRoute } from '@tanstack/react-router' +import { route } from '@/constants/routes' +import SettingsMenu from '@/containers/SettingsMenu' +import HeaderPage from '@/containers/HeaderPage' +import { Button } from '@/components/ui/button' +import { Card, CardItem } from '@/containers/Card' +import { useTranslation } from '@/i18n/react-i18next-compat' +import { useModelProvider } from '@/hooks/useModelProvider' +import { useNavigate } from '@tanstack/react-router' +import { IconCirclePlus, IconSettings } from '@tabler/icons-react' +import { getProviderTitle } from '@/lib/utils' +import ProvidersAvatar from '@/containers/ProvidersAvatar' +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' +import { useCallback, useState } from 'react' +import { openAIProviderSettings } from '@/mock/data' +import cloneDeep from 'lodash/cloneDeep' +import { toast } from 'sonner' + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Route = createFileRoute(route.settings.model_providers as any)({ + component: ModelProviders, +}) + +function ModelProviders() { + const { t } = useTranslation() + const { providers, addProvider, updateProvider } = useModelProvider() + const navigate = useNavigate() + const [name, setName] = useState('') + + const createProvider = useCallback(() => { + if (providers.some((e) => e.provider === name)) { + toast.error(t('providerAlreadyExists', { name })) + return + } + const newProvider = { + provider: name, + active: true, + models: [], + settings: cloneDeep(openAIProviderSettings) as ProviderSetting[], + api_key: '', + base_url: 'https://api.openai.com/v1', + } + addProvider(newProvider) + setTimeout(() => { + navigate({ + to: route.settings.providers, + params: { + providerName: name, + }, + }) + }, 0) + }, [providers, name, addProvider, t, navigate]) + + return ( +
+ +

{t('common:settings')}

+
+
+ +
+
+ {/* Model Providers */} + + + {t('common:modelProviders')} + + + + + + + + + {t('provider:addOpenAIProvider')} + + setName(e.target.value)} + className="mt-2" + placeholder={t('provider:enterNameForProvider')} + onKeyDown={(e) => { + // Prevent key from being captured by parent components + e.stopPropagation() + }} + /> + + + + + + + + + + + +
+ } + > + {providers.map((provider, index) => ( + + +
+

+ {getProviderTitle(provider.provider)} +

+

+ {provider.models.length} Models +

+
+
+ } + actions={ +
+ {provider.active && ( + + )} + { + updateProvider(provider.provider, { + ...provider, + active: e, + }) + }} + /> +
+ } + /> + ))} + +
+
+
+
+ ) +}