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 (
- -
+
+ +
+
{children}
+
) } 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_HINT}

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

{MESSAGES.STT_ENABLE_HINT}

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

Allow the assistant to execute code via the configured runtime.

- + { + setEnabled(Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + toast.success(`${action} Code Interpreter`) + }} />
diff --git a/components/admin/connections/ollama-connection-form.tsx b/components/admin/connections/ollama-connection-form.tsx index 6951829..5eba09d 100644 --- a/components/admin/connections/ollama-connection-form.tsx +++ b/components/admin/connections/ollama-connection-form.tsx @@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Switch } from "@/components/ui/switch" import { MESSAGES, PLACEHOLDERS } from "@/constants/connections" +import { toast } from "sonner" import type { NewOllamaConnection, Connection } from "@/types/connections.types" interface OllamaConnectionFormProps { @@ -40,7 +41,12 @@ export function OllamaConnectionForm({
onToggleOllamaEnabled?.(Boolean(checked))} + onCheckedChange={(checked) => { + onToggleOllamaEnabled?.(Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + const url = existingOllamaConnections[0]?.baseUrl + toast.success(`${action} Ollama connection${url ? `: ${url}` : ''}`) + }} />
diff --git a/components/admin/connections/openai-connection-form.tsx b/components/admin/connections/openai-connection-form.tsx index a018ba7..66e432b 100644 --- a/components/admin/connections/openai-connection-form.tsx +++ b/components/admin/connections/openai-connection-form.tsx @@ -115,7 +115,11 @@ export function OpenAIConnectionForm({
onToggleEnable?.(idx, Boolean(checked))} + onCheckedChange={(checked) => { + onToggleEnable?.(idx, Boolean(checked)) + const action = checked ? 'Enabled' : 'Disabled' + toast.success(`${action} OpenAI connection: ${connection.baseUrl}`) + }} />
diff --git a/components/admin/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({
{isLive && sttAllowed ? (
@@ -381,204 +704,68 @@ export function ChatInput({
) : ( -
+
{(isRecording || isTranscribing || isModelLoading) ? ( -
- {isRecording ? ( - - ) : ( - + ) : ( <> -
-