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/CHANGELOG.md b/CHANGELOG.md
index f617f22..d511f3a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,65 @@
+## [0.2.0] - 2025-11-13
+
+### PR: [API-first v1 migration, Swagger docs, and Drive/Mobile UX upgrades](https://github.com/openchatui/openchat/pull/120)
+
+Scope: 296 files changed, 12,179 insertions, 7,734 deletions. Direction: dev → main.
+
+### Added
+- Versioned, API-first surface under `app/api/v1/**` for Drive, Chat, Models, Users, Images, Videos, Websearch, Tasks, Activity, Code/Pyodide, and Connections.
+- Swagger UI at `/docs` with dark theme and OpenAPI served from `app/api/docs/*`.
+- Drive features and endpoints: roots, recent, search, signed URLs, move/rename/star/trash/restore/sync for files and folders.
+- Mobile-first Drive UI: `FilesResultsTableMobile`, `DriveMobileHeader`, bottom navigation, mobile FAB and related components.
+- Chat: attachments API (`app/api/v1/chat/attachments`) and streaming refinements.
+- Models: active Ollama models endpoint and activation logic.
+- Admin: mobile navigation improvements and enhanced config forms for audio/image/video/websearch.
+- Client API wrappers in `lib/api/**` for audio, chats, code, connections, drive, images, models, users, userSettings, video, and websearch.
+
+### Changed
+- Replaced legacy Server Actions with RESTful versioned endpoints; routes standardized under `/api/v1/**` with request/response validation and auth responses.
+- Consolidated data access into `lib/db/*.db.ts` modules; removed legacy repositories and `lib/db/client.ts`.
+- Simplified middleware to gate admin routes; refined auth handling across the app.
+- Dockerfile and `docker-compose.yml` updated for new envs and persistent SQLite path.
+- Chat and model selector UI/UX adjustments; Drive preview and video streaming improvements.
+
+### Removed
+- Deprecated Server Actions in `actions/**`.
+- Legacy Swagger integration in `app/swagger/*`.
+- Old docs pages under `app/(main)/docs/*`.
+
+### Breaking changes
+- Routes re-namespaced to `/api/v1/**` (old non-v1 paths removed/redirected).
+- Environment variables renamed: `NEXTAUTH_URL` → `AUTH_URL`, `NEXTAUTH_SECRET` → `AUTH_SECRET`.
+- DAL/files reorganized under `lib/db/*.db.ts`; old repository modules removed.
+
+### Migration notes
+- Update `.env`, Dockerfile, and `docker-compose.yml` to use `AUTH_URL`/`AUTH_SECRET` and the new SQLite path (`/app/data/openchat.db`).
+- Switch clients to `lib/api/**` wrappers and `/api/v1/**` endpoints; remove imports of deleted Server Actions.
+- Review middleware and any custom logic referencing old auth/env names.
+
+### Potential conflict hotspots
+- `middleware.ts`, `next.config.ts`, `package.json`, `pnpm-lock.yaml`
+- Route moves/removals under `app/api/**` → `app/api/v1/**`
+- Deletions under `actions/**` paired with new `lib/api/**` clients
+- Dockerfile and `docker-compose.yml` env changes
+
+## [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/app/(main)/drive/folder/[folderId]/page.tsx b/app/(main)/drive/folder/[folderId]/page.tsx
index 18e20c9..5a3406b 100644
--- a/app/(main)/drive/folder/[folderId]/page.tsx
+++ b/app/(main)/drive/folder/[folderId]/page.tsx
@@ -1,8 +1,11 @@
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";
+import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader";
+import { MobileDriveFab } from "@/components/drive/MobileDriveFab";
interface PageProps {
params: Promise<{ folderId: string }>
@@ -13,18 +16,36 @@ 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 */}
+
+
+ {/* Mobile results list (full-width, scrolls under header) */}
+
+
+
+
+ {/* Mobile floating action button */}
+
+
+ {/* Desktop layout */}
+
+
-
+
+ >
);
}
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 (
)
}
diff --git a/app/(main)/drive/page.tsx b/app/(main)/drive/page.tsx
index 160a377..8679902 100644
--- a/app/(main)/drive/page.tsx
+++ b/app/(main)/drive/page.tsx
@@ -1,8 +1,11 @@
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";
+import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader";
+import { MobileDriveFab } from "@/components/drive/MobileDriveFab";
interface FilesPageProps {
searchParams?: Promise<{ parentId?: string | string[] }>
@@ -20,19 +23,33 @@ 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 layout */}
+
+
+
+
+
+
+
+ {/* Desktop layout */}
+
+
+
+
+ >
);
}
diff --git a/app/(main)/drive/starred/page.tsx b/app/(main)/drive/starred/page.tsx
index 942ce8a..73bea7b 100644
--- a/app/(main)/drive/starred/page.tsx
+++ b/app/(main)/drive/starred/page.tsx
@@ -2,19 +2,44 @@ 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, findLocalRootFolderId, getGoogleRootFolderId } 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 [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 */}
+
+
+ {/* Mobile results list (full-width, scrolls under header) */}
+
+
+
+
+ {/* Mobile floating action button */}
+
+
+ {/* Desktop layout */}
+
+
+
+
+ >
)
}
diff --git a/app/(main)/drive/trash/page.tsx b/app/(main)/drive/trash/page.tsx
index 1728b0b..e91e8c5 100644
--- a/app/(main)/drive/trash/page.tsx
+++ b/app/(main)/drive/trash/page.tsx
@@ -1,15 +1,23 @@
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";
+import { DriveMobileHeader } from "@/components/drive/DriveMobileHeader";
+import { MobileDriveFab } from "@/components/drive/MobileDriveFab";
export default async function TrashPage() {
const session = await auth();
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([
@@ -20,10 +28,26 @@ 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) */}
+
+
+
+
+ {/* Mobile floating action button */}
+
+
+ {/* Desktop layout */}
+
+
+
+
+ >
);
}
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/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/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/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 })
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/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/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_LABEL}
{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_LABEL}
{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
Enable
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/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}
/>
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()}
/>
diff --git a/components/ai/message.tsx b/components/ai/message.tsx
index ab9d446..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}
@@ -55,7 +55,7 @@ export const MessageAvatar = ({
...props
}: MessageAvatarProps) => (
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-input.tsx b/components/chat/chat-input.tsx
index bf205ae..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
@@ -366,7 +689,7 @@ export function ChatInput({
-
{ void beginChat(value) }}
- disabled={false}
- sessionStorageKey={'chat-input'}
- webSearchAvailable={webSearchAvailable && !!permissions?.workspaceTools && !!permissions?.webSearch}
- imageAvailable={imageAvailable && !!permissions?.workspaceTools && !!permissions?.imageGeneration}
- codeInterpreterAvailable={!!permissions?.workspaceTools && !!permissions?.codeInterpreter}
- sttAllowed={!!permissions?.stt}
- ttsAllowed={!!permissions?.tts}
- />
- { void beginChat(prompt) }}
- />
+ <>
+ {
+ void beginChat(value, options as any, attachedFiles)
+ }}
+ disabled={false}
+ sessionStorageKey={'chat-input'}
+ webSearchAvailable={webSearchAvailable && !!permissions?.workspaceTools && !!permissions?.webSearch}
+ imageAvailable={imageAvailable && !!permissions?.workspaceTools && !!permissions?.imageGeneration}
+ codeInterpreterAvailable={!!permissions?.workspaceTools && !!permissions?.codeInterpreter}
+ sttAllowed={!!permissions?.stt}
+ ttsAllowed={!!permissions?.tts}
+ />
+ { void beginChat(prompt) }}
+ />
+ >
{!selectedModel && (
diff --git a/components/chat/chat-messages.tsx b/components/chat/chat-messages.tsx
index 0e15cde..72194df 100644
--- a/components/chat/chat-messages.tsx
+++ b/components/chat/chat-messages.tsx
@@ -1,7 +1,7 @@
"use client"
-import { useRef, useEffect, useState, useCallback } from 'react'
-import { Bot, CopyIcon } from 'lucide-react'
+import { useRef, useEffect, useState, useCallback, Fragment } from 'react'
+import { CopyIcon, HardDrive } from 'lucide-react'
import { Actions, Action, SpeakAction } from '@/components/ai/actions'
import { Message, MessageAvatar } from '@/components/ai/message'
import { Response } from '@/components/ai/response'
@@ -232,11 +232,7 @@ export default function ChatMessages({
if (messages.length === 0) {
return (
-
-
-
Start a conversation
-
Send a message to begin chatting with the AI assistant.
-
+
)
}
@@ -247,36 +243,100 @@ 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' ? (
-
- {/* User message with bubble */}
-
-
- {message.parts
- .filter((part) => part.type === 'text')
- .map((part, index) => (
-
-
- {(part as any).text}
-
-
- ))}
+
+
+ {/* User message with bubble */}
+
+
+ {/* Render referenced chat pills from metadata */}
+ {(() => {
+ const meta = (message as any).metadata
+ const refs = Array.isArray(meta?.referencedChats) ? meta.referencedChats : []
+ if (refs.length === 0) return null
+ return (
+
+ {refs.map((r: any) => (
+
+
+
+
+
@{r.title || 'Chat'}
+
+ ))}
+
+ )
+ })()}
+ {/* Render image attachments from metadata */}
+ {(() => {
+ const meta = (message as any).metadata
+ const attachments = meta?.attachments
+ if (!Array.isArray(attachments)) return null
+ return attachments
+ .filter((att: any) => att.type === 'image')
+ .map((att: any, index: number) => {
+ const imageUrl = typeof att.image === 'string' ? att.image : undefined
+ if (!imageUrl) return null
+ return (
+
+
+
+ )
+ })
+ })()}
+ {/* Render file attachments from metadata (non-images) */}
+ {(() => {
+ const meta = (message as any).metadata
+ const attachments = meta?.attachments
+ if (!Array.isArray(attachments)) return null
+ return attachments
+ .filter((att: any) => att.type === 'file')
+ .map((att: any, index: number) => {
+ const fileName = att.filename || `File ${index + 1}`
+ return (
+
+
+ {fileName}
+
+ )
+ })
+ })()}
+ {/* Render text parts */}
+ {message.parts
+ .filter((part) => part.type === 'text')
+ .map((part, index) => (
+
+
+ {(part as any).text}
+
+
+ ))}
+
+ {(() => {
+ const copyText = getVisibleTextForCopy(message as any)
+ if (!copyText) return null
+ return (
+
+ navigator.clipboard.writeText(copyText)} label="Copy">
+
+
+
+ )
+ })()}
- {(() => {
- const copyText = getVisibleTextForCopy(message as any)
- if (!copyText) return null
- return (
-
- navigator.clipboard.writeText(copyText)} label="Copy">
-
-
-
- )
- })()}
-
-
+
+ {/* Optimistic assistant header appears directly after the last user message only when no assistant has spoken yet */}
+ {(!lastAssistantMessageId && message === messages[messages.length - 1]) && (
+
+
+
+ {getAssistantDisplayName()}
+
+
+ )}
+
) : (
)
}
+ // 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
}
@@ -542,7 +607,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/chat-standard.tsx b/components/chat/chat-standard.tsx
index 12fb423..22811e3 100644
--- a/components/chat/chat-standard.tsx
+++ b/components/chat/chat-standard.tsx
@@ -4,7 +4,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SidebarInset } from '@/components/ui/sidebar'
import { ModelSelector } from '@/components/chat/model-selector'
import { ChatInput } from '@/components/chat/chat-input'
-import dynamic from 'next/dynamic'
import type { Session } from 'next-auth'
import type { Model } from '@/types/model.types'
import { useChatStore } from '@/lib/modules/chat/chat.client-store'
@@ -13,6 +12,7 @@ import { getChatMessages } from '@/lib/api/chats'
import { updateUserSettingsRaw } from '@/lib/api/userSettings'
import { useChatStreaming } from '@/hooks/useChatStreaming'
import { Loader } from '@/components/ui/loader'
+import ChatMessages from '@/components/chat/chat-messages'
interface ChatStandardProps {
session: Session | null
@@ -53,11 +53,11 @@ export function ChatStandard({
if (currentChatId !== chatId) setCurrentChatId(chatId)
}, [chatId, currentChatId, setCurrentChatId])
- // Clear messages when navigating to a different chat so the loader can replace them
+ // On chat change, reset auto-send guard and clear messages to prepare for new chat
useEffect(() => {
- setMessages([] as any)
hasAutoSentRef.current = false
- setSelectedModel(null)
+ // Clear messages when switching chats to avoid showing stale data
+ setMessages([])
}, [chatId, setMessages])
// (removed) defer message loading until after streaming hook is available
@@ -107,16 +107,6 @@ export function ChatStandard({
: null
}, [selectedModel, initialModels])
- // ChatMessages is quite heavy; lazy-load to reduce initial bundle
- const ChatMessagesDynamic = useMemo(() => dynamic(() => import('@/components/chat/chat-messages'), {
- ssr: false,
- loading: () => (
-
-
-
- )
- }), [])
-
const assistantInfo = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i] as any
@@ -135,10 +125,25 @@ export function ChatStandard({
return { displayName: name, imageUrl }
}
}
- return {
- displayName: selectedModel?.name || 'AI Assistant',
- imageUrl: selectedModel?.meta?.profile_image_url || '/avatars/01.png'
+ // Fallback: use model info from the latest user message metadata if available
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i] as any
+ if (msg?.role === 'user') {
+ const meta = msg?.metadata || {}
+ const modelMeta = meta?.model || {}
+ const name =
+ (typeof modelMeta.name === 'string' && modelMeta.name) ||
+ selectedModel?.name ||
+ 'AI Assistant'
+ const imageUrl =
+ (typeof modelMeta.profile_image_url === 'string' && modelMeta.profile_image_url) ||
+ selectedModel?.meta?.profile_image_url ||
+ '/avatars/01.png'
+ return { displayName: name, imageUrl }
+ }
}
+ // Final fallback
+ return { displayName: selectedModel?.name || 'AI Assistant', imageUrl: selectedModel?.meta?.profile_image_url || '/avatars/01.png' }
}, [messages, selectedModel])
const handleModelSelect = async (model: Model) => {
@@ -161,17 +166,39 @@ export function ChatStandard({
const { handleSendMessage, handleStop, isLoading, error } = useChatStreaming({ chatId, initialModels, selectedModel })
- // Load messages for the active chat, but avoid clobbering streaming state
+ const handleInputSubmit = useCallback(async (
+ value: string,
+ options: { webSearch: boolean; image: boolean; video?: boolean; codeInterpreter: boolean; referencedChats?: { id: string; title?: string | null }[] },
+ overrideModel?: Model | null,
+ isAutoSend: boolean = false,
+ streamHandlers?: any,
+ attachedFiles?: Array<{ file: File; localId: string } | { fileId: string; fileName: string }>
+ ) => {
+ // Load messages for any referenced chats and pass to streaming hook
+ let contextMessages: any[] | undefined
+ const refs = Array.isArray(options?.referencedChats) ? options.referencedChats : []
+ if (refs.length > 0) {
+ try {
+ const lists = await Promise.all(refs.map(r => getChatMessages(r.id).catch(() => [])))
+ contextMessages = ([] as any[]).concat(...lists)
+ } catch {
+ contextMessages = undefined
+ }
+ }
+ const { referencedChats, ...rest } = options as any
+ return await (handleSendMessage as any)(value, { ...rest, referencedChats, contextMessages }, overrideModel || undefined, isAutoSend, streamHandlers, attachedFiles)
+ }, [handleSendMessage])
+
+ // Load messages for the active chat (messages are cleared on chatId change above)
useEffect(() => {
if (!chatId) return
let cancelled = false
getChatMessages(chatId)
.then((loaded) => {
- if (cancelled) return
- if (!Array.isArray(loaded) || loaded.length === 0) return
- const hasAssistant = (messages as any[])?.some((m: any) => m?.role === 'assistant')
- const shouldReplace = (messages as any[])?.length === 0 || loaded.length > (messages as any[])?.length
- if (!isLoading && !hasAssistant && shouldReplace) {
+ if (cancelled || isLoading) return
+ if (!Array.isArray(loaded)) return
+ // Only set if we don't have messages yet (cleared on chat change)
+ if ((messages as any[])?.length === 0) {
setMessages(loaded as any)
}
})
@@ -195,22 +222,71 @@ export function ChatStandard({
let imageFromStorage = false
let codeFromStorage = false
let videoFromStorage = false
+ let referencedChatsFromStorage: { id: string; title?: string | null }[] = []
try {
- const raw = sessionStorage.getItem(`chat-input-${chatId}`)
+ let raw = sessionStorage.getItem(`chat-input-${chatId}`)
+ // Fallback: if migration from landing page hasn't occurred yet, read from base key
+ if (!raw) raw = sessionStorage.getItem('chat-input')
if (raw) {
const data = JSON.parse(raw)
webSearchFromStorage = Boolean(data?.webSearchEnabled)
imageFromStorage = Boolean(data?.imageGenerationEnabled)
codeFromStorage = Boolean(data?.codeInterpreterEnabled)
videoFromStorage = Boolean((data as any)?.videoGenerationEnabled)
+ // Extract referenced chat pills saved as contextFiles with id "chat:"
+ if (Array.isArray((data as any)?.contextFiles)) {
+ referencedChatsFromStorage = (data as any).contextFiles
+ .filter((f: any) => f && typeof f.id === 'string' && f.id.startsWith('chat:'))
+ .map((f: any) => ({ id: String(f.id).slice(5), title: String(f.name || 'Chat') }))
+ }
}
+ // Handoff fallback from landing page (cleared after read)
+ try {
+ const handoffRaw = sessionStorage.getItem(`chat-handoff-${chatId}`)
+ if (handoffRaw) {
+ const h = JSON.parse(handoffRaw)
+ if (Array.isArray(h?.referencedChats)) {
+ const add = h.referencedChats.filter((r: any) => r && typeof r.id === 'string').map((r: any) => ({ id: String(r.id), title: String(r.title || 'Chat') }))
+ const existingIds = new Set(referencedChatsFromStorage.map(r => r.id))
+ for (const r of add) if (!existingIds.has(r.id)) referencedChatsFromStorage.push(r)
+ }
+ sessionStorage.removeItem(`chat-handoff-${chatId}`)
+ }
+ } catch {}
} catch {}
- void handleSendMessage(
- textContent,
- { webSearch: webSearchFromStorage, image: imageFromStorage, video: videoFromStorage, codeInterpreter: codeFromStorage },
- selectedModel,
- true
- )
+ // Reflect chips in the existing user message metadata for UI
+ if (referencedChatsFromStorage.length > 0) {
+ useChatStore.setState(prev => {
+ const newMessages = [...prev.messages]
+ for (let j = newMessages.length - 1; j >= 0; j--) {
+ if (newMessages[j].role === 'user') {
+ const meta: any = { ...((newMessages[j] as any).metadata || {}) }
+ meta.referencedChats = referencedChatsFromStorage
+ ;(newMessages[j] as any) = { ...(newMessages[j] as any), metadata: meta }
+ break
+ }
+ }
+ return { ...prev, messages: newMessages }
+ })
+ }
+ // Fetch referenced chat messages asynchronously and then send
+ void (async () => {
+ let contextMessages: any[] | undefined
+ if (referencedChatsFromStorage.length > 0) {
+ try {
+ const lists = await Promise.all(referencedChatsFromStorage.map(r => getChatMessages(r.id).catch(() => [])))
+ contextMessages = ([] as any[]).concat(...lists)
+ } catch {
+ contextMessages = undefined
+ }
+ }
+ await (handleSendMessage as any)(
+ textContent,
+ { webSearch: webSearchFromStorage, image: imageFromStorage, video: videoFromStorage, codeInterpreter: codeFromStorage, referencedChats: referencedChatsFromStorage, contextMessages },
+ selectedModel,
+ true
+ )
+ })()
}
break
}
@@ -232,7 +308,7 @@ export function ChatStandard({
-
(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) }
@@ -144,10 +216,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 +232,8 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur
{selectedModel.name.charAt(0).toUpperCase()}
-
-
{getDisplayName(selectedModel)}
+
+
{getDisplayName(selectedModel)}
{isOllama(selectedModel) ? (
{getParameterSize(selectedModel) && (
@@ -169,9 +241,6 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur
{getParameterSize(selectedModel)}
)}
- {isOllamaActive(selectedModel) && (
-
- )}
) : (
@@ -188,9 +257,10 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur
-
e.preventDefault()}
>
@@ -221,8 +291,8 @@ export function ModelSelector({ selectedModelId, onModelSelect, models = [], cur
-
-
{getDisplayName(model)}
+
+
{getDisplayName(model)}
{isOllama(model) ? (
{getParameterSize(model) && (
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
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 (
+
+
+ } label="Home" active={!!isHome} />
+ } label="Starred" active={!!isStarred} />
+ } label="Shared" active={!!isShared} />
+ } label="Files" active={!!isFiles} />
+
+
+ )
+}
+
+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..36b678e
--- /dev/null
+++ b/components/drive/DriveMobileHeader.tsx
@@ -0,0 +1,47 @@
+"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
+
+ return (
+
+
+
+
+
+
+ {canToggle && (
+ router.push(targetHref)}
+ aria-label="Toggle drive source"
+ aria-pressed={isActiveGoogle}
+ >
+
+
+ )}
+
+
+ )
+}
+
+
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..65e05b6 100644
--- a/components/drive/FilesLeftSidebar.tsx
+++ b/components/drive/FilesLeftSidebar.tsx
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils"
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
@@ -29,13 +29,12 @@ interface FilesLeftSidebarProps {
}
export function FilesLeftSidebar({ parentId = "", localRootId, googleRootId }: FilesLeftSidebarProps) {
- const { isEnabled } = useIntegrations()
const [showNewFolder, setShowNewFolder] = useState(false)
const [showUploadFile, setShowUploadFile] = useState(false)
const [showUploadFolder, setShowUploadFolder] = useState(false)
const pathname = usePathname()
const isTrash = pathname?.startsWith('/drive/trash')
- const showGoogle = Boolean(googleRootId) && isEnabled('google-drive')
+ const showGoogle = Boolean(googleRootId)
return (
@@ -47,20 +46,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/FilesResultsTable.tsx b/components/drive/FilesResultsTable.tsx
index 3ecade9..703f086 100644
--- a/components/drive/FilesResultsTable.tsx
+++ b/components/drive/FilesResultsTable.tsx
@@ -8,9 +8,8 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
-import { FileText, Download, Pencil, Users, Table2 } from "lucide-react";
-import { FaStar, FaRegStar } from "react-icons/fa";
-import { LuSquareMenu } from "react-icons/lu";
+import { Download, Pencil, Users, MessageSquare } from "lucide-react";
+import { FaStar, FaRegStar, FaFolder } from "react-icons/fa";
import { useCallback, useMemo, useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import dynamic from "next/dynamic";
@@ -25,10 +24,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
-import { FaFilePdf, FaImage, FaFolder } from "react-icons/fa";
-import { BsFileEarmarkSpreadsheetFill } from "react-icons/bs";
-import { FaFilm } from "react-icons/fa6";
import { ItemContextMenu } from "./ItemContextMenu";
+import { getFileIconComponent } from "@/lib/utils/file-icons";
const SelectionBar = dynamic(
() => import("./SelectionBar").then((m) => m.SelectionBar),
{ loading: () =>
}
@@ -81,53 +78,6 @@ interface FilesResultsTableProps {
isGoogleDriveFolder?: boolean;
}
-function getIconForFile(name: string, item?: FileEntry) {
- // Check for Google Workspace files by MIME type
- 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
;
- }
- if (["doc", "docx", "rtf", "odt"].includes(ext)) {
- return
;
- }
- return
;
-}
function isPreviewable(name: string, item?: FileEntry) {
// Check if it's a Google Workspace file
@@ -577,9 +527,9 @@ export function FilesResultsTable({
Name
- Owner
- Last Modified
- Location
+ Owner
+ Last Modified
+ Location
@@ -776,7 +726,7 @@ export function FilesResultsTable({
{it.isDirectory ? (
) : (
- getIconForFile(it.name, it)
+ getFileIconComponent(it.name, it)
)}
{it.name}
@@ -853,6 +803,7 @@ function RowItem({
isStarred,
onToggleStar,
}: RowItemProps) {
+ const router = useRouter();
const { attributes, listeners, setNodeRef } = useDraggable({ id: item.id });
const { isOver, setNodeRef: setDropRef } = item.isDirectory
? useDroppable({ id: `folder/${item.id}` })
@@ -881,21 +832,35 @@ function RowItem({
{...listeners}
{...attributes}
>
-
- {item.isDirectory ? (
-
- ) : (
- getIconForFile(item.name, item)
- )}
- {item.name}
- {!item.isDirectory && item.ownedByMe === false && (
-
-
-
- )}
+
+
+ {item.isDirectory ? (
+
+ ) : (
+ getFileIconComponent(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 +872,7 @@ function RowItem({
hour12: true,
})}
-
+
{parentName ? `/${parentName}` : "/"}
@@ -915,6 +880,52 @@ function RowItem({
+ {!item.isDirectory && (
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ try {
+ const key = "chat-input";
+ const raw = sessionStorage.getItem(key);
+ const defaults = {
+ prompt: "",
+ files: [] as { name: string; size: number; type: string }[],
+ 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 as { id: string; name: string }[])
+ : [];
+ const next = existing.some((f) => f && f.id === item.id)
+ ? existing
+ : [...existing, { id: item.id, name: item.name }];
+ (data as any).contextFiles = next;
+ sessionStorage.setItem(key, JSON.stringify(data));
+ // Also pass via URL params so the landing page can inject if needed
+ const cfid = encodeURIComponent(item.id);
+ const cfn = encodeURIComponent(item.name);
+ router.push(`/?cfid=${cfid}&cfn=${cfn}`);
+ } catch {
+ // ignore storage errors
+ const cfid = encodeURIComponent(item.id);
+ const cfn = encodeURIComponent(item.name);
+ router.push(`/?cfid=${cfid}&cfn=${cfn}`);
+ }
+ }}
+ >
+
+
+ )}
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("/")}`
+}
+
+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)
+ const [renameFileId, setRenameFileId] = useState(null)
+ const [optimisticStars, setOptimisticStars] = useState>({})
+ const [hiddenIds, setHiddenIds] = useState>(new Set())
+ const idToItem = useMemo(() => {
+ const m = new Map()
+ for (const e of entries) m.set(e.id, e)
+ return m
+ }, [entries])
+ const visibleEntries = useMemo(
+ () => entries.filter((e) => !hiddenIds.has(e.id)),
+ [entries, hiddenIds]
+ )
+ useEffect(() => {
+ const onItem = (e: Event) => {
+ try {
+ const ce = e as CustomEvent<{ id?: string }>
+ const id = ce.detail?.id
+ if (id) {
+ setHiddenIds((prev) => {
+ const next = new Set(prev)
+ next.add(id)
+ return next
+ })
+ }
+ } catch {}
+ }
+ const onItems = (e: Event) => {
+ try {
+ const ce = e as CustomEvent<{ ids?: string[] }>
+ const ids = Array.isArray(ce.detail?.ids) ? ce.detail?.ids as string[] : []
+ if (ids.length) {
+ setHiddenIds((prev) => {
+ const next = new Set(prev)
+ for (const id of ids) next.add(id)
+ return next
+ })
+ }
+ } catch {}
+ }
+ window.addEventListener("drive:itemTrashed", onItem as EventListener)
+ window.addEventListener("drive:itemsTrashed", onItems as EventListener)
+ return () => {
+ window.removeEventListener("drive:itemTrashed", onItem as EventListener)
+ window.removeEventListener("drive:itemsTrashed", onItems as EventListener)
+ }
+ }, [])
+
+ 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 (
+
+
+
{ if (!next) setPreview(null) }}
+ name={preview?.name ?? ""}
+ url={preview?.url ?? ""}
+ mimeType={preview?.mimeType}
+ fileId={preview?.fileId}
+ />
+ {
+ if (!next) {
+ setRenameFileId(null)
+ router.refresh()
+ }
+ }}
+ itemId={renameFileId ?? ""}
+ itemType="file"
+ itemName={renameFileId ? idToItem.get(renameFileId)?.name ?? "" : ""}
+ />
+
+ )
+}
+
+
diff --git a/components/drive/FilesSearchBar.tsx b/components/drive/FilesSearchBar.tsx
index 39cf635..6a8f6e7 100644
--- a/components/drive/FilesSearchBar.tsx
+++ b/components/drive/FilesSearchBar.tsx
@@ -1,11 +1,15 @@
"use client"
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 { useRouter, useSearchParams } from "next/navigation"
import { useCallback, useEffect, useMemo, useState } from "react"
export function FilesSearchBar() {
const router = useRouter()
const params = useSearchParams()
+ const { toggleSidebar } = useSidebar()
const initial = useMemo(() => params.get('q') ?? '', [params])
const [q, setQ] = useState(initial)
@@ -22,14 +26,25 @@ export function FilesSearchBar() {
}, [params, router])
return (
-
-
-
+
)
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)}
+ />
+
+
+
+
+
+
+ )
+}
+
+
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 (
-
-
- Your browser does not support the video tag.
-
+ {(!isProbablySupported || errored) ? (
+
+ ) : (
+
setErrored(true)}
+ >
+
+ Your browser does not support the video tag.
+
+ )}
)
}
diff --git a/components/drive/ZoomableImage.tsx b/components/drive/ZoomableImage.tsx
index a75c4f2..6653784 100644
--- a/components/drive/ZoomableImage.tsx
+++ b/components/drive/ZoomableImage.tsx
@@ -1,5 +1,6 @@
"use client"
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch"
+import { useEffect, useMemo, useRef, useState } from "react"
interface ZoomableImageProps {
src: string
@@ -9,29 +10,128 @@ interface ZoomableImageProps {
maxScale?: number
}
-export function ZoomableImage({ src, alt, initialScale = 0.75, minScale = initialScale, maxScale = 8 }: ZoomableImageProps) {
+export function ZoomableImage({ src, alt, initialScale, minScale, maxScale = 8 }: ZoomableImageProps) {
+ const containerRef = useRef(null)
+ const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>(null)
+ const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null)
+ const [showContent, setShowContent] = useState(false)
+ const WHEEL_STEP = 0.1
+
+ // Measure the container
+ useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const update = () => {
+ const rect = el.getBoundingClientRect()
+ setContainerSize({ width: rect.width, height: rect.height })
+ }
+ update()
+ const ro = new ResizeObserver(update)
+ ro.observe(el)
+ return () => {
+ ro.disconnect()
+ }
+ }, [])
+
+ // Preload image to read natural dimensions
+ useEffect(() => {
+ if (!src) return
+ let cancelled = false
+ const img = new Image()
+ img.decoding = "async"
+ img.onload = () => {
+ if (cancelled) return
+ setImageSize({ width: img.naturalWidth, height: img.naturalHeight })
+ }
+ img.src = src
+ return () => {
+ cancelled = true
+ }
+ }, [src])
+
+ // Compute scale to fit width exactly
+ const widthFitScale = useMemo(() => {
+ if (!containerSize || !imageSize) return undefined
+ const s = containerSize.width / imageSize.width
+ if (!Number.isFinite(s) || s <= 0) return undefined
+ return s
+ }, [containerSize, imageSize])
+
+ // Compute scale to fit height exactly
+ const heightFitScale = useMemo(() => {
+ if (!containerSize || !imageSize) return undefined
+ const s = containerSize.height / imageSize.height
+ if (!Number.isFinite(s) || s <= 0) return undefined
+ return s
+ }, [containerSize, imageSize])
+
+ // Choose base fit depending on orientation: landscape -> width, portrait -> height
+ const baseFitScale = useMemo(() => {
+ if (!imageSize) return undefined
+ const isPortrait = imageSize.height >= imageSize.width
+ return (isPortrait ? heightFitScale : widthFitScale)
+ }, [imageSize, widthFitScale, heightFitScale])
+
+ const effectiveInitialScale = useMemo(() => {
+ // Start from the base fit and then apply two "zoom clicks" in
+ if (typeof baseFitScale === "number") return baseFitScale + 2 * WHEEL_STEP
+ return typeof initialScale === "number" ? initialScale : 1
+ }, [baseFitScale, initialScale])
+
+ const effectiveMinScale = useMemo(() => {
+ // Allow zooming out to exactly the base fit (so edges align), or to 1x for small images.
+ if (typeof baseFitScale === "number") return Math.min(baseFitScale, 1)
+ if (typeof minScale === "number") return minScale
+ if (typeof initialScale === "number") return initialScale
+ return 0.1
+ }, [baseFitScale, initialScale, minScale])
+
+ // Wait until we can determine fit vs not, so first paint is correct
+ const ready = containerSize !== null && imageSize !== null
+
+ // Avoid a single-frame flash of untransformed huge image by revealing content
+ // one animation frame after everything is ready (transforms applied).
+ useEffect(() => {
+ if (!ready) {
+ setShowContent(false)
+ return
+ }
+ const id = requestAnimationFrame(() => setShowContent(true))
+ return () => cancelAnimationFrame(id)
+ }, [ready])
+
return (
-
-
-
+ {ready && (
+
- {/* eslint-disable-next-line @next/next/no-img-element */}
-
-
-
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ )}
)
}
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()}
+
+
+
+
- {/* include mentions as hidden inputs for GET submission */}
- {mentions.map((m) => (
-
- ))}
- {/* render selected mentions as badges in the same color order */}
- {[...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"
+
+
+
+
- {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) => (
-
- setHighlightIndex(idx)}
- onMouseDown={(e) => {
- e.preventDefault();
- selectOption(opt);
- }}
- >
- @{opt}
-
-
- ))}
-
-
- )}
-
+ />
+
+
+ {open && (
+
+
+ {filtered.map((opt, idx) => (
+
+ setHighlightIndex(idx)}
+ onMouseDown={(e) => {
+ e.preventDefault();
+ selectOption(opt);
+ }}
+ >
+ @{opt}
+
+
+ ))}
+
+
+ )}
-
+
);
}
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..7ea0e61 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 {
@@ -25,6 +26,17 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
+import {
+ AlertDialog,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction,
+} from "@/components/ui/alert-dialog"
import { usePathname, useRouter } from "next/navigation"
import type { ChatData } from "@/lib/modules/chat"
import { useChatTitles } from "@/hooks/useChatTitles"
@@ -37,6 +49,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 +356,7 @@ export function NavChats({ chats, timeZone = 'UTC' }: NavChatsProps) {
return (
-
+
Archive
- {
- e.preventDefault()
- e.stopPropagation()
- try {
- // Optimistic removal
- removeChatById(chat.id)
- await fetch(`/api/v1/chats/${encodeURIComponent(chat.id)}`, {
- method: 'DELETE',
- cache: 'no-store',
- })
- try {
- const bc = new BroadcastChannel('chats')
- bc.postMessage({ type: 'deleted', id: chat.id })
- bc.close()
- } catch {}
- } finally {
- router.refresh()
- }
- }}
- >
-
- Delete
-
+
+
+ { e.preventDefault() }}
+ >
+
+ Delete
+
+
+
+
+ Delete this chat?
+
+ This action cannot be undone. This will permanently delete this chat and its messages.
+
+
+
+ Cancel
+ {
+ try {
+ // Optimistic removal
+ removeChatById(chat.id)
+ await fetch(`/api/v1/chats/${encodeURIComponent(chat.id)}`, {
+ method: 'DELETE',
+ cache: 'no-store',
+ })
+ try {
+ const bc = new BroadcastChannel('chats')
+ bc.postMessage({ type: 'deleted', id: chat.id })
+ bc.close()
+ } catch {}
+ } finally {
+ router.refresh()
+ }
+ }}
+ >
+ Delete
+
+
+
+
diff --git a/components/sidebar/nav-main.tsx b/components/sidebar/nav-main.tsx
index 6393253..d35a80f 100644
--- a/components/sidebar/nav-main.tsx
+++ b/components/sidebar/nav-main.tsx
@@ -17,6 +17,7 @@ import {
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
+ useSidebar,
} from "@/components/ui/sidebar"
import type { IconType } from "react-icons/lib"
@@ -36,6 +37,12 @@ export function NavMain({
}[]
}[]
}) {
+ const { isMobile, setOpenMobile } = useSidebar()
+
+ function handleNavigate() {
+ if (isMobile) setOpenMobile(false)
+ }
+
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 (
-
+
([])
+ 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() }
diff --git a/hooks/useChatStreaming.ts b/hooks/useChatStreaming.ts
index cd002a0..8d264c9 100644
--- a/hooks/useChatStreaming.ts
+++ b/hooks/useChatStreaming.ts
@@ -36,16 +36,145 @@ export function useChatStreaming({ chatId, initialModels, selectedModel }: UseCh
const handleSendMessage = useCallback(async (
value: string,
- options: { webSearch: boolean; image: boolean; video?: boolean; codeInterpreter: boolean },
+ options: { webSearch: boolean; image: boolean; video?: boolean; codeInterpreter: boolean; referencedChats?: Array<{ id: string; title?: string | null }>; contextMessages?: UIMessage[] },
overrideModel?: Model,
isAutoSend: boolean = false,
- streamHandlers?: StreamHandlers
+ streamHandlers?: StreamHandlers,
+ attachedFiles?: Array<{ file: File; localId: string } | { fileId: string; fileName: string }>
): Promise => {
const modelToUse = overrideModel || selectedModel
if (!modelToUse) { toast.error('Please select a model first.'); return null }
setError(null)
const providerModelId = (modelToUse as any).providerId || modelToUse.id
+
+ // Process attachments (both uploaded files and drive file references)
+ const attachments: any[] = []
+ if (attachedFiles && attachedFiles.length > 0) {
+ for (const item of attachedFiles) {
+ try {
+ let fileId: string
+ let fileName: string
+ let fileType: string = 'application/octet-stream'
+
+ // Check if it's an uploaded file or a drive file reference
+ if ('file' in item) {
+ // Uploaded file - upload it first
+ const { file, localId } = item
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const uploadRes = await fetch('/api/v1/chat/attachments', {
+ method: 'POST',
+ body: formData
+ })
+
+ if (!uploadRes.ok) {
+ console.error('Failed to upload file:', await uploadRes.text())
+ continue
+ }
+
+ const uploadData = await uploadRes.json()
+ if (!uploadData.ok || !uploadData.fileId) {
+ console.error('Upload response missing file data:', uploadData)
+ continue
+ }
+
+ fileId = uploadData.fileId
+ fileName = uploadData.filename
+ fileType = file.type
+ } else {
+ // Drive file reference - use the file ID directly
+ fileId = item.fileId
+ fileName = item.fileName
+ // Try to infer type from filename
+ if (fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
+ fileType = 'image/' + (fileName.split('.').pop() || 'jpeg')
+ } else if (fileName.endsWith('.pdf')) {
+ fileType = 'application/pdf'
+ }
+ }
+
+ // Get signed URL for external access (model API)
+ const signedRes = await fetch(`/api/v1/drive/file/${fileId}/signed-url`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filename: fileName, ttlSec: 3600 })
+ })
+
+ if (!signedRes.ok) {
+ console.error('Failed to get signed URL:', await signedRes.text())
+ continue
+ }
+
+ const { url } = await signedRes.json()
+
+ // For localhost, model APIs can't access URLs, so fall back to base64
+ const isLocalhost = url.includes('localhost') || url.includes('127.0.0.1')
+
+ let fileData = url
+ if (isLocalhost) {
+ // Convert to base64 for localhost
+ if ('file' in item) {
+ // Uploaded file - read from File object
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ resolve(result) // Keep full data URL
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(item.file)
+ })
+ fileData = base64
+ } else {
+ // Drive file reference - fetch and convert to base64
+ try {
+ const fetchRes = await fetch(`/api/v1/drive/file/${fileId}`)
+ if (fetchRes.ok) {
+ const blob = await fetchRes.blob()
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ resolve(result)
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ fileData = base64
+ }
+ } catch (fetchErr) {
+ console.error('Failed to fetch drive file for base64 conversion:', fetchErr)
+ }
+ }
+ }
+
+ // Store as attachment metadata
+ if (fileType.startsWith('image/')) {
+ attachments.push({
+ type: 'image',
+ image: fileData,
+ mediaType: fileType,
+ localId: 'localId' in item ? item.localId : undefined,
+ fileId
+ })
+ } else {
+ attachments.push({
+ type: 'file',
+ data: fileData,
+ mediaType: fileType,
+ filename: fileName,
+ localId: 'localId' in item ? item.localId : undefined,
+ fileId
+ })
+ }
+ } catch (err) {
+ console.error('Failed to process attachment:', err)
+ }
+ }
+ }
+
const userMessage: UIMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2,8)}`,
role: 'user',
@@ -56,8 +185,10 @@ export function useChatStreaming({ chatId, initialModels, selectedModel }: UseCh
id: providerModelId,
name: resolveModelDisplay(providerModelId, modelToUse).name,
profile_image_url: resolveModelDisplay(providerModelId, modelToUse).image || null,
- }
- }
+ },
+ attachments: attachments.length > 0 ? attachments : undefined,
+ referencedChats: Array.isArray(options?.referencedChats) && options.referencedChats.length > 0 ? options.referencedChats : undefined,
+ } as any
}
if (!isAutoSend) {
@@ -73,9 +204,15 @@ export function useChatStreaming({ chatId, initialModels, selectedModel }: UseCh
const state = useChatStore.getState()
const currentMessages = state.messages
+ const hasContext = Array.isArray(options?.contextMessages) && (options!.contextMessages as any[]).length > 0
+ const combinedMessages = hasContext
+ ? ([...(options!.contextMessages as any[]), ...(isAutoSend ? currentMessages : []), userMessage] as UIMessage[])
+ : undefined
const body = isAutoSend
- ? { messages: currentMessages, chatId, modelId: modelToUse.id, enableWebSearch: options.webSearch, enableImage: options.image, enableVideo: Boolean(options.video) }
- : { message: userMessage, chatId, modelId: modelToUse.id, enableWebSearch: options.webSearch, enableImage: options.image, enableVideo: Boolean(options.video) }
+ ? { messages: combinedMessages || currentMessages, chatId, modelId: modelToUse.id, enableWebSearch: options.webSearch, enableImage: options.image, enableVideo: Boolean(options.video) }
+ : combinedMessages
+ ? { messages: combinedMessages, chatId, modelId: modelToUse.id, enableWebSearch: options.webSearch, enableImage: options.image, enableVideo: Boolean(options.video) }
+ : { message: userMessage, chatId, modelId: modelToUse.id, enableWebSearch: options.webSearch, enableImage: options.image, enableVideo: Boolean(options.video) }
const response = await fetch('/api/v1/chat', {
method: 'POST',
diff --git a/lib/api/chats.ts b/lib/api/chats.ts
index 6a04620..538b09b 100644
--- a/lib/api/chats.ts
+++ b/lib/api/chats.ts
@@ -36,7 +36,15 @@ export async function unarchiveChat(chatId: string): Promise {
}
}
-export async function createInitialChat(input: { message: string; model: { id: string; name?: string; profile_image_url?: string | null } }): Promise<{ chatId: string } & { model: { id: string; name?: string; profile_image_url?: string | null } }> {
+type AttachmentImage = { type: 'image'; image: string; mediaType: string; fileId?: string; localId?: string }
+type AttachmentFile = { type: 'file'; data: string; mediaType: string; filename: string; fileId?: string; localId?: string }
+type Attachment = AttachmentImage | AttachmentFile
+
+export async function createInitialChat(input: {
+ message: string;
+ model: { id: string; name?: string; profile_image_url?: string | null };
+ attachments?: Attachment[];
+}): Promise<{ chatId: string } & { model: { id: string; name?: string; profile_image_url?: string | null } }> {
const initialMessage = {
id: `msg_${Date.now()}`,
role: 'user',
@@ -44,12 +52,16 @@ export async function createInitialChat(input: { message: string; model: { id: s
metadata: {
createdAt: Date.now(),
model: { id: input.model.id, name: input.model.name ?? 'Unknown Model', profile_image_url: input.model.profile_image_url ?? null },
+ ...(Array.isArray(input.attachments) && input.attachments.length > 0 ? { attachments: input.attachments } : {}),
},
}
const res = await httpFetch(absoluteUrl('/api/v1/chats'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ message: { text: input.message, model: input.model }, initialMessage }),
+ body: JSON.stringify({
+ message: { text: input.message, model: input.model, attachments: input.attachments || [] },
+ initialMessage
+ }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({}))
diff --git a/lib/modules/audio/transcription/whisper-worker.js b/lib/modules/audio/transcription/whisper-worker.js
index 178b29f..8dccbee 100644
--- a/lib/modules/audio/transcription/whisper-worker.js
+++ b/lib/modules/audio/transcription/whisper-worker.js
@@ -1,6 +1,10 @@
-import { pipeline, env } from '@xenova/transformers'
+import { pipeline, env } from '@huggingface/transformers'
+// Configure environment for browser/worker
env.allowLocalModels = false
+env.useFS = false
+env.useFSCache = false
+env.useBrowserCache = true
class PipelineFactory {
static task = null
@@ -9,7 +13,9 @@ class PipelineFactory {
static async getInstance(progress_callback = null) {
if (this.instance === null) {
- this.instance = pipeline(this.task, this.model, { progress_callback })
+ // Prefer WebGPU when available, fallback to CPU/wasm
+ const device = typeof navigator !== 'undefined' && navigator && 'gpu' in navigator ? 'webgpu' : 'cpu'
+ this.instance = pipeline(this.task, this.model, { progress_callback, device })
}
return this.instance
diff --git a/lib/modules/chat/chat.client-store.ts b/lib/modules/chat/chat.client-store.ts
index e5a89a0..d0d4aa4 100644
--- a/lib/modules/chat/chat.client-store.ts
+++ b/lib/modules/chat/chat.client-store.ts
@@ -11,7 +11,7 @@ interface ChatState {
messages: UIMessage[]
isInitialState: boolean
- startNewChat: (firstMessage: string, model: Model) => Promise
+ startNewChat: (firstMessage: string, model: Model, attachedFiles?: Array<{ file: File; localId: string } | { fileId: string; fileName: string }>) => Promise
addMessage: (message: UIMessage) => void
setMessages: (messages: UIMessage[]) => void
setCurrentChatId: (chatId: string | null) => void
@@ -23,10 +23,137 @@ export const useChatStore = create((set, get) => ({
messages: [],
isInitialState: true,
- startNewChat: async (firstMessage: string, model: Model) => {
+ startNewChat: async (firstMessage: string, model: Model, attachedFiles?: Array<{ file: File; localId: string } | { fileId: string; fileName: string }>) => {
const tempId = `temp-${Date.now()}`
const providerModelId = (model as any).providerId || model.id
+ // Process attachments (both uploaded files and drive file references)
+ const attachments: any[] = []
+ if (attachedFiles && attachedFiles.length > 0) {
+ for (const item of attachedFiles) {
+ try {
+ let fileId: string
+ let fileName: string
+ let fileType: string = 'application/octet-stream'
+
+ // Check if it's an uploaded file or a drive file reference
+ if ('file' in item) {
+ // Uploaded file - upload it first
+ const { file, localId } = item
+ const formData = new FormData()
+ formData.append('file', file)
+
+ const uploadRes = await fetch('/api/v1/chat/attachments', {
+ method: 'POST',
+ body: formData
+ })
+
+ if (!uploadRes.ok) {
+ console.error('Failed to upload file:', await uploadRes.text())
+ continue
+ }
+
+ const uploadData = await uploadRes.json()
+ if (!uploadData.ok || !uploadData.fileId) {
+ console.error('Upload response missing file data:', uploadData)
+ continue
+ }
+
+ fileId = uploadData.fileId
+ fileName = uploadData.filename
+ fileType = file.type
+ } else {
+ // Drive file reference - use the file ID directly
+ fileId = item.fileId
+ fileName = item.fileName
+ // Try to infer type from filename
+ if (fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
+ fileType = 'image/' + (fileName.split('.').pop() || 'jpeg')
+ } else if (fileName.endsWith('.pdf')) {
+ fileType = 'application/pdf'
+ }
+ }
+
+ // Get signed URL for external access (model API)
+ const signedRes = await fetch(`/api/v1/drive/file/${fileId}/signed-url`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ filename: fileName, ttlSec: 3600 })
+ })
+
+ if (!signedRes.ok) {
+ console.error('Failed to get signed URL:', await signedRes.text())
+ continue
+ }
+
+ const { url } = await signedRes.json()
+
+ // For localhost, model APIs can't access URLs, so fall back to base64
+ const isLocalhost = url.includes('localhost') || url.includes('127.0.0.1')
+
+ let fileData = url
+ if (isLocalhost) {
+ // Convert to base64 for localhost
+ if ('file' in item) {
+ // Uploaded file - read from File object
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ resolve(result) // Keep full data URL
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(item.file)
+ })
+ fileData = base64
+ } else {
+ // Drive file reference - fetch and convert to base64
+ try {
+ const fetchRes = await fetch(`/api/v1/drive/file/${fileId}`)
+ if (fetchRes.ok) {
+ const blob = await fetchRes.blob()
+ const base64 = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ const result = reader.result as string
+ resolve(result)
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+ fileData = base64
+ }
+ } catch (fetchErr) {
+ console.error('Failed to fetch drive file for base64 conversion:', fetchErr)
+ }
+ }
+ }
+
+ // Store as attachment metadata
+ if (fileType.startsWith('image/')) {
+ attachments.push({
+ type: 'image',
+ image: fileData,
+ mediaType: fileType,
+ localId: 'localId' in item ? item.localId : undefined,
+ fileId
+ })
+ } else {
+ attachments.push({
+ type: 'file',
+ data: fileData,
+ mediaType: fileType,
+ filename: fileName,
+ localId: 'localId' in item ? item.localId : undefined,
+ fileId
+ })
+ }
+ } catch (err) {
+ console.error('Failed to process attachment:', err)
+ }
+ }
+ }
+
const userMessage: UIMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2,8)}`,
role: 'user',
@@ -37,8 +164,9 @@ export const useChatStore = create((set, get) => ({
id: providerModelId,
name: model.name,
profile_image_url: (model as any)?.meta?.profile_image_url || null,
- }
- }
+ },
+ attachments: attachments.length > 0 ? attachments : undefined
+ } as any
}
set({ currentChatId: tempId, messages: [userMessage], isInitialState: false })
@@ -51,6 +179,7 @@ export const useChatStore = create((set, get) => ({
name: model.name,
profile_image_url: (model as any)?.meta?.profile_image_url || null,
},
+ attachments
})
const chatId: string = result.chatId
set({ currentChatId: chatId })
diff --git a/lib/modules/drive/db.service.ts b/lib/modules/drive/db.service.ts
index 2d87235..b91d1f5 100644
--- a/lib/modules/drive/db.service.ts
+++ b/lib/modules/drive/db.service.ts
@@ -625,6 +625,56 @@ export class DriveDbService {
return [...folderEntries, ...fileEntries]
}
+
+ static async searchFilesByName(userId: string, q: string, limit: number = 10): Promise<{ id: string; name: string }[]> {
+ const needle = String(q || '').trim().toLowerCase()
+ if (!needle) return []
+ const like = `%${needle.replace(/[%_]/g, '\\$&')}%`
+ try {
+ const rows = await db.$queryRaw`
+ SELECT id, filename
+ FROM "file"
+ WHERE user_id = ${userId}
+ AND LOWER(filename) LIKE ${like}
+ ORDER BY filename ASC
+ LIMIT ${Number.isFinite(limit) ? Math.max(1, Math.min(50, Math.floor(limit))) : 10}
+ `
+ return (rows || []).map(r => ({ id: String(r.id), name: String(r.filename) }))
+ } catch {
+ const rows = await db.$queryRaw`
+ SELECT id, filename
+ FROM "file"
+ WHERE user_id = ${userId}
+ AND LOWER(filename) LIKE ${like}
+ ORDER BY filename ASC
+ LIMIT ${Number.isFinite(limit) ? Math.max(1, Math.min(50, Math.floor(limit))) : 10}
+ `
+ return (rows || []).map(r => ({ id: String(r.id), name: String(r.filename) }))
+ }
+ }
+
+ static async listRecentFiles(userId: string, limit: number = 5): Promise<{ id: string; name: string }[]> {
+ const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(50, Math.floor(limit))) : 5
+ try {
+ const rows = await db.$queryRaw`
+ SELECT id, filename
+ FROM "file"
+ WHERE user_id = ${userId}
+ ORDER BY updated_at DESC
+ LIMIT ${safeLimit}
+ `
+ return (rows || []).map(r => ({ id: String(r.id), name: String(r.filename) }))
+ } catch {
+ const rows = await db.$queryRaw`
+ SELECT id, filename
+ FROM "file"
+ WHERE user_id = ${userId}
+ ORDER BY updated_at DESC
+ LIMIT ${safeLimit}
+ `
+ return (rows || []).map(r => ({ id: String(r.id), name: String(r.filename) }))
+ }
+ }
}
@@ -641,5 +691,7 @@ export const listFilesByParent = DriveDbService.listFilesByParent.bind(DriveDbSe
export const getFolderBreadcrumb = DriveDbService.getFolderBreadcrumb.bind(DriveDbService)
export const listStarredEntries = DriveDbService.listStarredEntries.bind(DriveDbService)
export const isGoogleDriveFolder = DriveDbService.isGoogleDriveFolder.bind(DriveDbService)
+export const searchFilesByName = DriveDbService.searchFilesByName.bind(DriveDbService)
+export const listRecentFiles = DriveDbService.listRecentFiles.bind(DriveDbService)
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()
diff --git a/lib/utils/file-icons.tsx b/lib/utils/file-icons.tsx
new file mode 100644
index 0000000..63a1237
--- /dev/null
+++ b/lib/utils/file-icons.tsx
@@ -0,0 +1,113 @@
+import { FileText, File as FileIcon, Image as ImageIcon, Video } from "lucide-react"
+import { FaFilePdf, FaImage } from "react-icons/fa"
+import { BsFileEarmarkSpreadsheetFill } from "react-icons/bs"
+import { FaFilm } from "react-icons/fa6"
+import { LuSquareMenu } from "react-icons/lu"
+import { Table2 } from "lucide-react"
+
+export function getFileIconComponent(fileName: string, item?: { meta?: any }) {
+ // Check for Google Workspace files by MIME type
+ if (item?.meta) {
+ const meta = item.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 = fileName.includes(".") ? fileName.split(".").pop()!.toLowerCase() : ""
+
+ // Image files
+ if (
+ [
+ "jpg",
+ "jpeg",
+ "png",
+ "gif",
+ "webp",
+ "svg",
+ "bmp",
+ "tiff",
+ "tif",
+ "heic",
+ "heif",
+ "avif",
+ ].includes(ext)
+ ) {
+ return
+ }
+
+ // Video files
+ if (["mp4", "webm", "ogg", "ogv", "mov", "m4v", "mkv"].includes(ext)) {
+ return
+ }
+
+ // PDF files
+ if (ext === "pdf") {
+ return
+ }
+
+ // Spreadsheet files
+ if (["xls", "xlsx", "xlsm", "csv", "tsv", "ods", "numbers"].includes(ext)) {
+ return
+ }
+
+ // Document files
+ if (["doc", "docx", "rtf", "odt"].includes(ext)) {
+ return
+ }
+
+ // Default file icon
+ return
+}
+
+// Smaller version for compact UIs (like mention dropdown)
+export function getFileIconCompact(fileName: string, item?: { meta?: any }) {
+ // Check for Google Workspace files by MIME type
+ if (item?.meta) {
+ const meta = item.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 = fileName.toLowerCase().split('.').pop() || ''
+
+ // Image files
+ if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff', 'tif', 'heic', 'heif', 'avif'].includes(ext)) {
+ return
+ }
+
+ // Document files (PDF)
+ if (ext === 'pdf') {
+ return
+ }
+
+ // Document files (Word, etc)
+ if (['doc', 'docx', 'txt', 'md', 'rtf', 'odt'].includes(ext)) {
+ return
+ }
+
+ // Spreadsheets
+ if (['xls', 'xlsx', 'xlsm', 'csv', 'tsv', 'ods', 'numbers'].includes(ext)) {
+ return
+ }
+
+ // Video files
+ if (['mp4', 'mov', 'avi', 'mkv', 'webm', 'ogg', 'ogv', 'm4v'].includes(ext)) {
+ return
+ }
+
+ // Default file icon
+ return
+}
+
diff --git a/package.json b/package.json
index 9b137bf..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",
@@ -87,7 +87,7 @@
"@tiptap/react": "^3.6.2",
"@tiptap/starter-kit": "^3.6.2",
"@types/bcryptjs": "^2.4.6",
- "@xenova/transformers": "^2.17.2",
+ "@huggingface/transformers": "^3.0.0",
"ai": "^5.0.45",
"archiver": "^7.0.1",
"bcryptjs": "^3.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4f82b06..ff58f8c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
'@hookform/resolvers':
specifier: ^5.1.1
version: 5.2.1(react-hook-form@7.62.0(react@19.1.0))
+ '@huggingface/transformers':
+ specifier: ^3.0.0
+ version: 3.7.6
'@icons-pack/react-simple-icons':
specifier: ^13.7.0
version: 13.7.0(react@19.1.0)
@@ -206,9 +209,6 @@ importers:
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
- '@xenova/transformers':
- specifier: ^2.17.2
- version: 2.17.2
ai:
specifier: ^5.0.45
version: 5.0.48(zod@4.1.8)
@@ -798,10 +798,13 @@ packages:
peerDependencies:
react-hook-form: ^7.55.0
- '@huggingface/jinja@0.2.2':
- resolution: {integrity: sha512-/KPde26khDUIPkTGU82jdtTW9UAuvUTumCAbFs/7giR0SxsvZC4hru51PBvpijH6BVkHcROcvZM/lpy5h1jRRA==}
+ '@huggingface/jinja@0.5.1':
+ resolution: {integrity: sha512-yUZLld4lrM9iFxHCwFQ7D1HW2MWMwSbeB7WzWqFYDWK+rEb+WldkLdAJxUPOmgICMHZLzZGVcVjFh3w/YGubng==}
engines: {node: '>=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: