From 66e0cf67d43a07d4d49624f67c295f4de61b48d6 Mon Sep 17 00:00:00 2001 From: apensotti Date: Sun, 2 Nov 2025 18:12:57 +0000 Subject: [PATCH 01/24] feat: enhance Drive layout with mobile navigation and responsive design adjustments --- app/(main)/drive/layout.tsx | 8 +- app/(main)/drive/page.tsx | 23 ++- components/drive/DriveBottomNav.tsx | 40 +++++ components/drive/DriveMobileHeader.tsx | 18 +++ components/drive/FilesResultsTable.tsx | 50 +++--- components/drive/FilesResultsTableMobile.tsx | 158 +++++++++++++++++++ components/drive/FilesSearchBar.tsx | 4 +- 7 files changed, 275 insertions(+), 26 deletions(-) create mode 100644 components/drive/DriveBottomNav.tsx create mode 100644 components/drive/DriveMobileHeader.tsx create mode 100644 components/drive/FilesResultsTableMobile.tsx diff --git a/app/(main)/drive/layout.tsx b/app/(main)/drive/layout.tsx index bd43cec..927289d 100644 --- a/app/(main)/drive/layout.tsx +++ b/app/(main)/drive/layout.tsx @@ -2,6 +2,7 @@ import { FilesLeftSidebar } from "@/components/drive/FilesLeftSidebar" import { auth } from "@/lib/auth" import { redirect } from "next/navigation" import { findLocalRootFolderId, getGoogleRootFolderId } from "@/lib/modules/drive" +import { DriveBottomNav } from "@/components/drive/DriveBottomNav" export default async function DriveLayout({ children }: { children: React.ReactNode }) { const session = await auth() @@ -15,10 +16,13 @@ export default async function DriveLayout({ children }: { children: React.ReactN return (
- -
+
+ +
+
{children}
+
) } diff --git a/app/(main)/drive/page.tsx b/app/(main)/drive/page.tsx index 160a377..33983c7 100644 --- a/app/(main)/drive/page.tsx +++ b/app/(main)/drive/page.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { getRootFolderId, listFoldersByParent, listFilesByParent, getFolderBreadcrumb, isGoogleDriveFolder } from "@/lib/modules/drive"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; +import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; +import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; interface FilesPageProps { searchParams?: Promise<{ parentId?: string | string[] }> @@ -29,10 +31,23 @@ export default async function FilesPage({ searchParams }: FilesPageProps) { const entries = [...folders, ...files] return ( -
- - -
+ <> + {/* Mobile header: fixed search + filters */} + + {/* Spacer to offset the fixed mobile header height */} +
+ + {/* Mobile results list (full-width, scrolls under header) */} +
+ +
+ + {/* Desktop layout */} +
+ + +
+ ); } diff --git a/components/drive/DriveBottomNav.tsx b/components/drive/DriveBottomNav.tsx new file mode 100644 index 0000000..7f4741f --- /dev/null +++ b/components/drive/DriveBottomNav.tsx @@ -0,0 +1,40 @@ +"use client" +import Link from "next/link" +import { usePathname } from "next/navigation" +import { Home, Star, Users, Folder } from "lucide-react" +import { cn } from "@/lib/utils" + +interface DriveBottomNavProps { + localRootId: string +} + +export function DriveBottomNav({ localRootId }: DriveBottomNavProps) { + const pathname = usePathname() + + const isHome = pathname === "/drive" + const isStarred = pathname?.startsWith("/drive/starred") + const isShared = pathname?.startsWith("/drive/shared") + const isFiles = pathname?.startsWith("/drive/folder") + + return ( + + ) +} + +function NavItem({ href, icon, label, active }: { href: string; icon: React.ReactNode; label: string; active: boolean }) { + return ( + + {icon} + {label} + + ) +} + + diff --git a/components/drive/DriveMobileHeader.tsx b/components/drive/DriveMobileHeader.tsx new file mode 100644 index 0000000..7a6acf1 --- /dev/null +++ b/components/drive/DriveMobileHeader.tsx @@ -0,0 +1,18 @@ +"use client" +import { FilesSearchBar } from "@/components/drive/FilesSearchBar" +import { FiltersBar } from "@/components/drive/FiltersBar" + +export function DriveMobileHeader() { + return ( +
+
+ +
+
+ +
+
+ ) +} + + diff --git a/components/drive/FilesResultsTable.tsx b/components/drive/FilesResultsTable.tsx index 3ecade9..707e13c 100644 --- a/components/drive/FilesResultsTable.tsx +++ b/components/drive/FilesResultsTable.tsx @@ -577,9 +577,9 @@ export function FilesResultsTable({ Name - Owner - Last Modified - Location + Owner + Last Modified + Location @@ -881,21 +881,35 @@ function RowItem({ {...listeners} {...attributes} > - - {item.isDirectory ? ( - - ) : ( - getIconForFile(item.name, item) - )} - {item.name} - {!item.isDirectory && item.ownedByMe === false && ( - - - - )} + +
+ {item.isDirectory ? ( + + ) : ( + getIconForFile(item.name, item) + )} + {item.name} + {!item.isDirectory && item.ownedByMe === false && ( + + + + )} +
+
+ {new Date(item.modifiedMs).toLocaleString("en-US", { + timeZone: "UTC", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", + hour12: true, + })} +
- You - + You + {new Date(item.modifiedMs).toLocaleString("en-US", { timeZone: "UTC", year: "numeric", @@ -907,7 +921,7 @@ function RowItem({ hour12: true, })} - + {parentName ? `/${parentName}` : "/"} diff --git a/components/drive/FilesResultsTableMobile.tsx b/components/drive/FilesResultsTableMobile.tsx new file mode 100644 index 0000000..abe2e10 --- /dev/null +++ b/components/drive/FilesResultsTableMobile.tsx @@ -0,0 +1,158 @@ +"use client" +import type { FileEntry } from "@/lib/modules/drive" +import { useRouter } from "next/navigation" +import { useMemo, useState, useCallback } from "react" +import { Users, Download, Pencil } from "lucide-react" +import { FaStar, FaRegStar, FaFolder, FaFilePdf, FaImage } from "react-icons/fa" +import { BsFileEarmarkSpreadsheetFill } from "react-icons/bs" +import { Table2 } from "lucide-react" +import { LuSquareMenu } from "react-icons/lu" +import { Button } from "@/components/ui/button" +import dynamic from "next/dynamic" +import { + setFileStarred, + setFolderStarred, +} from "@/lib/api/drive" + +const PreviewDialog = dynamic(() => import("./PreviewDialog")) + +interface FilesResultsTableMobileProps { + entries: FileEntry[] + parentName?: string +} + +function getIconForFile(name: string, item?: FileEntry) { + if (item && (item as any).meta) { + const meta = (item as any).meta as any + if (meta.mimeType) { + if (meta.mimeType === "application/vnd.google-apps.document") { + return + } + if (meta.mimeType === "application/vnd.google-apps.spreadsheet") { + return + } + } + } + + const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "" + if (["jpg","jpeg","png","gif","webp","svg","bmp","tiff","tif","heic","heif","avif"].includes(ext)) { + return + } + if (["mp4","webm","ogg","ogv","mov","m4v","mkv"].includes(ext)) { + return + } + if (ext === "pdf") { + return + } + if (["xls","xlsx","xlsm","csv","tsv","ods","numbers"].includes(ext)) { + return + } + return +} + +function isImageName(name: string) { + const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "" + return ["jpg","jpeg","png","gif","webp","svg","bmp","tiff","tif","heic","heif","avif"].includes(ext) +} + +function getFileUrl(item: FileEntry): string { + const isGoogle = item.path === item.name && !item.path.includes("/") + if (isGoogle) return `/api/v1/drive/file/${encodeURIComponent(item.id)}` + + let rel = item.path || item.name + if (rel.startsWith("/data/files/")) rel = rel.slice("/data/files/".length) + else if (rel.startsWith("data/files/")) rel = rel.slice("data/files/".length) + return isImageName(item.name) + ? `/images/${encodeURIComponent(item.name)}` + : `/files/${rel.split("/").map(encodeURIComponent).join("/")}` +} + +export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTableMobileProps) { + const router = useRouter() + const [preview, setPreview] = useState<{ name: string; url: string; fileId?: string; mimeType?: string } | null>(null) + const idToItem = useMemo(() => { + const m = new Map() + for (const e of entries) m.set(e.id, e) + return m + }, [entries]) + + const onOpen = useCallback((item: FileEntry) => { + if (item.isDirectory) { + router.push(`/drive/folder/${encodeURIComponent(item.path)}`) + return + } + const url = getFileUrl(item) + const meta = (item as any).meta as any + setPreview({ name: item.name, url, mimeType: meta?.mimeType, fileId: item.id }) + }, [router]) + + return ( +
+
    + {entries.map((item) => ( +
  • +
    + {item.isDirectory ? : getIconForFile(item.name, item)} +
    + +
    + {!item.isDirectory && ( + + )} + + {!item.isDirectory && ( + + )} +
    +
  • + ))} +
+ { if (!next) setPreview(null) }} + name={preview?.name ?? ""} + url={preview?.url ?? ""} + mimeType={preview?.mimeType} + fileId={preview?.fileId} + /> +
+ ) +} + + diff --git a/components/drive/FilesSearchBar.tsx b/components/drive/FilesSearchBar.tsx index 39cf635..6fa8c11 100644 --- a/components/drive/FilesSearchBar.tsx +++ b/components/drive/FilesSearchBar.tsx @@ -22,8 +22,8 @@ export function FilesSearchBar() { }, [params, router]) return ( -
-
+
+
Date: Sun, 2 Nov 2025 18:36:01 +0000 Subject: [PATCH 02/24] feat: implement video streaming support and error handling in VideoPreviewer component --- app/files/[...path]/route.ts | 50 +++++++++++++++++++--- components/drive/VideoPreviewer.tsx | 66 +++++++++++++++++++++-------- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/app/files/[...path]/route.ts b/app/files/[...path]/route.ts index 7583a8e..844947e 100644 --- a/app/files/[...path]/route.ts +++ b/app/files/[...path]/route.ts @@ -1,4 +1,5 @@ import { readFile, stat } from 'fs/promises' +import { createReadStream } from 'fs' import path from 'path' import { LOCAL_BASE_DIR } from '@/lib/modules/drive/providers/local.service' @@ -20,6 +21,7 @@ function resolveSafePath(segments: string[]): string | null { // Next.js dynamic API params are async; await them before use export async function GET(_req: Request, context: { params: Promise<{ path?: string[] }> }) { + const req = _req const { path: rawSegments } = await context.params if (!rawSegments || rawSegments.length === 0) { return new Response('Not Found', { status: 404 }) @@ -48,7 +50,6 @@ export async function GET(_req: Request, context: { params: Promise<{ path?: str try { const s = await stat(cand) if (!s.isFile()) continue - const data = await readFile(cand) const ext = cand.toLowerCase().split('.').pop() || '' const type = ext === 'png' ? 'image/png' : @@ -63,13 +64,52 @@ export async function GET(_req: Request, context: { params: Promise<{ path?: str ext === 'avif' ? 'image/avif' : ext === 'pdf' ? 'application/pdf' : ext === 'txt' ? 'text/plain; charset=utf-8' : + ext === 'mp4' || ext === 'm4v' ? 'video/mp4' : + ext === 'mov' ? 'video/quicktime' : + ext === 'webm' ? 'video/webm' : + ext === 'ogv' || ext === 'ogg' ? 'video/ogg' : + ext === 'mkv' ? 'video/x-matroska' : 'application/octet-stream' - return new Response(new Uint8Array(data), { + + const range = req.headers.get('range') + const fileSize = s.size + const commonHeaders: Record = { + 'content-type': type, + 'accept-ranges': 'bytes', + 'cache-control': 'no-store', + } + + if (range) { + // Parse Range: bytes=start-end + const match = /bytes=(\d+)-(\d+)?/.exec(range) + if (!match) { + return new Response('Invalid Range', { status: 416 }) + } + const start = parseInt(match[1]!, 10) + const end = match[2] ? Math.min(parseInt(match[2]!, 10), fileSize - 1) : Math.min(start + 1024 * 1024 * 4 - 1, fileSize - 1) // 4MB default chunk + if (isNaN(start) || isNaN(end) || start > end || start >= fileSize) { + return new Response('Invalid Range', { status: 416 }) + } + const chunkSize = end - start + 1 + const stream = createReadStream(cand, { start, end }) + return new Response(stream as any, { + status: 206, + headers: { + ...commonHeaders, + 'content-length': String(chunkSize), + 'content-range': `bytes ${start}-${end}/${fileSize}`, + } + }) + } + + // No range: stream entire file + const fullStream = createReadStream(cand) + return new Response(fullStream as any, { status: 200, headers: { - 'content-type': type, - 'cache-control': 'no-store', - ...(ext === 'pdf' ? { 'content-disposition': `inline; filename="${last}"` } : {}), + ...commonHeaders, + 'content-length': String(fileSize), + ...(ext === 'pdf' ? { 'content-disposition': `inline; filename="${last}"` } : {}), } }) } catch { diff --git a/components/drive/VideoPreviewer.tsx b/components/drive/VideoPreviewer.tsx index 2887b1a..a0cd607 100644 --- a/components/drive/VideoPreviewer.tsx +++ b/components/drive/VideoPreviewer.tsx @@ -1,5 +1,5 @@ "use client" -import { useRef, useEffect } from "react" +import { useRef, useEffect, useMemo, useState } from "react" interface VideoPreviewerProps { url: string @@ -8,6 +8,7 @@ interface VideoPreviewerProps { export default function VideoPreviewer({ url, name }: VideoPreviewerProps) { const videoRef = useRef(null) + const [errored, setErrored] = useState(false) useEffect(() => { const el = videoRef.current @@ -15,30 +16,59 @@ export default function VideoPreviewer({ url, name }: VideoPreviewerProps) { // Attempt to reset playback when URL changes el.pause() el.load() + setErrored(false) }, [url]) // Derive MIME type from extension (best-effort) const lower = name.toLowerCase() - const type = lower.endsWith('.mp4') ? 'video/mp4' - : lower.endsWith('.webm') ? 'video/webm' - : lower.endsWith('.ogg') || lower.endsWith('.ogv') ? 'video/ogg' - : lower.endsWith('.mov') || lower.endsWith('.m4v') ? 'video/mp4' - : lower.endsWith('.mkv') ? 'video/webm' - : undefined + const type = useMemo(() => { + if (lower.endsWith('.mp4') || lower.endsWith('.m4v')) return 'video/mp4' + if (lower.endsWith('.mov')) return 'video/quicktime' + if (lower.endsWith('.webm')) return 'video/webm' + if (lower.endsWith('.ogg') || lower.endsWith('.ogv')) return 'video/ogg' + // Avoid forcing incorrect types (e.g., mkv) + return undefined + }, [lower]) + + // Basic support detection; iOS Safari is picky about codecs/containers + const isProbablySupported = useMemo(() => { + if (typeof document === 'undefined') return true + if (!type) return true + const test = document.createElement('video') + const res = test.canPlayType(type) + return res === 'probably' || res === 'maybe' + }, [type]) return (
- + {(!isProbablySupported || errored) ? ( +
+

This video format may not be supported on your device.

+ + Open video in a new tab + +
+ ) : ( + + )}
) } From 0d5b03b72777925da57823507107cd16f9bb3db0 Mon Sep 17 00:00:00 2001 From: apensotti Date: Sun, 2 Nov 2025 18:47:21 +0000 Subject: [PATCH 03/24] feat: add rename functionality and optimistic UI updates for file starring in FilesResultsTableMobile --- components/drive/FilesResultsTableMobile.tsx | 27 +++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/components/drive/FilesResultsTableMobile.tsx b/components/drive/FilesResultsTableMobile.tsx index abe2e10..f3434b3 100644 --- a/components/drive/FilesResultsTableMobile.tsx +++ b/components/drive/FilesResultsTableMobile.tsx @@ -13,6 +13,7 @@ import { setFileStarred, setFolderStarred, } from "@/lib/api/drive" +import { RenameItemDialog } from "./RenameItemDialog" const PreviewDialog = dynamic(() => import("./PreviewDialog")) @@ -70,6 +71,8 @@ function getFileUrl(item: FileEntry): string { export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTableMobileProps) { const router = useRouter() const [preview, setPreview] = useState<{ name: string; url: string; fileId?: string; mimeType?: string } | null>(null) + const [renameFileId, setRenameFileId] = useState(null) + const [optimisticStars, setOptimisticStars] = useState>({}) const idToItem = useMemo(() => { const m = new Map() for (const e of entries) m.set(e.id, e) @@ -107,7 +110,7 @@ export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTab
{!item.isDirectory && ( - )} @@ -116,15 +119,19 @@ export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTab size="icon" aria-label="Star" onClick={async () => { - const current = Boolean((item as any).starred) + const current = (optimisticStars[item.id] ?? Boolean((item as any).starred)) const next = !current try { + setOptimisticStars((prev) => ({ ...prev, [item.id]: next })) if (item.isDirectory) await setFolderStarred({ id: item.id, starred: next }) else await setFileStarred({ id: item.id, starred: next }) - } catch {} + router.refresh() + } catch { + setOptimisticStars((prev) => ({ ...prev, [item.id]: current })) + } }} > - {Boolean((item as any).starred) ? : } + {(optimisticStars[item.id] ?? Boolean((item as any).starred)) ? : } {!item.isDirectory && (
) } From 1bb18fd4331027667ac04bbaac89e8f241c6681c Mon Sep 17 00:00:00 2001 From: apensotti Date: Sun, 2 Nov 2025 18:50:54 +0000 Subject: [PATCH 04/24] feat: implement mobile layout for folder, starred, trash pages with responsive header and results table --- app/(main)/drive/folder/[folderId]/page.tsx | 21 ++++++++++++++++--- app/(main)/drive/starred/page.tsx | 23 +++++++++++++++++---- app/(main)/drive/trash/page.tsx | 23 +++++++++++++++++---- 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/app/(main)/drive/folder/[folderId]/page.tsx b/app/(main)/drive/folder/[folderId]/page.tsx index 18e20c9..8e8eb23 100644 --- a/app/(main)/drive/folder/[folderId]/page.tsx +++ b/app/(main)/drive/folder/[folderId]/page.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { listFoldersByParent, listFilesByParent, getFolderNameById, getFolderBreadcrumb, isGoogleDriveFolder } from "@/lib/modules/drive"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; +import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; +import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; interface PageProps { params: Promise<{ folderId: string }> @@ -23,8 +25,20 @@ export default async function FolderPage({ params }: PageProps) { const entries = [...folders, ...files] return ( -
- + <> + {/* Mobile header: fixed search + filters */} + + {/* Spacer to offset the fixed mobile header height */} +
+ + {/* Mobile results list (full-width, scrolls under header) */} +
+ +
+ + {/* Desktop layout */} +
+ -
+
+ ); } diff --git a/app/(main)/drive/starred/page.tsx b/app/(main)/drive/starred/page.tsx index 942ce8a..631cb07 100644 --- a/app/(main)/drive/starred/page.tsx +++ b/app/(main)/drive/starred/page.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { listStarredEntries } from "@/lib/modules/drive"; +import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; +import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; export default async function StarredPage() { const session = await auth() @@ -11,10 +13,23 @@ export default async function StarredPage() { const entries = await listStarredEntries(session.user.id) return ( -
- - -
+ <> + {/* Mobile header: fixed search + filters */} + + {/* Spacer to offset the fixed mobile header height */} +
+ + {/* Mobile results list (full-width, scrolls under header) */} +
+ +
+ + {/* Desktop layout */} +
+ + +
+ ) } diff --git a/app/(main)/drive/trash/page.tsx b/app/(main)/drive/trash/page.tsx index 1728b0b..3f0e20c 100644 --- a/app/(main)/drive/trash/page.tsx +++ b/app/(main)/drive/trash/page.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { getTrashFolderId, listFoldersByParent, listFilesByParent } from "@/lib/modules/drive"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; +import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; +import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; export default async function TrashPage() { const session = await auth(); @@ -20,10 +22,23 @@ export default async function TrashPage() { const breadcrumb = [{ id: trashId, name: 'Trash' }] return ( -
- - -
+ <> + {/* Mobile header: fixed search + filters */} + + {/* Spacer to offset the fixed mobile header height */} +
+ + {/* Mobile results list (full-width, scrolls under header) */} +
+ +
+ + {/* Desktop layout */} +
+ + +
+ ); } From 8266c29ca59b8fa89704d8e76f3175f9156f06e8 Mon Sep 17 00:00:00 2001 From: apensotti Date: Sun, 2 Nov 2025 19:12:34 +0000 Subject: [PATCH 05/24] feat: add mobile floating action button to drive pages and refactor FAB menu for better reusability --- app/(main)/drive/folder/[folderId]/page.tsx | 4 ++ app/(main)/drive/page.tsx | 4 ++ app/(main)/drive/starred/page.tsx | 7 +++- app/(main)/drive/trash/page.tsx | 4 ++ components/drive/FilesFabMenu.tsx | 42 ++++++++++++------- components/drive/FilesLeftSidebar.tsx | 20 +++------ components/drive/MobileDriveFab.tsx | 45 +++++++++++++++++++++ 7 files changed, 97 insertions(+), 29 deletions(-) create mode 100644 components/drive/MobileDriveFab.tsx diff --git a/app/(main)/drive/folder/[folderId]/page.tsx b/app/(main)/drive/folder/[folderId]/page.tsx index 8e8eb23..0ff81b0 100644 --- a/app/(main)/drive/folder/[folderId]/page.tsx +++ b/app/(main)/drive/folder/[folderId]/page.tsx @@ -5,6 +5,7 @@ import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; +import { MobileDriveFab } from "@/components/drive/MobileDriveFab"; interface PageProps { params: Promise<{ folderId: string }> @@ -36,6 +37,9 @@ export default async function FolderPage({ params }: PageProps) {
+ {/* Mobile floating action button */} + + {/* Desktop layout */}
diff --git a/app/(main)/drive/page.tsx b/app/(main)/drive/page.tsx index 33983c7..993001a 100644 --- a/app/(main)/drive/page.tsx +++ b/app/(main)/drive/page.tsx @@ -5,6 +5,7 @@ import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; +import { MobileDriveFab } from "@/components/drive/MobileDriveFab"; interface FilesPageProps { searchParams?: Promise<{ parentId?: string | string[] }> @@ -42,6 +43,9 @@ export default async function FilesPage({ searchParams }: FilesPageProps) {
+ {/* Mobile floating action button */} + + {/* Desktop layout */}
diff --git a/app/(main)/drive/starred/page.tsx b/app/(main)/drive/starred/page.tsx index 631cb07..7a50c85 100644 --- a/app/(main)/drive/starred/page.tsx +++ b/app/(main)/drive/starred/page.tsx @@ -2,15 +2,17 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; -import { listStarredEntries } from "@/lib/modules/drive"; +import { listStarredEntries, getRootFolderId } from "@/lib/modules/drive"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; +import { MobileDriveFab } from "@/components/drive/MobileDriveFab"; export default async function StarredPage() { const session = await auth() if (!session?.user?.id) redirect('/login') const entries = await listStarredEntries(session.user.id) + const rootId = await getRootFolderId(session.user.id) return ( <> @@ -24,6 +26,9 @@ export default async function StarredPage() {
+ {/* Mobile floating action button */} + + {/* Desktop layout */}
diff --git a/app/(main)/drive/trash/page.tsx b/app/(main)/drive/trash/page.tsx index 3f0e20c..51bfe6b 100644 --- a/app/(main)/drive/trash/page.tsx +++ b/app/(main)/drive/trash/page.tsx @@ -5,6 +5,7 @@ import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; +import { MobileDriveFab } from "@/components/drive/MobileDriveFab"; export default async function TrashPage() { const session = await auth(); @@ -33,6 +34,9 @@ export default async function TrashPage() {
+ {/* Mobile floating action button */} + + {/* Desktop layout */}
diff --git a/components/drive/FilesFabMenu.tsx b/components/drive/FilesFabMenu.tsx index c7ff8be..4dea2aa 100644 --- a/components/drive/FilesFabMenu.tsx +++ b/components/drive/FilesFabMenu.tsx @@ -10,6 +10,33 @@ import { } from "@/components/ui/dropdown-menu" import { Plus, FolderPlus, FileUp, FolderUp } from "lucide-react" +export interface FilesFabMenuItemsProps { + onCreateFolder?: () => void + onUploadFile?: () => void + onUploadFolder?: () => void +} + +export function FilesFabMenuItems({ onCreateFolder, onUploadFile, onUploadFolder }: FilesFabMenuItemsProps = {}) { + return ( + <> + New + + { e.preventDefault(); onCreateFolder?.() }}> + + New Folder + + { e.preventDefault(); onUploadFile?.() }}> + + File Upload + + { e.preventDefault(); onUploadFolder?.() }}> + + Folder Upload + + + ) +} + export function FilesFabMenu() { return (
@@ -20,20 +47,7 @@ export function FilesFabMenu() { - New - - - - New Folder - - - - File Upload - - - - Folder Upload - +
diff --git a/components/drive/FilesLeftSidebar.tsx b/components/drive/FilesLeftSidebar.tsx index 605495d..01acfd3 100644 --- a/components/drive/FilesLeftSidebar.tsx +++ b/components/drive/FilesLeftSidebar.tsx @@ -21,6 +21,7 @@ import { FaStar, FaRegStar } from "react-icons/fa" import Image from "next/image" import { DriveStorageInfo } from "./DriveStorageInfo" import { useIntegrations } from "@/components/providers/IntegrationsProvider" +import { FilesFabMenuItems } from "./FilesFabMenu" interface FilesLeftSidebarProps { parentId?: string @@ -47,20 +48,11 @@ export function FilesLeftSidebar({ parentId = "", localRootId, googleRootId }: F {!isTrash && ( - Create - - { e.preventDefault(); setShowNewFolder(true) }}> - - New Folder - - { e.preventDefault(); setShowUploadFile(true) }}> - - File Upload - - { e.preventDefault(); setShowUploadFolder(true) }}> - - Folder Upload - + setShowNewFolder(true)} + onUploadFile={() => setShowUploadFile(true)} + onUploadFolder={() => setShowUploadFolder(true)} + /> )} diff --git a/components/drive/MobileDriveFab.tsx b/components/drive/MobileDriveFab.tsx new file mode 100644 index 0000000..60144c6 --- /dev/null +++ b/components/drive/MobileDriveFab.tsx @@ -0,0 +1,45 @@ +"use client" +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Plus } from "lucide-react" +import { FilesFabMenuItems } from "./FilesFabMenu" +import { CreateFolderDialog } from "./CreateFolderDialog" +import { UploadFileDialog } from "./UploadFileDialog" +import { UploadFolderDialog } from "./UploadFolderDialog" + +export function MobileDriveFab({ parentId, isTrash = false }: { parentId: string; isTrash?: boolean }) { + const [showNewFolder, setShowNewFolder] = useState(false) + const [showUploadFile, setShowUploadFile] = useState(false) + const [showUploadFolder, setShowUploadFolder] = useState(false) + + if (isTrash) return null + + return ( +
+ + + + + + setShowNewFolder(true)} + onUploadFile={() => setShowUploadFile(true)} + onUploadFolder={() => setShowUploadFolder(true)} + /> + + + + + +
+ ) +} + + From 6b0257516246ca80307e053a67d5bef6e730f52f Mon Sep 17 00:00:00 2001 From: apensotti Date: Sun, 2 Nov 2025 19:25:58 +0000 Subject: [PATCH 06/24] feat: add filename truncation for better display in FilesResultsTableMobile --- components/drive/FilesResultsTableMobile.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/components/drive/FilesResultsTableMobile.tsx b/components/drive/FilesResultsTableMobile.tsx index f3434b3..b638710 100644 --- a/components/drive/FilesResultsTableMobile.tsx +++ b/components/drive/FilesResultsTableMobile.tsx @@ -68,6 +68,19 @@ function getFileUrl(item: FileEntry): string { : `/files/${rel.split("/").map(encodeURIComponent).join("/")}` } +function truncateFilename(name: string, maxLength = 24): string { + if (name.length <= maxLength) return name + const lastDot = name.lastIndexOf(".") + if (lastDot > 0 && lastDot < name.length - 1) { + const ext = name.slice(lastDot + 1) + const reserved = ext.length + 1 + const available = maxLength - 3 - reserved + if (available <= 0) return name.slice(0, Math.max(0, maxLength - 3)) + "..." + return name.slice(0, available) + "..." + "." + ext + } + return name.slice(0, Math.max(0, maxLength - 3)) + "..." +} + export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTableMobileProps) { const router = useRouter() const [preview, setPreview] = useState<{ name: string; url: string; fileId?: string; mimeType?: string } | null>(null) @@ -99,7 +112,7 @@ export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTab
+ +
) From 3e273a3483a6d29a2ef904d9c60247bd725d90d8 Mon Sep 17 00:00:00 2001 From: Alex <126031796+apensotti@users.noreply.github.com> Date: Sun, 2 Nov 2025 20:35:00 -0500 Subject: [PATCH 08/24] Mobile UX & Navigation: Responsive layouts, sidebar toggle, Drive roots (#106) * feat: enhance drive pages with local and Google root folder ID retrieval for improved navigation * feat: improve search page layout with responsive design for mobile and desktop views * feat: improve ModelSelector component with responsive design adjustments for better display on mobile and desktop * fix: adjust padding and opacity for action buttons in ChatMessages component for improved visibility and layout * fix: update transformer package from @xenova to @huggingface and adjust message avatar class for improved styling * fix: remove fixed height from FilesResultsTableMobile for improved layout flexibility * feat: integrate sidebar toggle button into SearchBar for enhanced navigation and improve layout responsiveness --- app/(main)/drive/folder/[folderId]/page.tsx | 9 +- app/(main)/drive/page.tsx | 16 +- app/(main)/drive/starred/page.tsx | 13 +- app/(main)/drive/trash/page.tsx | 11 +- app/(main)/search/page.tsx | 74 ++- components/ai/message.tsx | 2 +- components/chat/chat-messages.tsx | 6 +- components/chat/model-selector.tsx | 12 +- components/drive/DriveMobileHeader.tsx | 33 +- components/drive/FilesResultsTableMobile.tsx | 2 +- components/search/search-bar.tsx | 140 +++--- components/sidebar/nav-channels.tsx | 9 +- components/sidebar/nav-chats.tsx | 7 +- components/sidebar/nav-main.tsx | 11 +- components/sidebar/nav-models.tsx | 7 +- components/sidebar/sidebar-logo.tsx | 7 +- .../audio/transcription/whisper-worker.js | 10 +- package.json | 2 +- pnpm-lock.yaml | 473 ++++++------------ 19 files changed, 395 insertions(+), 449 deletions(-) diff --git a/app/(main)/drive/folder/[folderId]/page.tsx b/app/(main)/drive/folder/[folderId]/page.tsx index 0ff81b0..5a3406b 100644 --- a/app/(main)/drive/folder/[folderId]/page.tsx +++ b/app/(main)/drive/folder/[folderId]/page.tsx @@ -1,6 +1,6 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { listFoldersByParent, listFilesByParent, getFolderNameById, getFolderBreadcrumb, isGoogleDriveFolder } from "@/lib/modules/drive"; +import { listFoldersByParent, listFilesByParent, getFolderNameById, getFolderBreadcrumb, isGoogleDriveFolder, findLocalRootFolderId, getGoogleRootFolderId } from "@/lib/modules/drive"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; @@ -16,19 +16,22 @@ export default async function FolderPage({ params }: PageProps) { if (!session?.user?.id) redirect("/login"); const { folderId } = await params - const [folders, files, parentName, breadcrumb, isDrive] = await Promise.all([ + const [folders, files, parentName, breadcrumb, isDrive, localRootIdRaw, googleRootId] = await Promise.all([ listFoldersByParent(session.user.id, folderId), listFilesByParent(session.user.id, folderId), getFolderNameById(session.user.id, folderId), getFolderBreadcrumb(session.user.id, folderId), isGoogleDriveFolder(session.user.id, folderId), + findLocalRootFolderId(session.user.id), + getGoogleRootFolderId(session.user.id), ]) const entries = [...folders, ...files] + const localRootId = localRootIdRaw ?? '' return ( <> {/* Mobile header: fixed search + filters */} - + {/* Spacer to offset the fixed mobile header height */}
diff --git a/app/(main)/drive/page.tsx b/app/(main)/drive/page.tsx index 993001a..8679902 100644 --- a/app/(main)/drive/page.tsx +++ b/app/(main)/drive/page.tsx @@ -1,6 +1,6 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { getRootFolderId, listFoldersByParent, listFilesByParent, getFolderBreadcrumb, isGoogleDriveFolder } from "@/lib/modules/drive"; +import { getRootFolderId, listFoldersByParent, listFilesByParent, getFolderBreadcrumb, isGoogleDriveFolder, findLocalRootFolderId, getGoogleRootFolderId } from "@/lib/modules/drive"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; @@ -23,27 +23,25 @@ export default async function FilesPage({ searchParams }: FilesPageProps) { ? parentId : await getRootFolderId(session.user.id) - const [folders, files, breadcrumb, isDrive] = await Promise.all([ + const [folders, files, breadcrumb, isDrive, localRootIdRaw, googleRootId] = await Promise.all([ listFoldersByParent(session.user.id, effectiveRootId), listFilesByParent(session.user.id, effectiveRootId), getFolderBreadcrumb(session.user.id, effectiveRootId), isGoogleDriveFolder(session.user.id, effectiveRootId), + findLocalRootFolderId(session.user.id), + getGoogleRootFolderId(session.user.id), ]) const entries = [...folders, ...files] + const localRootId = localRootIdRaw ?? '' return ( <> - {/* Mobile header: fixed search + filters */} - - {/* Spacer to offset the fixed mobile header height */} + {/* Mobile layout */} +
- - {/* Mobile results list (full-width, scrolls under header) */}
- - {/* Mobile floating action button */} {/* Desktop layout */} diff --git a/app/(main)/drive/starred/page.tsx b/app/(main)/drive/starred/page.tsx index 7a50c85..73bea7b 100644 --- a/app/(main)/drive/starred/page.tsx +++ b/app/(main)/drive/starred/page.tsx @@ -2,7 +2,7 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; -import { listStarredEntries, getRootFolderId } from "@/lib/modules/drive"; +import { listStarredEntries, getRootFolderId, findLocalRootFolderId, getGoogleRootFolderId } from "@/lib/modules/drive"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader"; import { MobileDriveFab } from "@/components/drive/MobileDriveFab"; @@ -11,13 +11,18 @@ export default async function StarredPage() { const session = await auth() if (!session?.user?.id) redirect('/login') - const entries = await listStarredEntries(session.user.id) - const rootId = await getRootFolderId(session.user.id) + const [entries, rootId, localRootIdRaw, googleRootId] = await Promise.all([ + listStarredEntries(session.user.id), + getRootFolderId(session.user.id), + findLocalRootFolderId(session.user.id), + getGoogleRootFolderId(session.user.id), + ]) + const localRootId = localRootIdRaw ?? '' return ( <> {/* Mobile header: fixed search + filters */} - + {/* Spacer to offset the fixed mobile header height */}
diff --git a/app/(main)/drive/trash/page.tsx b/app/(main)/drive/trash/page.tsx index 51bfe6b..e91e8c5 100644 --- a/app/(main)/drive/trash/page.tsx +++ b/app/(main)/drive/trash/page.tsx @@ -1,6 +1,6 @@ import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; -import { getTrashFolderId, listFoldersByParent, listFilesByParent } from "@/lib/modules/drive"; +import { getTrashFolderId, listFoldersByParent, listFilesByParent, findLocalRootFolderId, getGoogleRootFolderId } from "@/lib/modules/drive"; import { FilesResultsTable } from "@/components/drive/FilesResultsTable"; import { FilesSearchBar } from "@/components/drive/FilesSearchBar"; import { FilesResultsTableMobile } from "@/components/drive/FilesResultsTableMobile"; @@ -12,7 +12,12 @@ export default async function TrashPage() { if (!session?.user?.id) redirect("/login"); // Ensure the user's Trash system folder exists - const trashId = await getTrashFolderId(session.user.id); + const [trashId, localRootIdRaw, googleRootId] = await Promise.all([ + getTrashFolderId(session.user.id), + findLocalRootFolderId(session.user.id), + getGoogleRootFolderId(session.user.id), + ]) + const localRootId = localRootIdRaw ?? '' // Load only Trash folder contents (not My Drive root) const [folders, files] = await Promise.all([ @@ -25,7 +30,7 @@ export default async function TrashPage() { return ( <> {/* Mobile header: fixed search + filters */} - + {/* Spacer to offset the fixed mobile header height */}
diff --git a/app/(main)/search/page.tsx b/app/(main)/search/page.tsx index fa24550..aa05b31 100644 --- a/app/(main)/search/page.tsx +++ b/app/(main)/search/page.tsx @@ -29,36 +29,66 @@ export default async function SearchPage({ searchParams }: SearchPageProps) { const scopeNoneSpecified = !scopeChats && !scopeArchived; if (q) { - if (scopeModels) { - return ( -
- - -
- ); - } return ( -
- - +
+ {/* Mobile fixed header */} +
+
+
+ +
+
+
+ {/* Spacer for fixed header height */} +
+ + {/* Mobile: show chats list only */} +
+ +
+ + {/* Desktop layout */} +
+ + {scopeModels ? ( + + ) : ( + + )} +
); } // When no query: default to chats unless a mention switches scope if (!q) { - if (scopeModels) { - return ( -
- - -
- ); - } return ( -
- - +
+ {/* Mobile fixed header */} +
+
+
+ +
+
+
+ {/* Spacer for fixed header height */} +
+ + {/* Mobile: show chats list only */} +
+ +
+ + {/* Desktop layout */} +
+ + {scopeModels ? ( + + ) : ( + + )} +
); } diff --git a/components/ai/message.tsx b/components/ai/message.tsx index ab9d446..2d2c7f6 100644 --- a/components/ai/message.tsx +++ b/components/ai/message.tsx @@ -55,7 +55,7 @@ export const MessageAvatar = ({ ...props }: MessageAvatarProps) => ( diff --git a/components/chat/chat-messages.tsx b/components/chat/chat-messages.tsx index 0e15cde..d81a310 100644 --- a/components/chat/chat-messages.tsx +++ b/components/chat/chat-messages.tsx @@ -247,7 +247,7 @@ export default function ChatMessages({ onScroll={handleScroll} className="w-full h-full flex-1 min-h-0 overflow-y-auto pt-16" > -
+
{messages.map((message) => message.role === 'user' ? ( @@ -268,7 +268,7 @@ export default function ChatMessages({ const copyText = getVisibleTextForCopy(message as any) if (!copyText) return null return ( - + navigator.clipboard.writeText(copyText)} label="Copy"> @@ -542,7 +542,7 @@ export default function ChatMessages({ const copyText = getVisibleTextForCopy(message as any) if (!copyText) return null return ( - + navigator.clipboard.writeText(copyText)} label="Copy"> diff --git a/components/chat/model-selector.tsx b/components/chat/model-selector.tsx index 5eac1ff..ee668ff 100644 --- a/components/chat/model-selector.tsx +++ b/components/chat/model-selector.tsx @@ -144,10 +144,10 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur variant="ghost" role="combobox" aria-expanded={open} - className="w-fit h-12 justify-between bg-transparent hover:bg-muted/50 px-4" + className="w-fit h-12 justify-between bg-transparent hover:bg-muted/50 px-4 max-w-[90vw] md:max-w-none" disabled={isLoading} > -
+
{selectedModel ? ( <> @@ -160,8 +160,8 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur {selectedModel.name.charAt(0).toUpperCase()} -
- {getDisplayName(selectedModel)} +
+ {getDisplayName(selectedModel)} {isOllama(selectedModel) ? (
{getParameterSize(selectedModel) && ( @@ -221,8 +221,8 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur
-
- {getDisplayName(model)} +
+ {getDisplayName(model)} {isOllama(model) ? (
{getParameterSize(model) && ( diff --git a/components/drive/DriveMobileHeader.tsx b/components/drive/DriveMobileHeader.tsx index 7a6acf1..36b678e 100644 --- a/components/drive/DriveMobileHeader.tsx +++ b/components/drive/DriveMobileHeader.tsx @@ -1,15 +1,44 @@ "use client" import { FilesSearchBar } from "@/components/drive/FilesSearchBar" import { FiltersBar } from "@/components/drive/FiltersBar" +import { Button } from "@/components/ui/button" +import { useRouter } from "next/navigation" +import Image from "next/image" + +export function DriveMobileHeader({ localRootId, googleRootId, isGoogleDriveFolder }: { localRootId: string; googleRootId: string | null; isGoogleDriveFolder: boolean }) { + const router = useRouter() + + const canToggle = Boolean(googleRootId) + const targetHref = isGoogleDriveFolder + ? (localRootId ? `/drive/folder/${encodeURIComponent(localRootId)}` : "/drive") + : `/drive/folder/${encodeURIComponent(googleRootId ?? "")}` + const isActiveGoogle = isGoogleDriveFolder -export function DriveMobileHeader() { return (
-
+
+ {canToggle && ( + + )}
) diff --git a/components/drive/FilesResultsTableMobile.tsx b/components/drive/FilesResultsTableMobile.tsx index b638710..2c7d70c 100644 --- a/components/drive/FilesResultsTableMobile.tsx +++ b/components/drive/FilesResultsTableMobile.tsx @@ -103,7 +103,7 @@ export function FilesResultsTableMobile({ entries, parentName }: FilesResultsTab }, [router]) return ( -
+
    {entries.map((item) => (
  • diff --git a/components/search/search-bar.tsx b/components/search/search-bar.tsx index 511e4c8..9ab0169 100644 --- a/components/search/search-bar.tsx +++ b/components/search/search-bar.tsx @@ -2,6 +2,9 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; +import { FiSidebar } from "react-icons/fi"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; @@ -19,6 +22,7 @@ export function SearchBar({ placeholder = "Search or use @", className, }: SearchBarProps) { + const { toggleSidebar } = useSidebar(); const inputRef = useRef(null); const [value, setValue] = useState(query ?? ""); const [mentions, setMentions] = useState([]); @@ -198,74 +202,86 @@ export function SearchBar({ }, [value, mentions, pathname, router, sp, tokenText]); return ( -
    -
    -
    -
    inputRef.current?.focus()} +
    +
    +
    + + +
    - {open && ( -
    inputRef.current?.focus()} + > + {mentions.map((m) => ( + + ))} + {[...mentions] + .sort((a, b) => MENTION_OPTIONS.indexOf(a) - MENTION_OPTIONS.indexOf(b)) + .map((m) => ( + + @{m} + + ))} + setValue(e.target.value)} + onKeyDown={onKeyDown} + placeholder={mentions.length ? "" : placeholder} + autoComplete="off" className={cn( - "absolute left-2 right-2 top-full mt-1 z-50", - "rounded-md border bg-popover text-popover-foreground shadow-md" + "h-12 border-0 bg-transparent dark:bg-transparent shadow-none flex-1 min-w-[6rem] px-0", + "focus-visible:ring-0 focus-visible:ring-offset-0", + "text-[16px] md:text-[16px]" )} - > -
      - {filtered.map((opt, idx) => ( -
    • - -
    • - ))} -
    -
    - )} -
    + /> +
    + + {open && ( +
    +
      + {filtered.map((opt, idx) => ( +
    • + +
    • + ))} +
    +
    + )}
    - +
    ); } diff --git a/components/sidebar/nav-channels.tsx b/components/sidebar/nav-channels.tsx index cbd51bc..8f577ce 100644 --- a/components/sidebar/nav-channels.tsx +++ b/components/sidebar/nav-channels.tsx @@ -16,6 +16,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + useSidebar, } from "@/components/ui/sidebar" export function NavChannels({ @@ -34,6 +35,10 @@ export function NavChannels({ }[] }[] }) { + const { isMobile, setOpenMobile } = useSidebar() + function handleNavigate() { + if (isMobile) setOpenMobile(false) + } return ( @@ -42,7 +47,7 @@ export function NavChannels({ return ( - + {item.icon && } {item.title} @@ -71,7 +76,7 @@ export function NavChannels({ {item.items?.map((subItem) => ( - + {subItem.icon && } {subItem.title} diff --git a/components/sidebar/nav-chats.tsx b/components/sidebar/nav-chats.tsx index 53defa4..941e5d9 100644 --- a/components/sidebar/nav-chats.tsx +++ b/components/sidebar/nav-chats.tsx @@ -17,6 +17,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, + useSidebar, } from "@/components/ui/sidebar" import { Button } from "@/components/ui/button" import { @@ -37,6 +38,10 @@ interface NavChatsProps { } export function NavChats({ chats, timeZone = 'UTC' }: NavChatsProps) { + const { isMobile, setOpenMobile } = useSidebar() + const handleNavigate = useCallback(() => { + if (isMobile) setOpenMobile(false) + }, [isMobile, setOpenMobile]) const [isOpen, setIsOpen] = useState(true) const [mounted, setMounted] = useState(false) const [allChats, setAllChats] = useState(chats) @@ -340,7 +345,7 @@ export function NavChats({ chats, timeZone = 'UTC' }: NavChatsProps) { return ( - + @@ -44,7 +51,7 @@ export function NavMain({ return ( - + {item.icon && } {item.title} @@ -73,7 +80,7 @@ export function NavMain({ {item.items?.map((subItem) => ( - + {subItem.icon && } {subItem.title} diff --git a/components/sidebar/nav-models.tsx b/components/sidebar/nav-models.tsx index aabb649..d6735d9 100644 --- a/components/sidebar/nav-models.tsx +++ b/components/sidebar/nav-models.tsx @@ -10,10 +10,15 @@ import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, + useSidebar, } from "@/components/ui/sidebar" export function NavModels({ pinnedModels, currentUserId }: { pinnedModels: Model[]; currentUserId?: string | null }) { const { pinnedModels: localPinned } = usePinnedModels(currentUserId, { initialPinnedModels: pinnedModels }) + const { isMobile, setOpenMobile } = useSidebar() + function handleNavigate() { + if (isMobile) setOpenMobile(false) + } return ( @@ -28,7 +33,7 @@ export function NavModels({ pinnedModels, currentUserId }: { pinnedModels: Model const encoded = encodeURIComponent(providerModelId).replace(/%3A/g, ':') const href = `/?model=${encoded}` return ( - + {String(model?.name || '?').charAt(0).toUpperCase()} diff --git a/components/sidebar/sidebar-logo.tsx b/components/sidebar/sidebar-logo.tsx index 9feb1d7..8f2afe6 100644 --- a/components/sidebar/sidebar-logo.tsx +++ b/components/sidebar/sidebar-logo.tsx @@ -8,11 +8,14 @@ import { Button } from "@/components/ui/button" import { useSidebar } from "@/components/ui/sidebar" export function SidebarLogo() { - const { toggleSidebar, state } = useSidebar() + const { toggleSidebar, state, isMobile, setOpenMobile } = useSidebar() + const handleHomeClick = () => { + if (isMobile) setOpenMobile(false) + } return (
    - +
    =18'} + '@huggingface/transformers@3.7.6': + resolution: {integrity: sha512-OYlIRY8vj8r/pNx2CdXcDHz4KqpEC+bUMKzdVW5Dx//gp4XRmK+/g8as0h3cssRQYT0vG1A6VCfZy8SV0F4RDQ==} + '@icons-pack/react-simple-icons@13.7.0': resolution: {integrity: sha512-Vx5mnIm/3gD/9dpCfw/EdCXwzCswmvWnvMjL6zUJTbpk2PuyCdx5zSfiX8KQKYszD/1Z2mfaiBtqCxlHuDcpuA==} peerDependencies: @@ -2253,9 +2256,6 @@ packages: '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} - '@types/long@4.0.2': - resolution: {integrity: sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==} - '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} @@ -2312,9 +2312,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - '@xenova/transformers@2.17.2': - resolution: {integrity: sha512-lZmHqzrVIkSvZdKZEx7IYY51TK0WDrC8eR0c5IMnBsO8di8are1zzw8BlLhyO2TklZKLN5UffNGs1IJwT6oOqQ==} - abbrev@1.1.1: resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} @@ -2427,36 +2424,6 @@ packages: bare-events@2.6.1: resolution: {integrity: sha512-AuTJkq9XmE6Vk0FJVNq5QxETrSA/vKHarWVBG5l/JbdCL1prJemiyJqUS0jrlXO0MftuPq4m3YVYhoNc5+aE/g==} - bare-fs@4.4.4: - resolution: {integrity: sha512-Q8yxM1eLhJfuM7KXVP3zjhBvtMJCYRByoTT+wHXjpdMELv0xICFJX+1w4c7csa+WZEOsq4ItJ4RGwvzid6m/dw==} - engines: {bare: '>=1.16.0'} - peerDependencies: - bare-buffer: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - - bare-os@3.6.2: - resolution: {integrity: sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==} - engines: {bare: '>=1.14.0'} - - bare-path@3.0.0: - resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} - - bare-stream@2.7.0: - resolution: {integrity: sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==} - peerDependencies: - bare-buffer: '*' - bare-events: '*' - peerDependenciesMeta: - bare-buffer: - optional: true - bare-events: - optional: true - - bare-url@2.2.2: - resolution: {integrity: sha512-g+ueNGKkrjMazDG3elZO1pNs3HY5+mMmOet1jtKyhOaCnkLzitxf26z7hoAEkDNgdNmnc1KIlt/dw6Po6xZMpA==} - base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -2470,8 +2437,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} - bl@4.1.0: - resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -2486,9 +2454,6 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} - buffer@5.7.1: - resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} - buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2558,9 +2523,6 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - chownr@1.1.4: - resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} - chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} @@ -2775,10 +2737,6 @@ packages: resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} engines: {node: '>=8'} - decompress-response@6.0.0: - resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} - engines: {node: '>=10'} - deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -2795,6 +2753,10 @@ packages: resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} engines: {node: '>= 0.4'} + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -2819,6 +2781,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -2887,9 +2852,6 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} - end-of-stream@1.4.5: - resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} - enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -2918,6 +2880,9 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild@0.25.9: resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} @@ -2957,10 +2922,6 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} - expand-template@2.0.3: - resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} - engines: {node: '>=6'} - exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} @@ -2987,8 +2948,8 @@ packages: fault@1.0.4: resolution: {integrity: sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==} - flatbuffers@1.12.0: - resolution: {integrity: sha512-c7CZADjRcl6j0PlvFy0ZqXQ67qSEZfrVPynmnL+2zPc+NtMvrF8Y0QceMo7QqnSPc7+uWjUIAbvCQ5WIKlMVdQ==} + flatbuffers@25.9.23: + resolution: {integrity: sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==} follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} @@ -3022,9 +2983,6 @@ packages: resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==} engines: {node: '>= 12.20'} - fs-constants@1.0.0: - resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - fs-minipass@2.1.0: resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} engines: {node: '>= 8'} @@ -3081,9 +3039,6 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true - github-from-package@0.0.0: - resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -3092,6 +3047,14 @@ packages: resolution: {integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==} deprecated: Glob versions prior to v9 are no longer supported + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + google-auth-library@9.15.1: resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==} engines: {node: '>=14'} @@ -3242,9 +3205,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ini@1.3.8: - resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} - inline-style-parser@0.2.4: resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} @@ -3358,6 +3318,9 @@ packages: json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + jsonwebtoken@9.0.2: resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} engines: {node: '>=12', npm: '>=6'} @@ -3499,8 +3462,8 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - long@4.0.0: - resolution: {integrity: sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3541,6 +3504,10 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3698,10 +3665,6 @@ packages: resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} engines: {node: '>=8'} - mimic-response@3.1.0: - resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} - engines: {node: '>=10'} - minim@0.23.8: resolution: {integrity: sha512-bjdr2xW1dBCMsMGGsUeqM4eFI60m94+szhxWys+B1ztIt6gWSfeGBdSVCIawezeHYLYn0j6zrsXdQS/JllBzww==} engines: {node: '>=6'} @@ -3744,9 +3707,6 @@ packages: resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} engines: {node: '>= 18'} - mkdirp-classic@0.5.3: - resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} - mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} @@ -3768,9 +3728,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-build-utils@2.0.0: - resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} - neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -3818,16 +3775,9 @@ packages: sass: optional: true - node-abi@3.77.0: - resolution: {integrity: sha512-DSmt0OEcLoK4i3NuscSbGjOf3bqiDEutejqENSplMSFA/gmB8mkED9G4pKWnPl7MDU4rSHebKPHeitpDfyH0cQ==} - engines: {node: '>=10'} - node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} - node-addon-api@6.1.0: - resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} - node-addon-api@8.5.0: resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==} engines: {node: ^18 || ^20 || >= 21} @@ -3886,6 +3836,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + ohash@2.0.11: resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} @@ -3904,18 +3858,18 @@ packages: oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} - onnx-proto@4.0.4: - resolution: {integrity: sha512-aldMOB3HRoo6q/phyB6QRQxSt895HNNw82BNyZ2CMh4bjeKv7g/c+VpAFtJuEMVfYLMbRx61hbuqnKceLeDcDA==} + onnxruntime-common@1.21.0: + resolution: {integrity: sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==} - onnxruntime-common@1.14.0: - resolution: {integrity: sha512-3LJpegM2iMNRX2wUmtYfeX/ytfOzNwAWKSq1HbRrKc9+uqG/FsEA0bbKZl1btQeZaXhC26l44NWpNUeXPII7Ew==} + onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: + resolution: {integrity: sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==} - onnxruntime-node@1.14.0: - resolution: {integrity: sha512-5ba7TWomIV/9b6NH/1x/8QEeowsb+jBEvFzU6z0T4mNsFwdPqXeFUM7uxC6QeSRkEbWu3qEB0VMjrvzN/0S9+w==} + onnxruntime-node@1.21.0: + resolution: {integrity: sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==} os: [win32, darwin, linux] - onnxruntime-web@1.14.0: - resolution: {integrity: sha512-Kcqf43UMfW8mCydVGcX9OMXI2VN17c0p6XvR7IPSZzBf/6lteBzXHvcEVWDPmCKuGombl997HgLqj91F11DzXw==} + onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: + resolution: {integrity: sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==} openai@6.3.0: resolution: {integrity: sha512-E6vOGtZvdcb4yXQ5jXvDlUG599OhIkb/GjBLZXS+qk0HF+PJReIldEc9hM8Ft81vn+N6dRdFRb7BZNK8bbvXrw==} @@ -4020,11 +3974,6 @@ packages: preact@10.24.3: resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} - prebuild-install@7.1.3: - resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} - engines: {node: '>=10'} - hasBin: true - prisma@6.16.1: resolution: {integrity: sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w==} engines: {node: '>=18.18'} @@ -4117,16 +4066,13 @@ packages: prosemirror-view@1.41.2: resolution: {integrity: sha512-PGS/jETmh+Qjmre/6vcG7SNHAKiGc4vKOJmHMPRmvcUl7ISuVtrtHmH06UDUwaim4NDJfZfVMl7U7JkMMETa6g==} - protobufjs@6.11.4: - resolution: {integrity: sha512-5kQWPaJHi1WoCpjTGszzQ32PG2F4+wRY6BmAT4Vfw56Q2FZ4YZzK20xUYQH4YkfehY1e6QSICrJquM6xXZNcrw==} - hasBin: true + protobufjs@7.5.4: + resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} + engines: {node: '>=12.0.0'} proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - pump@3.0.3: - resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} - punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -4168,10 +4114,6 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} - rc@1.2.8: - resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} - hasBin: true - react-copy-to-clipboard@5.1.0: resolution: {integrity: sha512-k61RsNgAayIJNoy9yDsYzDe/yAZAzEbEgcz3DZMhF686LEyukcE1hzurxe85JandPUG+yTfGVFzuEw3xt8WP/A==} peerDependencies: @@ -4404,6 +4346,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} @@ -4429,6 +4375,9 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4438,6 +4387,10 @@ packages: engines: {node: '>=10'} hasBin: true + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + serialize-error@8.1.0: resolution: {integrity: sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==} engines: {node: '>=10'} @@ -4454,10 +4407,6 @@ packages: engines: {node: '>= 0.10'} hasBin: true - sharp@0.32.6: - resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} - engines: {node: '>=14.15.0'} - sharp@0.34.3: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -4510,9 +4459,6 @@ packages: simple-get@3.1.1: resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} - simple-get@4.0.1: - resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} - simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} @@ -4535,6 +4481,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -4567,10 +4516,6 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} - strip-json-comments@2.0.1: - resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} - engines: {node: '>=0.10.0'} - style-to-js@1.1.17: resolution: {integrity: sha512-xQcBGDxJb6jjFCTzvQtfiPn6YvvP2O8U1MDIPNfJQlWMYfktPy+iGsHE7cssjs7y84d9fQaK4UF3RIJaAHSoYA==} @@ -4640,16 +4585,6 @@ packages: resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} engines: {node: '>=6'} - tar-fs@2.1.3: - resolution: {integrity: sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg==} - - tar-fs@3.1.0: - resolution: {integrity: sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==} - - tar-stream@2.2.0: - resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} - engines: {node: '>=6'} - tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -4743,12 +4678,13 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tunnel-agent@0.6.0: - resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - tw-animate-css@1.3.8: resolution: {integrity: sha512-Qrk3PZ7l7wUcGYhwZloqfkWCmaXZAoqjkdbIDvzfGshwGtexa/DAs9koXxIkrpEasyevandomzCBAV1Yyop5rw==} + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + type-fest@0.20.2: resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} engines: {node: '>=10'} @@ -5380,7 +5316,14 @@ snapshots: '@standard-schema/utils': 0.3.0 react-hook-form: 7.62.0(react@19.1.0) - '@huggingface/jinja@0.2.2': {} + '@huggingface/jinja@0.5.1': {} + + '@huggingface/transformers@3.7.6': + dependencies: + '@huggingface/jinja': 0.5.1 + onnxruntime-node: 1.21.0 + onnxruntime-web: 1.22.0-dev.20250409-89f8206ba4 + sharp: 0.34.3 '@icons-pack/react-simple-icons@13.7.0(react@19.1.0)': dependencies: @@ -7078,8 +7021,6 @@ snapshots: '@types/linkify-it@5.0.0': {} - '@types/long@4.0.2': {} - '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 @@ -7139,17 +7080,6 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@xenova/transformers@2.17.2': - dependencies: - '@huggingface/jinja': 0.2.2 - onnxruntime-web: 1.14.0 - sharp: 0.32.6 - optionalDependencies: - onnxruntime-node: 1.14.0 - transitivePeerDependencies: - - bare-buffer - - react-native-b4a - abbrev@1.1.1: optional: true @@ -7270,39 +7200,6 @@ snapshots: bare-events@2.6.1: optional: true - bare-fs@4.4.4: - dependencies: - bare-events: 2.6.1 - bare-path: 3.0.0 - bare-stream: 2.7.0(bare-events@2.6.1) - bare-url: 2.2.2 - fast-fifo: 1.3.2 - transitivePeerDependencies: - - react-native-b4a - optional: true - - bare-os@3.6.2: - optional: true - - bare-path@3.0.0: - dependencies: - bare-os: 3.6.2 - optional: true - - bare-stream@2.7.0(bare-events@2.6.1): - dependencies: - streamx: 2.22.1 - optionalDependencies: - bare-events: 2.6.1 - transitivePeerDependencies: - - react-native-b4a - optional: true - - bare-url@2.2.2: - dependencies: - bare-path: 3.0.0 - optional: true - base64-js@1.5.1: {} bcryptjs@3.0.2: {} @@ -7313,11 +7210,7 @@ snapshots: bignumber.js@9.3.1: {} - bl@4.1.0: - dependencies: - buffer: 5.7.1 - inherits: 2.0.4 - readable-stream: 3.6.2 + boolean@3.2.0: {} brace-expansion@1.1.12: dependencies: @@ -7332,11 +7225,6 @@ snapshots: buffer-equal-constant-time@1.0.1: {} - buffer@5.7.1: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -7417,8 +7305,6 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@1.1.4: {} - chownr@2.0.0: optional: true @@ -7622,10 +7508,6 @@ snapshots: mimic-response: 2.1.0 optional: true - decompress-response@6.0.0: - dependencies: - mimic-response: 3.1.0 - deep-extend@0.6.0: {} deepmerge-ts@7.1.5: {} @@ -7638,6 +7520,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + defu@6.1.4: {} delayed-stream@1.0.0: {} @@ -7653,6 +7541,8 @@ snapshots: detect-node-es@1.1.0: {} + detect-node@2.1.0: {} + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -7720,10 +7610,6 @@ snapshots: empathic@2.0.0: {} - end-of-stream@1.4.5: - dependencies: - once: 1.4.0 - enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -7748,6 +7634,8 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 + es6-error@4.1.1: {} + esbuild@0.25.9: optionalDependencies: '@esbuild/aix-ppc64': 0.25.9 @@ -7795,8 +7683,6 @@ snapshots: eventsource-parser@3.0.6: {} - expand-template@2.0.3: {} - exsolve@1.0.7: {} extend@3.0.2: {} @@ -7817,7 +7703,7 @@ snapshots: dependencies: format: 0.2.2 - flatbuffers@1.12.0: {} + flatbuffers@25.9.23: {} follow-redirects@1.15.11: {} @@ -7847,8 +7733,6 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 4.0.0-beta.3 - fs-constants@1.0.0: {} - fs-minipass@2.1.0: dependencies: minipass: 3.3.6 @@ -7932,8 +7816,6 @@ snapshots: nypm: 0.6.1 pathe: 2.0.3 - github-from-package@0.0.0: {} - glob@10.4.5: dependencies: foreground-child: 3.3.1 @@ -7952,6 +7834,20 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.7.2 + serialize-error: 7.0.1 + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + google-auth-library@9.15.1: dependencies: base64-js: 1.5.1 @@ -8183,8 +8079,6 @@ snapshots: inherits@2.0.4: {} - ini@1.3.8: {} - inline-style-parser@0.2.4: {} input-otp@1.4.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): @@ -8296,6 +8190,8 @@ snapshots: json-schema@0.4.0: {} + json-stringify-safe@5.0.1: {} + jsonwebtoken@9.0.2: dependencies: jws: 3.2.2 @@ -8420,7 +8316,7 @@ snapshots: lodash@4.17.21: {} - long@4.0.0: {} + long@5.3.2: {} longest-streak@3.1.0: {} @@ -8467,6 +8363,10 @@ snapshots: markdown-table@3.0.4: {} + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -8848,8 +8748,6 @@ snapshots: mimic-response@2.1.0: optional: true - mimic-response@3.1.0: {} - minim@0.23.8: dependencies: lodash: 4.17.21 @@ -8892,8 +8790,6 @@ snapshots: dependencies: minipass: 7.1.2 - mkdirp-classic@0.5.3: {} - mkdirp@1.0.4: optional: true @@ -8906,8 +8802,6 @@ snapshots: nanoid@3.3.11: {} - napi-build-utils@2.0.0: {} - neotraverse@0.6.18: {} next-auth@5.0.0-beta.29(next@15.4.1(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0): @@ -8946,14 +8840,8 @@ snapshots: - '@babel/core' - babel-plugin-macros - node-abi@3.77.0: - dependencies: - semver: 7.7.2 - node-abort-controller@3.1.1: {} - node-addon-api@6.1.0: {} - node-addon-api@8.5.0: optional: true @@ -9002,6 +8890,8 @@ snapshots: object-inspect@1.13.4: {} + object-keys@1.1.1: {} + ohash@2.0.11: {} ollama-ai-provider-v2@1.3.1(zod@4.1.8): @@ -9022,25 +8912,24 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 - onnx-proto@4.0.4: - dependencies: - protobufjs: 6.11.4 + onnxruntime-common@1.21.0: {} - onnxruntime-common@1.14.0: {} + onnxruntime-common@1.22.0-dev.20250409-89f8206ba4: {} - onnxruntime-node@1.14.0: + onnxruntime-node@1.21.0: dependencies: - onnxruntime-common: 1.14.0 - optional: true + global-agent: 3.0.0 + onnxruntime-common: 1.21.0 + tar: 7.4.3 - onnxruntime-web@1.14.0: + onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: dependencies: - flatbuffers: 1.12.0 + flatbuffers: 25.9.23 guid-typescript: 1.0.9 - long: 4.0.0 - onnx-proto: 4.0.4 - onnxruntime-common: 1.14.0 + long: 5.3.2 + onnxruntime-common: 1.22.0-dev.20250409-89f8206ba4 platform: 1.3.6 + protobufjs: 7.5.4 openai@6.3.0(ws@8.18.3)(zod@4.1.8): optionalDependencies: @@ -9146,21 +9035,6 @@ snapshots: preact@10.24.3: {} - prebuild-install@7.1.3: - dependencies: - detect-libc: 2.0.4 - expand-template: 2.0.3 - github-from-package: 0.0.0 - minimist: 1.2.8 - mkdirp-classic: 0.5.3 - napi-build-utils: 2.0.0 - node-abi: 3.77.0 - pump: 3.0.3 - rc: 1.2.8 - simple-get: 4.0.1 - tar-fs: 2.1.3 - tunnel-agent: 0.6.0 - prisma@6.16.1(typescript@5.9.2): dependencies: '@prisma/config': 6.16.1 @@ -9293,7 +9167,7 @@ snapshots: prosemirror-state: 1.4.3 prosemirror-transform: 1.10.4 - protobufjs@6.11.4: + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/base64': 1.1.2 @@ -9305,17 +9179,11 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/long': 4.0.2 '@types/node': 20.19.14 - long: 4.0.0 + long: 5.3.2 proxy-from-env@1.1.0: {} - pump@3.0.3: - dependencies: - end-of-stream: 1.4.5 - once: 1.4.0 - punycode.js@2.3.1: {} punycode@2.3.1: {} @@ -9355,13 +9223,6 @@ snapshots: defu: 6.1.4 destr: 2.0.5 - rc@1.2.8: - dependencies: - deep-extend: 0.6.0 - ini: 1.3.8 - minimist: 1.2.8 - strip-json-comments: 2.0.1 - react-copy-to-clipboard@5.1.0(react@19.1.0): dependencies: copy-to-clipboard: 3.3.3 @@ -9516,6 +9377,7 @@ snapshots: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + optional: true readable-stream@4.7.0: dependencies: @@ -9647,6 +9509,15 @@ snapshots: glob: 7.1.6 optional: true + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + rope-sequence@1.3.4: {} rrweb-cssom@0.8.0: {} @@ -9667,11 +9538,17 @@ snapshots: scheduler@0.26.0: {} + semver-compare@1.0.0: {} + semver@6.3.1: optional: true semver@7.7.2: {} + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + serialize-error@8.1.0: dependencies: type-fest: 0.20.2 @@ -9694,20 +9571,6 @@ snapshots: safe-buffer: 5.2.1 to-buffer: 1.2.1 - sharp@0.32.6: - dependencies: - color: 4.2.3 - detect-libc: 2.0.4 - node-addon-api: 6.1.0 - prebuild-install: 7.1.3 - semver: 7.7.2 - simple-get: 4.0.1 - tar-fs: 3.1.0 - tunnel-agent: 0.6.0 - transitivePeerDependencies: - - bare-buffer - - react-native-b4a - sharp@0.34.3: dependencies: color: 4.2.3 @@ -9736,7 +9599,6 @@ snapshots: '@img/sharp-win32-arm64': 0.34.3 '@img/sharp-win32-ia32': 0.34.3 '@img/sharp-win32-x64': 0.34.3 - optional: true shebang-command@2.0.0: dependencies: @@ -9792,7 +9654,8 @@ snapshots: signal-exit@4.1.0: {} - simple-concat@1.0.1: {} + simple-concat@1.0.1: + optional: true simple-get@3.1.1: dependencies: @@ -9801,12 +9664,6 @@ snapshots: simple-concat: 1.0.1 optional: true - simple-get@4.0.1: - dependencies: - decompress-response: 6.0.0 - once: 1.4.0 - simple-concat: 1.0.1 - simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 @@ -9824,6 +9681,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + streamsearch@1.1.0: {} streamx@2.22.1: @@ -9868,8 +9727,6 @@ snapshots: dependencies: ansi-regex: 6.2.2 - strip-json-comments@2.0.1: {} - style-to-js@1.1.17: dependencies: style-to-object: 1.0.9 @@ -10033,32 +9890,6 @@ snapshots: tapable@2.2.3: {} - tar-fs@2.1.3: - dependencies: - chownr: 1.1.4 - mkdirp-classic: 0.5.3 - pump: 3.0.3 - tar-stream: 2.2.0 - - tar-fs@3.1.0: - dependencies: - pump: 3.0.3 - tar-stream: 3.1.7 - optionalDependencies: - bare-fs: 4.4.4 - bare-path: 3.0.0 - transitivePeerDependencies: - - bare-buffer - - react-native-b4a - - tar-stream@2.2.0: - dependencies: - bl: 4.1.0 - end-of-stream: 1.4.5 - fs-constants: 1.0.0 - inherits: 2.0.4 - readable-stream: 3.6.2 - tar-stream@3.1.7: dependencies: b4a: 1.7.1 @@ -10166,12 +9997,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tunnel-agent@0.6.0: - dependencies: - safe-buffer: 5.2.1 - tw-animate-css@1.3.8: {} + type-fest@0.13.1: {} + type-fest@0.20.2: {} typed-array-buffer@1.0.3: From 5c04fb5cd0c9931f6e6d9473cd13d991e84c206e Mon Sep 17 00:00:00 2001 From: Alex <126031796+apensotti@users.noreply.github.com> Date: Sun, 2 Nov 2025 22:05:33 -0500 Subject: [PATCH 09/24] Video generation: show locally saved video in chat; enrich status endpoint - Display locally saved videos in chat instead of expiring provider URLs. - Enrich GET /api/v1/videos/sora2/{id}/status to include local URL/fileId when available. - Update VideoJob and ChatMessages to prefer local asset. - Remove /api/v1/videos/sora2/by-job/{id}. --- app/api/v1/videos/sora2/[id]/status/route.ts | 47 ++++++++++++++++++- components/ai/video.tsx | 4 ++ components/chat/chat-messages.tsx | 9 +++- .../tools/video-generation/video.service.ts | 32 ------------- 4 files changed, 57 insertions(+), 35 deletions(-) diff --git a/app/api/v1/videos/sora2/[id]/status/route.ts b/app/api/v1/videos/sora2/[id]/status/route.ts index b8d2eae..1514600 100644 --- a/app/api/v1/videos/sora2/[id]/status/route.ts +++ b/app/api/v1/videos/sora2/[id]/status/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { ProviderService } from '@/lib/modules/ai/providers/provider.service' +import db from '@/lib/db' import OpenAI from 'openai' export const runtime = 'nodejs' @@ -85,7 +86,51 @@ export async function GET(_request: NextRequest, context: { params: Promise<{ id const status: string = (anyJson?.status || '').toString() const progress: number = Number.isFinite(anyJson?.progress) ? Number(anyJson.progress) : 0 - return NextResponse.json({ status, progress, job: json }) + // If we've already saved the asset locally, include the local URL + let url: string | null = null + let fileId: string | null = null + try { + type Row = { id: string; filename: string; path: string | null; parentId: string | null } + let rows: Row[] = [] + try { + rows = await db.$queryRaw` + SELECT id, filename, path, parent_id as parentId + FROM "file" + WHERE user_id = ${userId} + AND COALESCE(CAST(json_extract(meta, '$.jobId') AS TEXT), '') = ${videoId} + LIMIT 1` + } catch { + rows = await db.$queryRaw` + SELECT id, filename, path, parent_id as parentId + FROM "file" + WHERE user_id = ${userId} + AND COALESCE((meta ->> 'jobId')::text, '') = ${videoId} + LIMIT 1` + } + const file = rows && rows[0] + if (file) { + let rel = '' + const p = file.path ? String(file.path) : '' + if (p) { + let normalized = p.replace(/^\/+/, '') + if (!normalized.endsWith('/' + file.filename)) { + normalized = normalized + '/' + file.filename + } + if (normalized.startsWith('data/files/')) { + normalized = normalized.slice('data/'.length) + } + rel = normalized + } else if (file.parentId) { + rel = `files/${file.parentId}/${file.filename}` + } else { + rel = `files/${file.filename}` + } + url = rel.startsWith('files/') ? `/${rel}` : `/files/${rel}` + fileId = file.id + } + } catch {} + + return NextResponse.json({ status, progress, job: json, ...(url ? { url, fileId } : {}) }) } catch (error) { return NextResponse.json({ error: 'Failed to check status' }, { status: 500 }) } diff --git a/components/ai/video.tsx b/components/ai/video.tsx index 57cc4ac..67428eb 100644 --- a/components/ai/video.tsx +++ b/components/ai/video.tsx @@ -28,6 +28,10 @@ export function VideoJob({ jobId }: VideoJobProps) { if (cancelled) return setStatus(String(data.status || 'queued')) setProgress(Number.isFinite(data.progress) ? Number(data.progress) : 0) + if (typeof data.url === 'string' && data.url) { + setUrl(data.url) + return + } // Upstream moderation or provider errors (surface and stop polling) const jobError = (data?.job && typeof data.job === 'object') ? (data.job as any).error : null if (jobError && (jobError.message || jobError.code)) { diff --git a/components/chat/chat-messages.tsx b/components/chat/chat-messages.tsx index d81a310..822c06c 100644 --- a/components/chat/chat-messages.tsx +++ b/components/chat/chat-messages.tsx @@ -370,15 +370,20 @@ export default function ChatMessages({ if (latestVideoPart) { const out = latestVideoPart?.output const url: string | undefined = (out && typeof out.url === 'string' && out.url) ? out.url : undefined - if (url) { + const jobId: string | undefined = (out?.details?.job?.id as string) || undefined + // Prefer local files; ignore remote API URLs + if (url && url.startsWith('/')) { return (
    ) } + // If the tool provided a remote URL, fall back to VideoJob to resolve the local saved asset + if (url && jobId) { + return + } // Poll job status while queued - const jobId: string | undefined = (out?.details?.job?.id as string) || undefined if (jobId) { return } diff --git a/lib/modules/tools/video-generation/video.service.ts b/lib/modules/tools/video-generation/video.service.ts index b2b0c11..a2b6afe 100644 --- a/lib/modules/tools/video-generation/video.service.ts +++ b/lib/modules/tools/video-generation/video.service.ts @@ -33,38 +33,6 @@ async function getVideoDefaults(): Promise<{ model: string; size: string; second } } -function pickContentExt(contentType: string | null | undefined): string { - const ct = String(contentType || '').toLowerCase() - if (ct.includes('webm')) return 'webm' - if (ct.includes('quicktime') || ct.includes('mov')) return 'mov' - if (ct.includes('m4v')) return 'm4v' - return 'mp4' -} - -function extractVideoUrl(json: any): string | null { - if (!json || typeof json !== 'object') return null - if (json.assets && typeof json.assets === 'object') { - if (typeof json.assets.video === 'string' && json.assets.video) return json.assets.video - if (Array.isArray(json.assets) && json.assets.length > 0) { - const first = json.assets.find((a: any) => typeof a?.video === 'string') - if (first?.video) return String(first.video) - } - } - if (typeof json.url === 'string' && json.url) return json.url - if (json.data && Array.isArray(json.data) && json.data[0]?.url) return String(json.data[0].url) - const maybeContent = json.output || json.contents || json.content - const arr = Array.isArray(maybeContent) ? maybeContent : [] - for (const item of arr) { - const blocks = Array.isArray(item?.content) ? item.content : [] - for (const b of blocks) { - if (b?.type === 'output_video' && typeof b?.video?.url === 'string') return b.video.url - if (b?.type === 'video' && typeof b?.video?.url === 'string') return b.video.url - if (typeof b?.url === 'string') return b.url - } - } - return null -} - export class VideoGenerationService { static async generateWithOpenAI(userId: string, input: VideoGenerationInput): Promise { const defaults = await getVideoDefaults() From fe8f45cebecf382c8c04d922bea760de2011f994 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 3 Nov 2025 03:19:44 +0000 Subject: [PATCH 10/24] RELEASE: Update v0.1.30 CHANGELOG --- CHANGELOG.md | 18 ++++++++++++++++++ package.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f617f22..7c3b0ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +## [0.1.30] - 2025-11-03 + +### PR: [Release: Merge dev into main — Mobile UX, Drive navigation, and video features](https://github.com/openchatui/openchat/pull/110) + +### Added +- Implemented mobile layouts for Drive folder, starred, and trash pages with a fixed header and responsive results list. (https://github.com/openchatui/openchat/commit/1bb18fd4331027667ac04bbaac89e8f241c6681c) +- Enhanced Drive layout with mobile navigation, including a bottom nav and responsive adjustments. (https://github.com/openchatui/openchat/commit/66e0cf67d43a07d4d49624f67c295f4de61b48d6) +- Added a mobile floating action button on Drive pages and improved the FAB menu for reuse. (https://github.com/openchatui/openchat/commit/8266c29ca59b8fa89704d8e76f3175f9156f06e8) +- Introduced rename capability and optimistic updates for starring in FilesResultsTableMobile. (https://github.com/openchatui/openchat/commit/0d5b03b72777925da57823507107cd16f9bb3db0) +- Improved mobile file name display with truncation in FilesResultsTableMobile. (https://github.com/openchatui/openchat/commit/6b0257516246ca80307e053a67d5bef6e730f52f) +- Added a sidebar toggle button to the FilesSearchBar for quicker navigation on smaller screens. (https://github.com/openchatui/openchat/commit/b45e13b5b2353750bc33abd70c10c164c9362e2b) +- Enabled video streaming and added robust error handling in the VideoPreviewer component. (https://github.com/openchatui/openchat/commit/45daf1dd1ff1868c7fb57ffd4c746e7658dd5bbc) +- Chat now shows locally saved videos for generation jobs and the status endpoint returns richer data. (https://github.com/openchatui/openchat/commit/5c04fb5cd0c9931f6e6d9473cd13d991e84c206e) +- Improved mobile UX and navigation, including Drive root switching and responsive search/results. (https://github.com/openchatui/openchat/commit/3e273a3483a6d29a2ef904d9c60247bd725d90d8) + +### Changed +- Migrated Whisper transcription worker to @huggingface/transformers with WebGPU/CPU fallback and browser caching. (https://github.com/openchatui/openchat/pull/110) + ## [0.1.29] - 2025-11-01 ### PR: [admin mobile, auth fixes](https://github.com/openchatui/openchat/pull/104) diff --git a/package.json b/package.json index 4d9d166..6ab5864 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchatui", - "version": "0.1.29", + "version": "0.1.30", "private": true, "scripts": { "dev": "next dev --turbopack", From 1255768bb9aff731550f2ad9d259922e8f8eb41d Mon Sep 17 00:00:00 2001 From: apensotti Date: Tue, 4 Nov 2025 00:13:54 +0000 Subject: [PATCH 11/24] fix: update profile image URL path in POST request response --- app/api/v1/users/profile-image/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/api/v1/users/profile-image/route.ts b/app/api/v1/users/profile-image/route.ts index 43f607e..c29b7bb 100644 --- a/app/api/v1/users/profile-image/route.ts +++ b/app/api/v1/users/profile-image/route.ts @@ -63,7 +63,7 @@ export async function POST(request: NextRequest): Promise { const filePath = path.join(profilesDir, filename) await writeFile(filePath, buffer) - const url = `/data/profiles/${filename}` + const url = `/profiles/${filename}` return NextResponse.json({ url }) } catch (error) { return NextResponse.json({ error: "Upload failed" }, { status: 500 }) From fc8c86092e05f4b2344db68e477cd210a9f8f416 Mon Sep 17 00:00:00 2001 From: apensotti Date: Tue, 4 Nov 2025 00:32:50 +0000 Subject: [PATCH 12/24] feat: implement local state management for model activation toggle --- components/admin/models/model-item.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/components/admin/models/model-item.tsx b/components/admin/models/model-item.tsx index b66c8ff..4e0b7e9 100644 --- a/components/admin/models/model-item.tsx +++ b/components/admin/models/model-item.tsx @@ -3,6 +3,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" +import { useEffect, useState } from "react" import { Edit } from "lucide-react" import type { Model } from '@/types/model.types' import type { UpdateModelData } from '@/types/model.types' @@ -17,6 +18,11 @@ interface ModelItemProps { export function ModelItem({ model, onToggleActive, onUpdateModel, isUpdating }: ModelItemProps) { const profileImageUrl = model.meta?.profile_image_url || "/OpenChat.png" + const [localActive, setLocalActive] = useState(model.isActive) + + useEffect(() => { + setLocalActive(model.isActive) + }, [model.isActive]) return (
    @@ -43,13 +49,16 @@ export function ModelItem({ model, onToggleActive, onUpdateModel, isUpdating }: { - if (isUpdating) return - onToggleActive(model.id, checked) + checked={localActive} + onCheckedChange={async (checked) => { + const previous = localActive + setLocalActive(checked) + try { + await onToggleActive(model.id, checked) + } catch { + setLocalActive(previous) + } }} - aria-disabled={isUpdating} - className={isUpdating ? "opacity-60" : undefined} />
    From c612369350918ec6753ce23d380b0527a213f587 Mon Sep 17 00:00:00 2001 From: apensotti Date: Tue, 4 Nov 2025 00:54:57 +0000 Subject: [PATCH 13/24] feat: add toast notifications for toggle actions in admin components --- components/admin/audio/AdminAudio.tsx | 13 +++++++++++-- .../admin/code-interpreter/AdminCodeInterpreter.tsx | 7 ++++++- .../admin/connections/ollama-connection-form.tsx | 8 +++++++- .../admin/connections/openai-connection-form.tsx | 6 +++++- components/admin/websearch/AdminWebSearch.tsx | 7 ++++++- 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/components/admin/audio/AdminAudio.tsx b/components/admin/audio/AdminAudio.tsx index 3f1af06..115384e 100644 --- a/components/admin/audio/AdminAudio.tsx +++ b/components/admin/audio/AdminAudio.tsx @@ -11,6 +11,7 @@ import { Label } from "@/components/ui/label" import { Separator } from "@/components/ui/separator" import { AnimatedLoader } from "@/components/ui/loader" import { MESSAGES } from "@/constants/audio" +import { toast } from "sonner" import { useAudio } from "@/hooks/audio/useAudio" import { OpenAISttConnectionForm } from "@/components/admin/audio/OpenAISttConnectionForm" import { DeepgramSttConnectionForm } from "@/components/admin/audio/DeepgramSttConnectionForm" @@ -67,7 +68,11 @@ export function AdminAudio({ session, initialChats = [], initialOpenAI, initialE

    {MESSAGES.TTS_ENABLE_HINT}

    - + { + toggleTtsEnabled(Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + toast.success(`${action} TTS`) + }} />
    @@ -116,7 +121,11 @@ export function AdminAudio({ session, initialChats = [], initialOpenAI, initialE

    {MESSAGES.STT_ENABLE_HINT}

    - + { + toggleSttEnabled(Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + toast.success(`${action} STT`) + }} />
    diff --git a/components/admin/code-interpreter/AdminCodeInterpreter.tsx b/components/admin/code-interpreter/AdminCodeInterpreter.tsx index f789d6d..c72fcff 100644 --- a/components/admin/code-interpreter/AdminCodeInterpreter.tsx +++ b/components/admin/code-interpreter/AdminCodeInterpreter.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Separator } from "@/components/ui/separator" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { toast } from "sonner" interface AdminCodeInterpreterProps { session: Session | null @@ -64,7 +65,11 @@ export function AdminCodeInterpreter({ session, initialConfig }: AdminCodeInterp

    Allow the assistant to execute code via the configured runtime.

    - + { + setEnabled(Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + toast.success(`${action} Code Interpreter`) + }} />
diff --git a/components/admin/connections/ollama-connection-form.tsx b/components/admin/connections/ollama-connection-form.tsx index 6951829..5eba09d 100644 --- a/components/admin/connections/ollama-connection-form.tsx +++ b/components/admin/connections/ollama-connection-form.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { MESSAGES, PLACEHOLDERS } from "@/constants/connections" +import { toast } from "sonner" import type { NewOllamaConnection, Connection } from "@/types/connections.types" interface OllamaConnectionFormProps { @@ -40,7 +41,12 @@ export function OllamaConnectionForm({
onToggleOllamaEnabled?.(Boolean(checked))} + onCheckedChange={(checked) => { + onToggleOllamaEnabled?.(Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + const url = existingOllamaConnections[0]?.baseUrl + toast.success(`${action} Ollama connection${url ? `: ${url}` : ''}`) + }} />
diff --git a/components/admin/connections/openai-connection-form.tsx b/components/admin/connections/openai-connection-form.tsx index a018ba7..66e432b 100644 --- a/components/admin/connections/openai-connection-form.tsx +++ b/components/admin/connections/openai-connection-form.tsx @@ -115,7 +115,11 @@ export function OpenAIConnectionForm({
onToggleEnable?.(idx, Boolean(checked))} + onCheckedChange={(checked) => { + onToggleEnable?.(idx, Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + toast.success(`${action} OpenAI connection: ${connection.baseUrl}`) + }} />
diff --git a/components/admin/websearch/AdminWebSearch.tsx b/components/admin/websearch/AdminWebSearch.tsx index 15ea4ea..25bb26a 100644 --- a/components/admin/websearch/AdminWebSearch.tsx +++ b/components/admin/websearch/AdminWebSearch.tsx @@ -11,6 +11,7 @@ import { useAdminWebSearch } from "@/hooks/useAdminWebSearch" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { GooglePSEConnectionForm } from "./GooglePSEConnectionForm" import { BrowserlessConnectionForm } from "./BrowserlessConnectionForm" +import { toast } from "sonner" interface AdminWebSearchProps { session: Session | null @@ -76,7 +77,11 @@ export function AdminWebSearch({ session, initialChats = [], initialEnabled = fa setEnabled(Boolean(v))} + onCheckedChange={(v) => { + setEnabled(Boolean(v)) + const action = v ? 'Enabled' : 'Disabled' + toast.success(`${action} Web Search`) + }} disabled={isLoading} onBlur={() => persistEnabled()} /> From c766c005df9daad45515cfaf92e5e808359ebf89 Mon Sep 17 00:00:00 2001 From: apensotti Date: Tue, 4 Nov 2025 01:16:30 +0000 Subject: [PATCH 14/24] feat: enhance usePinnedModels hook to support initial pinned models and prevent unnecessary refetches --- hooks/models/usePinnedModels.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/hooks/models/usePinnedModels.ts b/hooks/models/usePinnedModels.ts index 6ba0d2e..816dce4 100644 --- a/hooks/models/usePinnedModels.ts +++ b/hooks/models/usePinnedModels.ts @@ -23,7 +23,12 @@ export function usePinnedModels( currentUserId?: string | null, options?: UsePinnedModelsOptions, ): UsePinnedModelsResult { - const [pinnedIds, setPinnedIds] = useState([]) + const initialIds = useMemo(() => { + const initial = options?.initialPinnedModels + return Array.isArray(initial) ? initial.map((m) => m.id) : [] + }, [options?.initialPinnedModels]) + + const [pinnedIds, setPinnedIds] = useState(initialIds) const [pinnedModels, setPinnedModels] = useState(() => { const initial = options?.initialPinnedModels return Array.isArray(initial) ? [...initial] : [] @@ -60,9 +65,13 @@ export function usePinnedModels( }, [currentUserId, allModels]) useEffect(() => { - // Initial load + // Skip initial refresh if caller provided initial pinned models to avoid + // unnecessary refetches during mount/unmount cycles (e.g., mobile sidebar). + if (Array.isArray(options?.initialPinnedModels) && options.initialPinnedModels.length > 0) { + return + } void refresh() - }, [refresh]) + }, [refresh, options?.initialPinnedModels]) useEffect(() => { const handler = () => { void refresh() } From 70f0d721b9604e384225bfd37dfd6b43a6b086e6 Mon Sep 17 00:00:00 2001 From: apensotti Date: Tue, 4 Nov 2025 01:37:37 +0000 Subject: [PATCH 15/24] fix: adjust padding in ChatInput and PromptSuggestions components for improved layout --- components/chat/chat-input.tsx | 2 +- components/chat/prompt-suggestions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx index bf205ae..36e8e56 100644 --- a/components/chat/chat-input.tsx +++ b/components/chat/chat-input.tsx @@ -366,7 +366,7 @@ export function ChatInput({
{isLive && sttAllowed ? (
diff --git a/components/chat/prompt-suggestions.tsx b/components/chat/prompt-suggestions.tsx index 5a638f2..75451f4 100644 --- a/components/chat/prompt-suggestions.tsx +++ b/components/chat/prompt-suggestions.tsx @@ -53,7 +53,7 @@ interface PromptSuggestionsProps { export function PromptSuggestions({ onSelect, disabled = false }: PromptSuggestionsProps) { return ( -
+
Suggestions From 1d13e4c9c71ad11ab9de455f6627ade4218d4268 Mon Sep 17 00:00:00 2001 From: apensotti Date: Tue, 4 Nov 2025 01:43:18 +0000 Subject: [PATCH 16/24] fix: update max-width for message component and prevent auto-focus on model selector popover --- components/ai/message.tsx | 2 +- components/chat/model-selector.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/components/ai/message.tsx b/components/ai/message.tsx index 2d2c7f6..fd308ec 100644 --- a/components/ai/message.tsx +++ b/components/ai/message.tsx @@ -16,7 +16,7 @@ export const Message = ({ className, from, ...props }: MessageProps) => ( className={cn( 'group flex w-full items-end justify-end gap-2 py-4', from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end', - '[&>div]:max-w-[80%]', + '[&>div]:max-w-[100%]', className )} {...props} diff --git a/components/chat/model-selector.tsx b/components/chat/model-selector.tsx index ee668ff..9195aea 100644 --- a/components/chat/model-selector.tsx +++ b/components/chat/model-selector.tsx @@ -188,9 +188,10 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur - e.preventDefault()} > From e45b36f5d5ab115bddc268c0128c299fa3b06f31 Mon Sep 17 00:00:00 2001 From: Alex <126031796+apensotti@users.noreply.github.com> Date: Mon, 3 Nov 2025 21:42:48 -0500 Subject: [PATCH 17/24] feat: integrate active Ollama models fetching and enhance model activation logic in ModelSelector (#114) --- app/api/v1/ollama/active_models/route.ts | 6 +- components/chat/model-selector.tsx | 77 ++++++++++++++++++++++-- 2 files changed, 77 insertions(+), 6 deletions(-) diff --git a/app/api/v1/ollama/active_models/route.ts b/app/api/v1/ollama/active_models/route.ts index 29251e1..cc5b435 100644 --- a/app/api/v1/ollama/active_models/route.ts +++ b/app/api/v1/ollama/active_models/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' +import * as ConnectionsRepo from '@/lib/db/connections.db' /** * @swagger @@ -29,8 +30,9 @@ import { NextRequest, NextResponse } from 'next/server' export async function GET(request: NextRequest) { try { const { searchParams } = new URL(request.url) - const base = (searchParams.get('baseUrl') || 'http://localhost:11434').trim() - const apiKey = searchParams.get('apiKey') + const providerConn = await ConnectionsRepo.getProviderConnection('ollama').catch(() => null) + const base = (searchParams.get('baseUrl') || providerConn?.baseUrl || 'http://localhost:11434').trim() + const apiKey = searchParams.get('apiKey') || providerConn?.apiKey || undefined // Validate URL format try { new URL(base) } catch { diff --git a/components/chat/model-selector.tsx b/components/chat/model-selector.tsx index 9195aea..000716c 100644 --- a/components/chat/model-selector.tsx +++ b/components/chat/model-selector.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useMemo } from "react" +import { useState, useEffect, useMemo, useRef } from "react" import { useSearchParams } from "next/navigation" import { Check, ChevronsUpDown, Cpu, Link as LinkIcon, MoreHorizontal, PanelLeft } from "lucide-react" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" @@ -40,6 +40,8 @@ interface ModelSelectorProps { export function ModelSelector({ selectedModelId, onModelSelect, models = [], currentUserId }: ModelSelectorProps) { const [open, setOpen] = useState(false) const [userSelectedModel, setUserSelectedModel] = useState(null) + const [ollamaActiveNames, setOllamaActiveNames] = useState>(new Set()) + const pollRef = useRef | null>(null) const { pinnedIds, pin, unpin } = usePinnedModels(currentUserId, { allModels: models }) const { setOpenMobile } = useSidebar() const searchParams = useSearchParams() @@ -80,6 +82,26 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur } const isOllamaActive = (model: Model): boolean => { + // Prefer live data from /api/v1/ollama/active_models if available + if (ollamaActiveNames.size > 0) { + const normalize = (v: string) => v.trim().toLowerCase() + const tokens = new Set() + tokens.add(normalize(model.name)) + const providerId = typeof (model as unknown as { providerId?: unknown }).providerId === 'string' + ? (model as unknown as { providerId?: string }).providerId + : undefined + if (providerId) { + tokens.add(normalize(providerId)) + const suffix = providerId.split('/').pop() || providerId + tokens.add(normalize(suffix)) + tokens.add(normalize((suffix.split(':')[0] || suffix))) + } + for (const t of tokens) { + if (ollamaActiveNames.has(t) || ollamaActiveNames.has(t.split(':')[0])) return true + } + return false + } + // Fallback to meta flag if present return Boolean(model.meta?.details?.runtime_active) } @@ -122,6 +144,56 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur } }, [searchParams, activeModels, onModelSelect, userSelectedModel]) + // Fetch active Ollama models and maintain a set of active names (normalized) + useEffect(() => { + let aborted = false + const normalize = (v: string) => v.trim().toLowerCase() + + const fetchActive = async () => { + try { + const res = await fetch('/api/v1/ollama/active_models', { method: 'GET', cache: 'no-store' }) + if (!res.ok) { + setOllamaActiveNames(new Set()) + return + } + const raw: unknown = await res.json().catch(() => null) + const next = new Set() + if (raw && typeof raw === 'object' && raw !== null) { + const modelsArr = Array.isArray((raw as any).models) ? (raw as any).models : [] + for (const item of modelsArr) { + if (!item || typeof item !== 'object') continue + const name: unknown = (item as any).name ?? (item as any).model + if (typeof name === 'string' && name.trim()) { + next.add(normalize(name)) + next.add(normalize(name.split(':')[0])) + const suffix = name.split('/').pop() || name + next.add(normalize(suffix)) + next.add(normalize((suffix.split(':')[0] || suffix))) + } + } + } + if (!aborted) setOllamaActiveNames(next) + } catch { + if (!aborted) setOllamaActiveNames(new Set()) + } + } + + // Initial fetch + fetchActive() + + // Lightweight polling + if (pollRef.current) clearInterval(pollRef.current) + pollRef.current = setInterval(fetchActive, 10000) + + return () => { + aborted = true + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + } + }, []) + const handlePinModelById = async (modelId: string) => { await pin(modelId) } const handleUnpinModelById = async (modelId: string) => { await unpin(modelId) } @@ -169,9 +241,6 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur {getParameterSize(selectedModel)} )} - {isOllamaActive(selectedModel) && ( - - )}
) : ( From 3687f5ef41f530b582fde9a0bc8e3d4bbc2dab58 Mon Sep 17 00:00:00 2001 From: Alex <126031796+apensotti@users.noreply.github.com> Date: Wed, 12 Nov 2025 00:00:42 -0500 Subject: [PATCH 18/24] Merge documents-attachments-and-context into dev (#117) * feat: add file upload and camera capture functionality to ChatInput component * feat: refactor ChatInput component to improve structure and enhance functionality with new subcomponents for voice recording, attachments, and action buttons * feat: introduce outlineAlt button variant and update styles in ChatInput component for improved visual feedback * feat: enhance ChatInput component with mention functionality and recent file suggestions for improved user experience * refactor: update parameter naming from fileId to id in drive file routes for consistency * feat: implement file attachment handling in chat components, allowing users to upload and reference files in messages * feat: enhance ChatInput component with folder browsing and file selection capabilities, improving user interaction with file management * refactor: remove unused integration hook from FilesLeftSidebar component, simplifying the logic for Google Drive visibility * feat: enhance chat creation and message handling with support for attachments, improving message structure and validation * feat: add nested dropdown menus for referencing chats and selecting drive files in ChatInput component, enhancing file management and user interaction * feat: enhance chat components with support for referenced chats, improving context management and user experience * feat: enhance chat and drive components with URL parameter handling for context files, improving user experience and file management * feat: implement confirmation dialog for chat deletion in NavChats component, enhancing user experience and preventing accidental deletions * feat: improve ZoomableImage component with enhanced scaling logic and content visibility management for better user experience * feat: update chat-landing component by removing unused URL parameter handling and simplifying state management, enhancing code clarity and performance --- .gitignore | 4 + app/api/v1/chat/attachments/route.ts | 120 ++ app/api/v1/chat/route.ts | 123 +- app/api/v1/chats/route.ts | 83 +- .../file/[id]/content/[...filename]/route.ts | 183 ++ app/api/v1/drive/file/[id]/download/route.ts | 10 +- app/api/v1/drive/file/[id]/export/route.ts | 10 +- app/api/v1/drive/file/[id]/route.ts | 66 +- .../v1/drive/file/[id]/signed-url/route.ts | 92 + app/api/v1/drive/file/[id]/sync/route.ts | 18 +- app/api/v1/drive/file/route.ts | 72 + app/api/v1/drive/file/upload/route.ts | 19 +- app/api/v1/drive/files/recent/route.ts | 66 + app/api/v1/drive/files/search/route.ts | 75 + app/api/v1/drive/roots/route.ts | 56 + components/chat/chat-input.tsx | 1652 ++++++++++++++--- components/chat/chat-landing.tsx | 58 +- components/chat/chat-messages.tsx | 126 +- components/chat/chat-standard.tsx | 140 +- components/drive/FilesLeftSidebar.tsx | 4 +- components/drive/FilesResultsTable.tsx | 107 +- components/drive/ZoomableImage.tsx | 142 +- components/sidebar/nav-chats.tsx | 79 +- components/ui/button.tsx | 2 + hooks/useChatStreaming.ts | 149 +- lib/api/chats.ts | 16 +- lib/modules/chat/chat.client-store.ts | 137 +- lib/modules/drive/db.service.ts | 52 + lib/utils/file-icons.tsx | 113 ++ 29 files changed, 3305 insertions(+), 469 deletions(-) create mode 100644 app/api/v1/chat/attachments/route.ts create mode 100644 app/api/v1/drive/file/[id]/content/[...filename]/route.ts create mode 100644 app/api/v1/drive/file/[id]/signed-url/route.ts create mode 100644 app/api/v1/drive/file/route.ts create mode 100644 app/api/v1/drive/files/recent/route.ts create mode 100644 app/api/v1/drive/files/search/route.ts create mode 100644 app/api/v1/drive/roots/route.ts create mode 100644 lib/utils/file-icons.tsx diff --git a/.gitignore b/.gitignore index 000f3c6..416f26d 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ node_modules/ # editor folders .cursor/ .vscode/ + +# local docker compose overrides +docker-compose.override.yml +docker-compose.local.yml diff --git a/app/api/v1/chat/attachments/route.ts b/app/api/v1/chat/attachments/route.ts new file mode 100644 index 0000000..9641abb --- /dev/null +++ b/app/api/v1/chat/attachments/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' +import { randomUUID } from 'crypto' +import db from '@/lib/db' +import { getRootFolderId } from '@/lib/modules/drive' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * @swagger + * /api/v1/chat/attachments: + * post: + * tags: [Chat] + * summary: Upload a file attachment for chat messages + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * responses: + * 200: + * description: File uploaded successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * fileId: + * type: string + * filename: + * type: string + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 500: + * description: Upload failed + */ +export async function POST(request: NextRequest): Promise { + try { + console.log('[DEBUG] Chat attachment upload received') + const session = await auth() + const userId = session?.user?.id + console.log('[DEBUG] Auth check:', { hasSession: !!session, userId }) + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get('file') + console.log('[DEBUG] FormData parsed, has file:', !!file) + + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Missing file' }, { status: 400 }) + } + + console.log('[DEBUG] File info:', { name: file.name, size: file.size, type: file.type }) + + // Validate file size (max 20MB for chat attachments) + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const maxBytes = 20 * 1024 * 1024 + if (buffer.length > maxBytes) { + return NextResponse.json({ error: 'File too large (max 20MB)' }, { status: 400 }) + } + + const fileId = randomUUID() + const ext = file.name.includes('.') ? '.' + file.name.split('.').pop() : '' + const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Get root folder ID + const rootFolderId = await getRootFolderId(userId) + + // Save to data/files/{rootFolderId}/ + const dataDir = path.join(process.cwd(), 'data', 'files', rootFolderId) + await mkdir(dataDir, { recursive: true }) + + const filename = `${Date.now()}-${sanitizedName}` + const filePath = path.join(dataDir, filename) + await writeFile(filePath, buffer) + + console.log('[DEBUG] File saved to:', filePath) + + // Save to database + const nowSec = Math.floor(Date.now() / 1000) + await db.file.create({ + data: { + id: fileId, + userId, + filename, + parentId: rootFolderId, + meta: { originalName: file.name, mimeType: file.type }, + createdAt: nowSec, + updatedAt: nowSec, + path: `/data/files/${rootFolderId}/${filename}`, + }, + }) + + console.log('[DEBUG] File saved to DB:', fileId) + + return NextResponse.json({ ok: true, fileId, filename }) + } catch (error) { + console.error('[ERROR] POST /api/v1/chat/attachments error:', error) + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + } +} + diff --git a/app/api/v1/chat/route.ts b/app/api/v1/chat/route.ts index 428b225..a1fed8d 100644 --- a/app/api/v1/chat/route.ts +++ b/app/api/v1/chat/route.ts @@ -167,14 +167,109 @@ export async function POST(req: NextRequest) { const mergedTools = await ToolsService.buildTools({ enableWebSearch, enableImage, enableVideo }) const toolsEnabled = Boolean(mergedTools) + // Helper: Convert UIMessages with attachments to ModelMessages manually + const convertMessagesToModelFormat = (messages: UIMessage[]): any[] => { + return messages.map((msg) => { + const meta = (msg as any).metadata + const attachments = meta?.attachments + + // Assistant messages - handle text and tool calls + if (msg.role === 'assistant') { + const textParts = msg.parts.filter((p: any) => p.type === 'text') + const toolParts = msg.parts.filter((p: any) => + typeof p.type === 'string' && p.type.startsWith('tool-') + ) + + // If has tool calls, need special handling + if (toolParts.length > 0) { + const textContent = textParts.map((p: any) => p.text || '').join('') + const toolCalls = toolParts.map((p: any) => ({ + type: 'tool-call', + toolCallId: (p as any).toolCallId, + toolName: (p as any).type?.replace('tool-', ''), + args: (p as any).input || {} + })) + + return { + role: 'assistant', + content: [ + { type: 'text', text: textContent }, + ...toolCalls + ] + } + } + + // Simple text response + const textContent = textParts.map((p: any) => p.text || '').join('') + return { + role: 'assistant', + content: textContent + } + } + + // User messages - check for attachments + if (msg.role === 'user') { + const textContent = msg.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text || '') + .join('') + + // If no attachments, return simple text + if (!Array.isArray(attachments) || attachments.length === 0) { + return { + role: 'user', + content: textContent + } + } + + // With attachments, use array of content parts + const contentParts: any[] = [ + ...attachments.map((att: any) => { + if (att.type === 'image') { + return { type: 'image', image: att.image, mediaType: att.mediaType } + } else { + return { type: 'file', data: att.data, mediaType: att.mediaType } + } + }), + { type: 'text', text: textContent } + ] + + return { + role: 'user', + content: contentParts + } + } + + // System messages + if (msg.role === 'system') { + const textContent = msg.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text || '') + .join('') + return { + role: 'system', + content: textContent + } + } + + return msg + }) + } + try { // First attempt: no trimming; if provider throws context error, we'll retry with trimmed payload - const validatedFull = await validateUIMessages({ messages: fullMessages }); + console.log('[DEBUG] Processing messages, count:', fullMessages.length); + console.log('[DEBUG] Last message metadata:', JSON.stringify((fullMessages[fullMessages.length - 1] as any)?.metadata?.attachments, null, 2)); + + // Convert UIMessages to ModelMessages manually to handle attachments + const modelMessages = convertMessagesToModelFormat(fullMessages) + + console.log('[DEBUG] Model messages created, count:', modelMessages.length); + console.log('[DEBUG] Last model message:', JSON.stringify(modelMessages[modelMessages.length - 1], null, 2).slice(0, 800)); + const result = streamText({ model: modelHandle, - messages: convertToModelMessages( - validatedFull as UIMessage[] - ), + messages: modelMessages, system: combinedSystem, experimental_transform: smoothStream({ delayInMs: 10, // optional: defaults to 10ms @@ -202,23 +297,37 @@ export async function POST(req: NextRequest) { tools: mergedTools as any, }); const toUIArgs = StreamUtils.buildToUIMessageStreamArgs( - validatedFull as UIMessage[], + fullMessages as UIMessage[], selectedModelInfo, { finalChatId, userId }, undefined, ); return result.toUIMessageStreamResponse(toUIArgs); } catch (err: any) { + console.error('[ERROR] Chat API streamText failed:', err); + console.error('[ERROR] Error stack:', err?.stack); + console.error('[ERROR] Error message:', err?.message); + console.error('[ERROR] Error code:', (err as any)?.code); + console.error('[ERROR] Full error object:', JSON.stringify(err, Object.getOwnPropertyNames(err), 2)); + const msg = String(err?.message || '') const code = String((err as any)?.code || '') const isContextError = code === 'context_length_exceeded' || /context length|too many tokens|maximum context/i.test(msg) - const status = isContextError ? 413 : 502 + + // Check for vision/image support errors + const isVisionError = + /vision|image|multimodal|not supported/i.test(msg) || + /invalid.*content.*type/i.test(msg) + + const status = isContextError ? 413 : (isVisionError ? 400 : 502) const body = { error: isContextError ? 'Your message is too long for this model. Please shorten it or switch models.' - : 'Failed to generate a response. Please try again.', + : isVisionError + ? `This model does not support image inputs. Please select a vision-capable model (e.g., GPT-4 Vision, Claude 3, Gemini Pro Vision). Details: ${msg}` + : `Failed to generate a response. Error: ${msg}`, } return new Response(JSON.stringify(body), { status, diff --git a/app/api/v1/chats/route.ts b/app/api/v1/chats/route.ts index ec79b64..ef362da 100644 --- a/app/api/v1/chats/route.ts +++ b/app/api/v1/chats/route.ts @@ -1,6 +1,7 @@ import { auth } from "@/lib/auth"; import { NextRequest, NextResponse } from 'next/server'; import { ChatStore } from '@/lib/modules/chat'; +import { z } from 'zod'; // Allow streaming responses up to 30 seconds export const maxDuration = 30; @@ -12,6 +13,49 @@ export const runtime = 'nodejs'; * post: * tags: [Chats] * summary: Create a new chat + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: object + * properties: + * text: + * type: string + * model: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * profile_image_url: + * type: string + * nullable: true + * attachments: + * type: array + * items: + * oneOf: + * - type: object + * required: [type, image, mediaType] + * properties: + * type: { type: string, enum: [image] } + * image: { type: string, description: "Signed URL or data: URI" } + * mediaType: { type: string } + * fileId: { type: string } + * localId: { type: string } + * - type: object + * required: [type, data, mediaType, filename] + * properties: + * type: { type: string, enum: [file] } + * data: { type: string, description: "Signed URL or data: URI" } + * mediaType: { type: string } + * filename: { type: string } + * fileId: { type: string } + * localId: { type: string } * responses: * 200: * description: Chat created @@ -45,15 +89,47 @@ export async function POST(req: NextRequest) { // Optionally seed chat with an initial user message let initialMessage: any | undefined = undefined; try { - const body = await req.json(); - if (body && typeof body === 'object' && body.message && typeof body.message.text === 'string') { + const BodySchema = z.object({ + message: z.object({ + text: z.string(), + model: z.object({ + id: z.string(), + name: z.string().optional(), + profile_image_url: z.string().nullable().optional(), + }).optional(), + attachments: z.array( + z.union([ + z.object({ + type: z.literal('image'), + image: z.string(), + mediaType: z.string(), + fileId: z.string().optional(), + localId: z.string().optional(), + }), + z.object({ + type: z.literal('file'), + data: z.string(), + mediaType: z.string(), + filename: z.string(), + fileId: z.string().optional(), + localId: z.string().optional(), + }), + ]) + ).optional(), + }).optional(), + initialMessage: z.any().optional(), // ignored if provided; we rebuild below + }).optional(); + const raw = await req.json(); + const body = BodySchema.parse(raw); + if (body && body.message && typeof body.message.text === 'string') { const text: string = body.message.text; - const model = body.message.model && typeof body.message.model === 'object' ? body.message.model : undefined; + const model = body.message.model; const modelId = typeof model?.id === 'string' ? model.id : undefined; const modelName = typeof model?.name === 'string' ? model.name : undefined; const modelImage = (typeof model?.profile_image_url === 'string' || model?.profile_image_url === null) ? model.profile_image_url : undefined; + const attachments = Array.isArray(body.message.attachments) ? body.message.attachments : undefined; initialMessage = { id: `msg_${Date.now()}`, @@ -64,6 +140,7 @@ export async function POST(req: NextRequest) { ...(modelId || modelName || typeof modelImage !== 'undefined' ? { model: { id: modelId ?? 'unknown', name: modelName ?? 'Unknown Model', profile_image_url: modelImage ?? null } } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), }, }; } diff --git a/app/api/v1/drive/file/[id]/content/[...filename]/route.ts b/app/api/v1/drive/file/[id]/content/[...filename]/route.ts new file mode 100644 index 0000000..22dfd39 --- /dev/null +++ b/app/api/v1/drive/file/[id]/content/[...filename]/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import db from '@/lib/db' +import { Readable } from 'stream' +import { getGoogleDriveFileStream } from '@/lib/modules/drive/providers/google-drive.service' +import { verify } from 'jsonwebtoken' +import { readFile } from 'fs/promises' +import path from 'path' + +function getMimeTypeFromFilename(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() + const mimeTypes: Record = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'json': 'application/json', + 'xml': 'text/xml', + 'mp4': 'video/mp4', + 'mp3': 'audio/mpeg', + } + return mimeTypes[ext || ''] || 'application/octet-stream' +} + +/** + * @swagger + * /api/v1/drive/file/{id}/content/{filename}: + * get: + * tags: [Drive] + * summary: Stream a Google Drive file (supports signed URLs) + * description: | + * Streams the raw bytes of a Google Drive file. Authenticated users can access their own files directly. + * For external consumers (e.g., AI model web fetch), provide a `token` query param containing a short-lived signed token. + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: path + * name: filename + * required: false + * schema: + * type: string + * description: Optional filename for pretty URLs; ignored by server. + * - in: query + * name: token + * required: false + * schema: + * type: string + * description: Signed access token for cookie-less access (e.g., external fetchers). + * responses: + * 200: + * description: File stream + * content: + * application/octet-stream: + * schema: + * type: string + * format: binary + * 400: + * description: Invalid parameters + * 401: + * description: Unauthorized + * 404: + * description: File not found + * 500: + * description: Failed to fetch file + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string; filename?: string[] }> } +) { + try { + const { id } = await params + if (!id) { + return NextResponse.json({ error: 'File ID required' }, { status: 400 }) + } + + const url = new URL(req.url) + const tokenParam = url.searchParams.get('token') + + let userId: string | null = null + + if (tokenParam) { + // Verify signed token for cookie-less access + const secret = process.env.TOKEN_SECRET || process.env.AUTH_SECRET || '' + try { + const decoded = verify(tokenParam, secret) as { sub?: string; userId?: string } + if (!decoded || (decoded.sub !== id && decoded.userId == null)) { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + } + // Prefer explicit userId from token; otherwise resolve from DB by id + userId = decoded.userId ?? null + } catch (err) { + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }) + } + } + + if (!userId) { + // Fallback to session auth + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + userId = session.user.id + } + + // If userId still not known (token without userId), try resolving owner from DB + if (!userId) { + const fileRow = await db.file.findFirst({ where: { id }, select: { userId: true } }) + if (!fileRow?.userId) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + userId = fileRow.userId + } + + // Check if file is stored locally or in Google Drive + const fileRecord = await db.file.findFirst({ + where: { id, userId }, + select: { path: true, filename: true, meta: true } + }) + + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + // If path starts with /data/files/, it's a local file + if (fileRecord.path && fileRecord.path.startsWith('/data/files/')) { + const localPath = path.join(process.cwd(), fileRecord.path) + + try { + const buffer = await readFile(localPath) + + // Determine mime type + const mimeType = (fileRecord.meta as any)?.mimeType || + getMimeTypeFromFilename(fileRecord.filename) || + 'application/octet-stream' + + const headers: Record = { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + 'Content-Length': buffer.length.toString() + } + + // Stream the buffer as a Web ReadableStream (widely accepted BodyInit) + const nodeStream = Readable.from(buffer) + const webStream = Readable.toWeb(nodeStream) as ReadableStream + return new NextResponse(webStream, { headers }) + } catch (err) { + console.error('Error reading local file:', err) + return NextResponse.json({ error: 'File not found on disk' }, { status: 404 }) + } + } + + // Otherwise, fetch from Google Drive + const { stream, mimeType, size } = await getGoogleDriveFileStream(userId, id) + + const webStream = Readable.toWeb(stream as any) as ReadableStream + + const headers: Record = { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + } + + if (size) headers['Content-Length'] = size.toString() + + return new NextResponse(webStream, { headers }) + } catch (error: any) { + console.error('Error streaming Drive file (content):', error) + return NextResponse.json( + { error: error?.message ?? 'Failed to fetch file' }, + { status: 500 } + ) + } +} + + diff --git a/app/api/v1/drive/file/[id]/download/route.ts b/app/api/v1/drive/file/[id]/download/route.ts index 7a9255e..548c461 100644 --- a/app/api/v1/drive/file/[id]/download/route.ts +++ b/app/api/v1/drive/file/[id]/download/route.ts @@ -34,7 +34,7 @@ import db from '@/lib/db' */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth() @@ -42,16 +42,16 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { fileId } = await params + const { id } = await params - if (!fileId) { + if (!id) { return NextResponse.json({ error: 'File ID required' }, { status: 400 }) } // Get file metadata from database to get the filename const file = await db.file.findFirst({ where: { - id: fileId, + id: id, userId: session.user.id }, select: { @@ -67,7 +67,7 @@ export async function GET( let filename: string try { - const result = await getGoogleDriveFileStream(session.user.id, fileId) + const result = await getGoogleDriveFileStream(session.user.id, id) stream = result.stream mimeType = result.mimeType size = result.size diff --git a/app/api/v1/drive/file/[id]/export/route.ts b/app/api/v1/drive/file/[id]/export/route.ts index 6b8ebf5..077cd36 100644 --- a/app/api/v1/drive/file/[id]/export/route.ts +++ b/app/api/v1/drive/file/[id]/export/route.ts @@ -39,7 +39,7 @@ import db from '@/lib/db' */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth() @@ -47,16 +47,16 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { fileId } = await params + const { id } = await params - if (!fileId) { + if (!id) { return NextResponse.json({ error: 'File ID required' }, { status: 400 }) } // Get file metadata from database to determine mime type const file = await db.file.findFirst({ where: { - id: fileId, + id: id, userId: session.user.id }, select: { @@ -89,7 +89,7 @@ export async function GET( const { stream, mimeType } = await exportGoogleDriveFile( session.user.id, - fileId, + id, exportMimeType ) diff --git a/app/api/v1/drive/file/[id]/route.ts b/app/api/v1/drive/file/[id]/route.ts index 936ac16..3622eb4 100644 --- a/app/api/v1/drive/file/[id]/route.ts +++ b/app/api/v1/drive/file/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { getGoogleDriveFileStream } from '@/lib/modules/drive/providers/google-drive.service' import { Readable } from 'stream' +import db from '@/lib/db' /** * @swagger @@ -32,7 +33,7 @@ import { Readable } from 'stream' */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth() @@ -40,15 +41,53 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { fileId } = await params + const { id } = await params - if (!fileId) { + if (!id) { return NextResponse.json({ error: 'File ID required' }, { status: 400 }) } + // Check if file is stored locally or in Google Drive + const fileRecord = await db.file.findFirst({ + where: { id, userId: session.user.id }, + select: { path: true, filename: true, meta: true } + }) + + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + // If path starts with /data/files/, it's a local file + if (fileRecord.path && fileRecord.path.startsWith('/data/files/')) { + const { readFile } = await import('fs/promises') + const path = await import('path') + const localPath = path.join(process.cwd(), fileRecord.path) + + try { + const buffer = await readFile(localPath) + + // Determine mime type + const mimeType = (fileRecord.meta as any)?.mimeType || + getMimeTypeFromFilename(fileRecord.filename) || + 'application/octet-stream' + + const headers: Record = { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600', + 'Content-Length': buffer.length.toString() + } + + return new NextResponse(new Uint8Array(buffer), { headers }) + } catch (err) { + console.error('Error reading local file:', err) + return NextResponse.json({ error: 'File not found on disk' }, { status: 404 }) + } + } + + // Otherwise, fetch from Google Drive const { stream, mimeType, size } = await getGoogleDriveFileStream( session.user.id, - fileId + id ) // Convert Node.js stream to Web ReadableStream @@ -75,3 +114,22 @@ export async function GET( } } +function getMimeTypeFromFilename(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() + const mimeTypes: Record = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'json': 'application/json', + 'xml': 'text/xml', + 'mp4': 'video/mp4', + 'mp3': 'audio/mpeg', + } + return mimeTypes[ext || ''] || 'application/octet-stream' +} + diff --git a/app/api/v1/drive/file/[id]/signed-url/route.ts b/app/api/v1/drive/file/[id]/signed-url/route.ts new file mode 100644 index 0000000..1609e43 --- /dev/null +++ b/app/api/v1/drive/file/[id]/signed-url/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import db from '@/lib/db' +import { sign } from 'jsonwebtoken' + +interface BodyInput { + filename?: string + ttlSec?: number +} + +/** + * @swagger + * /api/v1/drive/file/{id}/signed-url: + * post: + * tags: [Drive] + * summary: Create a short-lived signed URL for streaming a Drive file + * description: Returns a URL that streams the file via `/content/{filename}?token=...` for cookie-less access (e.g., AI model fetch). + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * filename: + * type: string + * description: Optional filename to include in the URL path. + * ttlSec: + * type: integer + * description: Time-to-live in seconds (default 3600, max 86400). + * responses: + * 200: + * description: Signed URL generated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: File not found + * 500: + * description: Internal server error + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + if (!id) return NextResponse.json({ error: 'File ID required' }, { status: 400 }) + + // Ensure the file belongs to the current user + const file = await db.file.findFirst({ where: { id, userId: session.user.id }, select: { filename: true } }) + if (!file) return NextResponse.json({ error: 'File not found' }, { status: 404 }) + + const body = (await req.json().catch(() => ({}))) as BodyInput + const rawTtl = typeof body.ttlSec === 'number' ? body.ttlSec : 3600 + const ttlSec = Math.min(Math.max(rawTtl, 60), 86400) + const name = (body.filename && typeof body.filename === 'string') ? body.filename : file.filename + + const secret = process.env.TOKEN_SECRET || process.env.AUTH_SECRET || '' + if (!secret) return NextResponse.json({ error: 'Server secret not configured' }, { status: 500 }) + + const token = sign({ sub: id, userId: session.user.id }, secret, { expiresIn: ttlSec }) + + const base = new URL(req.url) + // Construct content URL: /api/v1/drive/file/{id}/content/{filename}?token=... + const filename = encodeURIComponent(name) + const contentPath = `/api/v1/drive/file/${encodeURIComponent(id)}/content/${filename}` + const signedUrl = new URL(contentPath, `${base.protocol}//${base.host}`) + signedUrl.searchParams.set('token', token) + + return NextResponse.json({ url: signedUrl.toString(), expiresIn: ttlSec }) + } catch (error) { + console.error('POST /api/v1/drive/file/{id}/signed-url error:', error) + return NextResponse.json({ error: 'Failed to generate signed URL' }, { status: 500 }) + } +} + + diff --git a/app/api/v1/drive/file/[id]/sync/route.ts b/app/api/v1/drive/file/[id]/sync/route.ts index 4c4b0b4..a03fcd5 100644 --- a/app/api/v1/drive/file/[id]/sync/route.ts +++ b/app/api/v1/drive/file/[id]/sync/route.ts @@ -74,15 +74,15 @@ async function streamToString(stream: NodeJS.ReadableStream): Promise { */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { fileId } = await params; - if (!fileId) + const { id } = await params; + if (!id) return NextResponse.json({ error: "File ID required" }, { status: 400 }); const { searchParams } = new URL(req.url); @@ -91,7 +91,7 @@ export async function GET( if (mode === "meta") { const modifiedMs = await getGoogleFileModifiedTime( session.user.id, - fileId + id ); return NextResponse.json({ modifiedMs }); } @@ -99,7 +99,7 @@ export async function GET( if (mode === "html") { const { stream } = await exportGoogleDriveFile( session.user.id, - fileId, + id, "text/html" ); const html = await streamToString(stream); @@ -119,15 +119,15 @@ export async function GET( export async function POST( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { fileId } = await params; - if (!fileId) + const { id } = await params; + if (!id) return NextResponse.json({ error: "File ID required" }, { status: 400 }); const body = (await req.json().catch(() => null)) as { html?: string }; @@ -140,7 +140,7 @@ export async function POST( const html = body.html; // Preserve basic formatting in Google Docs - await updateGoogleDocFromHTML(session.user.id, fileId, html); + await updateGoogleDocFromHTML(session.user.id, id, html); return NextResponse.json({ ok: true }); } catch (error: any) { return NextResponse.json( diff --git a/app/api/v1/drive/file/route.ts b/app/api/v1/drive/file/route.ts new file mode 100644 index 0000000..6890a9f --- /dev/null +++ b/app/api/v1/drive/file/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { z } from 'zod' +import { listFilesByParent, getRootFolderId } from '@/lib/modules/drive' + +export const runtime = 'nodejs' + +const Query = z.object({ + parent: z.string().optional(), +}) + +/** + * @swagger + * /api/v1/drive/file: + * get: + * tags: [Drive] + * summary: List files for a parent folder (or root if omitted) + * parameters: + * - in: query + * name: parent + * required: false + * schema: + * type: string + * responses: + * 200: + * description: List of files for the specified parent + * content: + * application/json: + * schema: + * type: object + * properties: + * parentId: + * type: string + * files: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * 401: + * description: Unauthorized + * 400: + * description: Validation error + * 500: + * description: Failed to list files + */ +export async function GET(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const raw = { parent: searchParams.get('parent') || undefined } + const parsed = Query.safeParse(raw) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid query' }, { status: 400 }) + } + + const parent = parsed.data.parent + try { + const effectiveParentId = parent && parent.length > 0 ? parent : await getRootFolderId(session.user.id) + const entries = await listFilesByParent(session.user.id, effectiveParentId) + const files = entries.map(e => ({ id: e.id, name: e.name })) + return NextResponse.json({ parentId: effectiveParentId, files }) + } catch (err: any) { + return NextResponse.json({ error: err?.message || 'Failed to list files' }, { status: 500 }) + } +} + + diff --git a/app/api/v1/drive/file/upload/route.ts b/app/api/v1/drive/file/upload/route.ts index 9275f06..b5c18cb 100644 --- a/app/api/v1/drive/file/upload/route.ts +++ b/app/api/v1/drive/file/upload/route.ts @@ -17,16 +17,19 @@ function ensureDirSync(dir: string) { } export async function POST(req: Request) { + console.log('[DEBUG] Upload POST received') const session = await auth() + console.log('[DEBUG] Upload auth check:', { hasSession: !!session, userId: session?.user?.id }) if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return new Promise((resolve) => { try { const headers = Object.fromEntries(req.headers as any) + console.log('[DEBUG] Creating Busboy with headers:', { contentType: headers['content-type'] }) const bb = Busboy({ headers }) let parent = '' - const saved: { name: string; path: string }[] = [] + const saved: { id: string; name: string; path: string }[] = [] const inserts: Promise[] = [] const insertErrors: any[] = [] @@ -35,7 +38,9 @@ export async function POST(req: Request) { }) bb.on('file', (_name, fileStream, info) => { + console.log('[DEBUG] Busboy file event:', { name: _name, filename: info.filename }) const filename = info.filename || 'file' + const fileId = randomUUID() // Save under data/files//; targetDir updated once we know parent let targetDir = LOCAL_BASE_DIR ensureDirSync(targetDir) @@ -52,7 +57,7 @@ export async function POST(req: Request) { fileStream.pipe(writeStream) writeStream.on('close', () => { const relativeFilePath = path.relative(LOCAL_BASE_DIR, finalPath) - saved.push({ name: path.basename(finalPath), path: relativeFilePath }) + saved.push({ id: fileId, name: path.basename(finalPath), path: relativeFilePath }) // Prepare DB insert for this file const userId = session.user!.id as string @@ -89,7 +94,7 @@ export async function POST(req: Request) { if (client?.file?.create) { await client.file.create({ data: { - id: randomUUID(), + id: fileId, userId, filename: path.basename(finalPath), parentId: resolvedParentId, @@ -101,7 +106,7 @@ export async function POST(req: Request) { }) } else { await db.$executeRaw`INSERT INTO "file" (id, user_id, filename, parent_id, meta, created_at, updated_at, path) - VALUES (${randomUUID()}, ${userId}, ${path.basename(finalPath)}, ${resolvedParentId}, ${JSON.stringify({})}, ${nowSec}, ${nowSec}, ${`/data/files/${resolvedParentId}/${path.basename(finalPath)}`})` + VALUES (${fileId}, ${userId}, ${path.basename(finalPath)}, ${resolvedParentId}, ${JSON.stringify({})}, ${nowSec}, ${nowSec}, ${`/data/files/${resolvedParentId}/${path.basename(finalPath)}`})` } } catch (err) { insertErrors.push(err) @@ -114,12 +119,18 @@ export async function POST(req: Request) { bb.on('finish', async () => { try { + console.log('[DEBUG] Upload finish, saved files:', saved.length) + console.log('[DEBUG] Waiting for DB inserts:', inserts.length) await Promise.all(inserts) + console.log('[DEBUG] DB inserts complete, errors:', insertErrors.length) if (insertErrors.length > 0) { + console.error('[DEBUG] Insert errors:', insertErrors) return resolve(NextResponse.json({ ok: false, error: 'Failed to save some files to the database' }, { status: 500 })) } + console.log('[DEBUG] Returning success response with files:', saved) resolve(NextResponse.json({ ok: true, files: saved })) } catch (e) { + console.error('[DEBUG] Finish handler error:', e) resolve(NextResponse.json({ ok: false, error: 'Database error' }, { status: 500 })) } }) diff --git a/app/api/v1/drive/files/recent/route.ts b/app/api/v1/drive/files/recent/route.ts new file mode 100644 index 0000000..30aa368 --- /dev/null +++ b/app/api/v1/drive/files/recent/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { z } from 'zod' +import { listRecentFiles } from '@/lib/db/drive.db' + +export const runtime = 'nodejs' + +const Query = z.object({ + limit: z.coerce.number().int().min(1).max(50).optional(), +}) + +/** + * @swagger + * /api/v1/drive/files/recent: + * get: + * tags: [Drive] + * summary: List recently edited files (current user) + * parameters: + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * responses: + * 200: + * description: List of recent files + * content: + * application/json: + * schema: + * type: object + * properties: + * files: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * 401: + * description: Unauthorized + * 500: + * description: Failed to list recent files + */ +export async function GET(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const raw = { limit: searchParams.get('limit') || undefined } + const parsed = Query.safeParse(raw) + if (!parsed.success) return NextResponse.json({ error: 'Invalid query' }, { status: 400 }) + const { limit } = parsed.data + + try { + const files = await listRecentFiles(session.user.id, limit ?? 5) + return NextResponse.json({ files }) + } catch (err: any) { + return NextResponse.json({ error: err?.message || 'Failed to list recent files' }, { status: 500 }) + } +} + + diff --git a/app/api/v1/drive/files/search/route.ts b/app/api/v1/drive/files/search/route.ts new file mode 100644 index 0000000..b57f54d --- /dev/null +++ b/app/api/v1/drive/files/search/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { z } from 'zod' +import { searchFilesByName } from '@/lib/db/drive.db' + +export const runtime = 'nodejs' + +const Query = z.object({ + q: z.string().min(1), + limit: z.coerce.number().int().min(1).max(50).optional(), +}) + +/** + * @swagger + * /api/v1/drive/files/search: + * get: + * tags: [Drive] + * summary: Search files by name (current user) + * parameters: + * - in: query + * name: q + * required: true + * schema: + * type: string + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * responses: + * 200: + * description: List of matching files + * content: + * application/json: + * schema: + * type: object + * properties: + * files: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * 401: + * description: Unauthorized + * 400: + * description: Validation error + * 500: + * description: Failed to search files + */ +export async function GET(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const raw = { q: searchParams.get('q') || '', limit: searchParams.get('limit') || undefined } + const parsed = Query.safeParse(raw) + if (!parsed.success) return NextResponse.json({ error: 'Invalid query' }, { status: 400 }) + const { q, limit } = parsed.data + + try { + const files = await searchFilesByName(session.user.id, q, limit ?? 10) + return NextResponse.json({ files }) + } catch (err: any) { + return NextResponse.json({ error: err?.message || 'Failed to search files' }, { status: 500 }) + } +} + + + diff --git a/app/api/v1/drive/roots/route.ts b/app/api/v1/drive/roots/route.ts new file mode 100644 index 0000000..79e298e --- /dev/null +++ b/app/api/v1/drive/roots/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { getRootFolderId, getGoogleRootFolderId } from '@/lib/modules/drive' + +export const runtime = 'nodejs' + +/** + * @swagger + * /api/v1/drive/roots: + * get: + * tags: [Drive] + * summary: Get root folder ids for Local and Google Drive (current user) + * description: Returns the Local root id and, if connected, the Google Drive root id for the authenticated user. + * responses: + * 200: + * description: Root ids fetched + * content: + * application/json: + * schema: + * type: object + * properties: + * localRootId: + * type: string + * nullable: true + * googleRootId: + * type: string + * nullable: true + * 401: + * description: Unauthorized + * 500: + * description: Failed to fetch roots + */ +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + const [localRootId, googleRootId] = await Promise.all([ + getRootFolderId(userId).catch(() => null), + getGoogleRootFolderId(userId).catch(() => null), + ]) + return NextResponse.json({ + localRootId: localRootId || null, + googleRootId: googleRootId || null, + }) + } catch (error: any) { + return NextResponse.json( + { error: error?.message || 'Failed to fetch roots' }, + { status: 500 } + ) + } +} + + diff --git a/components/chat/chat-input.tsx b/components/chat/chat-input.tsx index 36e8e56..c861510 100644 --- a/components/chat/chat-input.tsx +++ b/components/chat/chat-input.tsx @@ -1,13 +1,28 @@ "use client"; -import { useState, useRef, useEffect, useCallback } from "react"; +import { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import type { RefObject, MutableRefObject } from "react"; import dynamic from "next/dynamic"; import { useVoiceInput } from "@/hooks/audio/useVoiceInput"; import { cn } from "@/lib/utils"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from "@/components/ui/command"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Loader } from "@/components/ui/loader"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + // Submenu components for nested dropdowns + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} from "@/components/ui/dropdown-menu"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { useChats } from "@/hooks/useChats"; import { Plus, Mic, @@ -19,7 +34,16 @@ import { AudioWaveform, ArrowUp, Video, + FileUp, + HardDrive, + Camera, + MessageSquare, + Folder, + ChevronRight, + ChevronDown, } from "lucide-react"; +import { RiHardDrive3Line } from "react-icons/ri"; +import { getFileIconCompact } from "@/lib/utils/file-icons"; const RecordingWaveform = dynamic(() => import("./recording-waveform"), { ssr: false }) const LiveCircle = dynamic(() => import("./live-circle"), { ssr: false }) @@ -37,6 +61,7 @@ interface ChatInputProps { image: boolean; video?: boolean; codeInterpreter: boolean; + referencedChats?: { id: string; title?: string | null }[]; }, overrideModel?: any, isAutoSend?: boolean, @@ -44,7 +69,8 @@ interface ChatInputProps { onStart?: () => void onDelta?: (delta: string, fullText: string) => void onFinish?: (finalText: string) => void - } + }, + attachedFiles?: Array<{ file: File; localId: string }> ) => Promise | void; sessionStorageKey?: string; webSearchAvailable?: boolean; @@ -54,6 +80,8 @@ interface ChatInputProps { ttsAllowed?: boolean; } +type BoolSetter = (updater: (prev: boolean) => boolean) => void + export function ChatInput({ placeholder = "Send a Message", disabled, @@ -76,14 +104,26 @@ export function ChatInput({ const [isLive, setIsLive] = useState(false); const isLiveRef = useRef(false) const textareaRef = useRef(null); + const fileInputRef = useRef(null) + const cameraInputRef = useRef(null) const ttsAudioRef = useRef(null) const ttsUrlsRef = useRef>(new Set()) const ttsQueueRef = useRef([]) const ttsPlayingRef = useRef(false) const ttsAbortedRef = useRef(false) + const uploadPreviewUrlsRef = useRef>(new Set()) const pendingTextRef = useRef("") const firstSegmentSentRef = useRef(false) const drainResolverRef = useRef<(() => void) | null>(null) + const [showChatRefDialog, setShowChatRefDialog] = useState(false) + const [selectedFiles, setSelectedFiles] = useState([]) + const [contextFiles, setContextFiles] = useState<{ id: string; name: string; type?: string; previewUrl?: string }[]>([]) + const [mentionOpen, setMentionOpen] = useState(false) + const [mentionTokenStart, setMentionTokenStart] = useState(0) + const [mentionQuery, setMentionQuery] = useState("") + const [mentionResults, setMentionResults] = useState<{ id: string; name: string }[]>([]) + const [mentionHighlight, setMentionHighlight] = useState(0) + const recentFiles = useRecentFilesPrefetch(5) const { isRecording, @@ -108,6 +148,18 @@ export function ChatInput({ isLiveRef.current = isLive }, [isLive]) + // Cleanup any blob URLs created for local image previews + useEffect(() => { + return () => { + try { + for (const url of uploadPreviewUrlsRef.current) { + try { URL.revokeObjectURL(url) } catch {} + } + uploadPreviewUrlsRef.current.clear() + } catch {} + } + }, []) + // Load initial state from sessionStorage if provided useEffect(() => { try { @@ -122,12 +174,14 @@ export function ChatInput({ webSearchEnabled: false, codeInterpreterEnabled: false, videoGenerationEnabled: false, + contextFiles: [] as { id: string; name: string }[], } const data = raw ? { ...defaults, ...JSON.parse(raw) } : defaults if (typeof data.prompt === 'string') setValue(data.prompt) if (typeof data.webSearchEnabled === 'boolean') setWebSearch(data.webSearchEnabled) if (typeof data.imageGenerationEnabled === 'boolean') setImage(data.imageGenerationEnabled) if (typeof (data as any).videoGenerationEnabled === 'boolean') setVideo(Boolean((data as any).videoGenerationEnabled)) + if (Array.isArray((data as any).contextFiles)) setContextFiles(((data as any).contextFiles || []).filter((x: any) => x && typeof x.id === 'string' && typeof x.name === 'string')) } catch {} // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionStorageKey]) @@ -147,6 +201,7 @@ export function ChatInput({ imageGenerationEnabled: false, webSearchEnabled: false, codeInterpreterEnabled: false, + contextFiles: [] as { id: string; name: string }[], } const data = raw ? { ...defaults, ...JSON.parse(raw) } : defaults data.prompt = value @@ -154,6 +209,27 @@ export function ChatInput({ } catch {} }, [value, sessionStorageKey]) + // Persist context files to sessionStorage + useEffect(() => { + try { + if (!sessionStorageKey) return + const raw = sessionStorage.getItem(sessionStorageKey) + const defaults = { + prompt: "", + files: [] as any[], + selectedToolIds: [] as string[], + selectedFilterIds: [] as string[], + imageGenerationEnabled: false, + webSearchEnabled: false, + codeInterpreterEnabled: false, + contextFiles: [] as { id: string; name: string }[], + } + const data = raw ? { ...defaults, ...JSON.parse(raw) } : defaults + ;(data as any).contextFiles = contextFiles + sessionStorage.setItem(sessionStorageKey, JSON.stringify(data)) + } catch {} + }, [contextFiles, sessionStorageKey]) + // Auto-resize the textarea as content grows (with a sensible max height) const resizeTextarea = () => { const el = textareaRef.current; @@ -167,12 +243,71 @@ export function ChatInput({ resizeTextarea(); }, [value]); + // Fallback: inject context from URL params (?cfid, ?cfn) on mount, then clean URL + useEffect(() => { + try { + if (typeof window === 'undefined') return + const loc = new URL(window.location.href) + const cfid = loc.searchParams.get('cfid') || '' + const cfn = loc.searchParams.get('cfn') || '' + if (cfid && cfn) { + setContextFiles((prev) => { + if (prev.some((f) => f.id === cfid)) return prev + return [...prev, { id: cfid, name: cfn }] + }) + // Persist into sessionStorageKey if available + try { + if (sessionStorageKey) { + const raw = sessionStorage.getItem(sessionStorageKey) + const defaults = { + prompt: "", + files: [] as any[], + selectedToolIds: [] as string[], + selectedFilterIds: [] as string[], + imageGenerationEnabled: false, + webSearchEnabled: false, + codeInterpreterEnabled: false, + contextFiles: [] as { id: string; name: string }[], + } + const data = raw ? { ...defaults, ...JSON.parse(raw) } : defaults + const existing = Array.isArray((data as any).contextFiles) ? (data as any).contextFiles : [] + if (!existing.some((f: any) => f && f.id === cfid)) { + ;(data as any).contextFiles = [...existing, { id: cfid, name: cfn }] + sessionStorage.setItem(sessionStorageKey, JSON.stringify(data)) + } + } + } catch {} + } + } catch {} + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); const trimmed = value.trim(); - if (!trimmed) return; - onSubmit?.(trimmed, { webSearch, image, video, codeInterpreter }); + if (!trimmed && contextFiles.length === 0) return; + + // Split context into drive/files vs referenced chats + const referencedChats = contextFiles + .filter((cf) => cf.id.startsWith('chat:')) + .map((cf) => ({ id: cf.id.slice(5), title: cf.name })) + + // Build attachedFiles array with both uploaded files and drive file references (exclude chats) + const attachedFiles = contextFiles.filter((cf) => !cf.id.startsWith('chat:')).map((cf) => { + // Check if it's a locally uploaded file (starts with 'local:') + if (cf.id.startsWith('local:')) { + const file = selectedFiles.find((f) => f.name === cf.name) + return file ? { file, localId: cf.id } : null + } else { + // It's a drive file reference - pass the file ID + return { fileId: cf.id, fileName: cf.name } + } + }).filter((x): x is { file: File; localId: string } | { fileId: string; fileName: string } => x !== null) + + onSubmit?.(trimmed || '', { webSearch, image, video, codeInterpreter, referencedChats }, undefined, false, undefined, attachedFiles as any); setValue(""); + setSelectedFiles([]) + setContextFiles([]) requestAnimationFrame(resizeTextarea); textareaRef.current?.focus(); }; @@ -321,9 +456,12 @@ export function ChatInput({ } try { - onSubmit?.(text, { webSearch, image, codeInterpreter }, undefined, false, streamHandlers) + const referencedChats = contextFiles + .filter((cf) => cf.id.startsWith('chat:')) + .map((cf) => ({ id: cf.id.slice(5), title: cf.name })) + onSubmit?.(text, { webSearch, image, codeInterpreter, referencedChats }, undefined, false, streamHandlers, []) } catch {} - }, [onSubmit, webSearch, image, codeInterpreter, cleanupTts, enqueueTtsSegment, waitForQueueToDrain, startRecording]) + }, [onSubmit, webSearch, image, codeInterpreter, contextFiles, cleanupTts, enqueueTtsSegment, waitForQueueToDrain, startRecording]) const startLive = useCallback(() => { if (disabled || !sttAllowed) return @@ -340,6 +478,32 @@ export function ChatInput({ }, [cancelRecording, cleanupTts]) const handleKeyDown = (e: React.KeyboardEvent) => { + // Remove last context pill when input is empty and Backspace is pressed + if (e.key === 'Backspace' && (value.length === 0 || caretIndex() === 0) && contextFiles.length > 0) { + e.preventDefault() + setContextFiles((prev) => { + if (prev.length === 0) return prev + const next = [...prev] + const removed = next.pop() + if (removed?.previewUrl) { + try { URL.revokeObjectURL(removed.previewUrl); uploadPreviewUrlsRef.current.delete(removed.previewUrl) } catch {} + } + return next + }) + return + } + // Mention dropdown keyboard navigation + if (mentionOpen) { + if (e.key === 'ArrowDown') { e.preventDefault(); setMentionHighlight((i) => (i + 1) % Math.max(displayMentionFiles.length, 1)); return } + if (e.key === 'ArrowUp') { e.preventDefault(); setMentionHighlight((i) => (i - 1 + Math.max(displayMentionFiles.length, 1)) % Math.max(displayMentionFiles.length, 1)); return } + if (e.key === 'Enter') { + e.preventDefault() + const opt = displayMentionFiles[mentionHighlight] + if (opt) selectMentionOption(opt) + return + } + if (e.key === 'Escape') { e.preventDefault(); setMentionOpen(false); return } + } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!isStreaming) { @@ -348,6 +512,92 @@ export function ChatInput({ } }; + // Compute current token at caret for @/# mentions + const caretIndex = useCallback(() => { + const el = textareaRef.current + if (!el) return value.length + try { return el.selectionStart ?? value.length } catch { return value.length } + }, [value]) + + const currentToken = useMemo(() => { + const pos = caretIndex() + const left = value.slice(0, pos) + const lastSpace = Math.max(left.lastIndexOf(" "), left.lastIndexOf("\n"), left.lastIndexOf("\t")) + const start = lastSpace + 1 + const token = left.slice(start) + return { tokenStart: start, tokenText: token } + }, [value, caretIndex]) + + useEffect(() => { + const t = currentToken.tokenText + const startsMention = t.startsWith('@') || t.startsWith('#') + setMentionOpen(startsMention) + setMentionTokenStart(currentToken.tokenStart) + setMentionQuery(startsMention ? t.slice(1) : '') + setMentionHighlight(0) + }, [currentToken]) + + + // Debounced fetch for mention results + useEffect(() => { + let active = true + const q = mentionQuery.trim() + if (!mentionOpen) { setMentionResults([]); return } + // If no query, show preloaded recent files immediately + if (!q && recentFiles.length > 0) { setMentionResults(recentFiles); } + const handle = setTimeout(async () => { + try { + const url = q + ? `/api/v1/drive/files/search?q=${encodeURIComponent(q)}&limit=24` + : `/api/v1/drive/files/recent?limit=5` + const res = await fetch(url, { cache: 'no-store' }) + if (!res.ok) return + const data = await res.json() + if (!active) return + const files = Array.isArray(data?.files) ? data.files : [] + setMentionResults(files.filter((f: any) => f && typeof f.id === 'string' && typeof f.name === 'string')) + } catch {} + }, 200) + return () => { active = false; clearTimeout(handle) } + }, [mentionQuery, mentionOpen, recentFiles]) + + const selectMentionOption = useCallback((opt: { id: string; name: string }) => { + const el = textareaRef.current + const pos = caretIndex() + const before = value.slice(0, mentionTokenStart) + const after = value.slice(pos) + setContextFiles((prev) => { + if (prev.some((f) => f.id === opt.id)) return prev + return [...prev, { id: opt.id, name: opt.name }] + }) + const next = (before + after).replace(/^\s+/, "") + setValue(next) + setMentionOpen(false) + requestAnimationFrame(() => { + if (el) { + const newPos = (before + after).length - after.length + try { el.setSelectionRange(newPos, newPos); el.focus() } catch {} + } + }) + }, [caretIndex, mentionTokenStart, value]) + + // Rank and limit to 6 items, closest match first + const displayMentionFiles = useMemo(() => { + const q = mentionQuery.trim().toLowerCase() + if (!q) return mentionResults.slice(0, 5) + const score = (name: string): number => { + const n = name.toLowerCase() + if (n === q) return 0 + if (n.startsWith(q)) return 1 + const idx = n.indexOf(q) + if (idx === 0) return 1 + if (idx > 0) return 2 + Math.min(10, idx) + return 999 + } + const sorted = [...mentionResults].sort((a, b) => score(a.name) - score(b.name) || a.name.localeCompare(b.name)) + return sorted.slice(0, 6) + }, [mentionResults, mentionQuery]) + const handlePrimaryClick = useCallback((e: React.MouseEvent) => { // If there's no text, use this button to toggle live voice if (!value.trim()) { @@ -356,6 +606,79 @@ export function ChatInput({ } }, [value, isLive, startLive, sttAllowed]) + // File helpers and actions for dropdown + const persistFilesMeta = useCallback((files: File[]) => { + try { + if (!sessionStorageKey) return + const raw = sessionStorage.getItem(sessionStorageKey) + const defaults = { + prompt: "", + files: [] as { name: string; size: number; type: string }[], + selectedToolIds: [] as string[], + selectedFilterIds: [] as string[], + imageGenerationEnabled: false, + webSearchEnabled: false, + codeInterpreterEnabled: false, + } + const data = raw ? { ...defaults, ...JSON.parse(raw) } : defaults + const metas = files.map((f) => ({ name: f.name, size: f.size, type: f.type })) + const existing = Array.isArray((data as any).files) ? (data as any).files : [] + ;(data as any).files = [...existing, ...metas] + sessionStorage.setItem(sessionStorageKey, JSON.stringify(data)) + } catch {} + }, [sessionStorageKey]) + + const triggerUploadFiles = useCallback(() => { + fileInputRef.current?.click() + }, []) + + const triggerCameraCapture = useCallback(() => { + cameraInputRef.current?.click() + }, []) + + const handleFilesSelected = useCallback((filesList: FileList | null) => { + const files = filesList ? Array.from(filesList) : [] + if (files.length === 0) return + setSelectedFiles((prev) => [...prev, ...files]) + persistFilesMeta(files) + // Add uploaded files into context pills with tiny preview for images + setContextFiles((prev) => { + const additions = files.map((file) => { + const id = `local:${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + let previewUrl: string | undefined + if (file.type && file.type.startsWith('image/')) { + try { + previewUrl = URL.createObjectURL(file) + if (previewUrl) uploadPreviewUrlsRef.current.add(previewUrl) + } catch {} + } + return { id, name: file.name, type: file.type, previewUrl } + }) + return [...prev, ...additions] + }) + }, [persistFilesMeta]) + + const handleReferenceChats = useCallback(() => { + setShowChatRefDialog(true) + }, []) + + const handleSelectDriveFile = useCallback((opt: { id: string; name: string }) => { + setContextFiles((prev) => { + if (prev.some((f) => f.id === opt.id)) return prev + return [...prev, { id: opt.id, name: opt.name }] + }) + }, []) + + const handleSelectChatRef = useCallback((chat: { id: string; title?: string | null }) => { + const title = chat.title || 'Chat' + const id = chat.id + setContextFiles((prev) => { + const pillId = `chat:${id}` + if (prev.some((f) => f.id === pillId)) return prev + return [...prev, { id: pillId, name: title, type: 'chat' }] + }) + }, []) + const formatTime = (total: number) => { const m = Math.floor(total / 60) const s = total % 60 @@ -381,204 +704,68 @@ export function ChatInput({
) : ( -
+
{(isRecording || isTranscribing || isModelLoading) ? ( -
- {isRecording ? ( - - ) : ( - + ) : ( <> -
-