diff --git a/.gitignore b/.gitignore index 000f3c6..416f26d 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,7 @@ node_modules/ # editor folders .cursor/ .vscode/ + +# local docker compose overrides +docker-compose.override.yml +docker-compose.local.yml diff --git a/app/api/v1/chat/attachments/route.ts b/app/api/v1/chat/attachments/route.ts new file mode 100644 index 0000000..9641abb --- /dev/null +++ b/app/api/v1/chat/attachments/route.ts @@ -0,0 +1,120 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { writeFile, mkdir } from 'fs/promises' +import path from 'path' +import { randomUUID } from 'crypto' +import db from '@/lib/db' +import { getRootFolderId } from '@/lib/modules/drive' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** + * @swagger + * /api/v1/chat/attachments: + * post: + * tags: [Chat] + * summary: Upload a file attachment for chat messages + * security: + * - BearerAuth: [] + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * responses: + * 200: + * description: File uploaded successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * fileId: + * type: string + * filename: + * type: string + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 500: + * description: Upload failed + */ +export async function POST(request: NextRequest): Promise { + try { + console.log('[DEBUG] Chat attachment upload received') + const session = await auth() + const userId = session?.user?.id + console.log('[DEBUG] Auth check:', { hasSession: !!session, userId }) + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const formData = await request.formData() + const file = formData.get('file') + console.log('[DEBUG] FormData parsed, has file:', !!file) + + if (!(file instanceof File)) { + return NextResponse.json({ error: 'Missing file' }, { status: 400 }) + } + + console.log('[DEBUG] File info:', { name: file.name, size: file.size, type: file.type }) + + // Validate file size (max 20MB for chat attachments) + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + const maxBytes = 20 * 1024 * 1024 + if (buffer.length > maxBytes) { + return NextResponse.json({ error: 'File too large (max 20MB)' }, { status: 400 }) + } + + const fileId = randomUUID() + const ext = file.name.includes('.') ? '.' + file.name.split('.').pop() : '' + const sanitizedName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_') + + // Get root folder ID + const rootFolderId = await getRootFolderId(userId) + + // Save to data/files/{rootFolderId}/ + const dataDir = path.join(process.cwd(), 'data', 'files', rootFolderId) + await mkdir(dataDir, { recursive: true }) + + const filename = `${Date.now()}-${sanitizedName}` + const filePath = path.join(dataDir, filename) + await writeFile(filePath, buffer) + + console.log('[DEBUG] File saved to:', filePath) + + // Save to database + const nowSec = Math.floor(Date.now() / 1000) + await db.file.create({ + data: { + id: fileId, + userId, + filename, + parentId: rootFolderId, + meta: { originalName: file.name, mimeType: file.type }, + createdAt: nowSec, + updatedAt: nowSec, + path: `/data/files/${rootFolderId}/${filename}`, + }, + }) + + console.log('[DEBUG] File saved to DB:', fileId) + + return NextResponse.json({ ok: true, fileId, filename }) + } catch (error) { + console.error('[ERROR] POST /api/v1/chat/attachments error:', error) + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }) + } +} + diff --git a/app/api/v1/chat/route.ts b/app/api/v1/chat/route.ts index 428b225..a1fed8d 100644 --- a/app/api/v1/chat/route.ts +++ b/app/api/v1/chat/route.ts @@ -167,14 +167,109 @@ export async function POST(req: NextRequest) { const mergedTools = await ToolsService.buildTools({ enableWebSearch, enableImage, enableVideo }) const toolsEnabled = Boolean(mergedTools) + // Helper: Convert UIMessages with attachments to ModelMessages manually + const convertMessagesToModelFormat = (messages: UIMessage[]): any[] => { + return messages.map((msg) => { + const meta = (msg as any).metadata + const attachments = meta?.attachments + + // Assistant messages - handle text and tool calls + if (msg.role === 'assistant') { + const textParts = msg.parts.filter((p: any) => p.type === 'text') + const toolParts = msg.parts.filter((p: any) => + typeof p.type === 'string' && p.type.startsWith('tool-') + ) + + // If has tool calls, need special handling + if (toolParts.length > 0) { + const textContent = textParts.map((p: any) => p.text || '').join('') + const toolCalls = toolParts.map((p: any) => ({ + type: 'tool-call', + toolCallId: (p as any).toolCallId, + toolName: (p as any).type?.replace('tool-', ''), + args: (p as any).input || {} + })) + + return { + role: 'assistant', + content: [ + { type: 'text', text: textContent }, + ...toolCalls + ] + } + } + + // Simple text response + const textContent = textParts.map((p: any) => p.text || '').join('') + return { + role: 'assistant', + content: textContent + } + } + + // User messages - check for attachments + if (msg.role === 'user') { + const textContent = msg.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text || '') + .join('') + + // If no attachments, return simple text + if (!Array.isArray(attachments) || attachments.length === 0) { + return { + role: 'user', + content: textContent + } + } + + // With attachments, use array of content parts + const contentParts: any[] = [ + ...attachments.map((att: any) => { + if (att.type === 'image') { + return { type: 'image', image: att.image, mediaType: att.mediaType } + } else { + return { type: 'file', data: att.data, mediaType: att.mediaType } + } + }), + { type: 'text', text: textContent } + ] + + return { + role: 'user', + content: contentParts + } + } + + // System messages + if (msg.role === 'system') { + const textContent = msg.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text || '') + .join('') + return { + role: 'system', + content: textContent + } + } + + return msg + }) + } + try { // First attempt: no trimming; if provider throws context error, we'll retry with trimmed payload - const validatedFull = await validateUIMessages({ messages: fullMessages }); + console.log('[DEBUG] Processing messages, count:', fullMessages.length); + console.log('[DEBUG] Last message metadata:', JSON.stringify((fullMessages[fullMessages.length - 1] as any)?.metadata?.attachments, null, 2)); + + // Convert UIMessages to ModelMessages manually to handle attachments + const modelMessages = convertMessagesToModelFormat(fullMessages) + + console.log('[DEBUG] Model messages created, count:', modelMessages.length); + console.log('[DEBUG] Last model message:', JSON.stringify(modelMessages[modelMessages.length - 1], null, 2).slice(0, 800)); + const result = streamText({ model: modelHandle, - messages: convertToModelMessages( - validatedFull as UIMessage[] - ), + messages: modelMessages, system: combinedSystem, experimental_transform: smoothStream({ delayInMs: 10, // optional: defaults to 10ms @@ -202,23 +297,37 @@ export async function POST(req: NextRequest) { tools: mergedTools as any, }); const toUIArgs = StreamUtils.buildToUIMessageStreamArgs( - validatedFull as UIMessage[], + fullMessages as UIMessage[], selectedModelInfo, { finalChatId, userId }, undefined, ); return result.toUIMessageStreamResponse(toUIArgs); } catch (err: any) { + console.error('[ERROR] Chat API streamText failed:', err); + console.error('[ERROR] Error stack:', err?.stack); + console.error('[ERROR] Error message:', err?.message); + console.error('[ERROR] Error code:', (err as any)?.code); + console.error('[ERROR] Full error object:', JSON.stringify(err, Object.getOwnPropertyNames(err), 2)); + const msg = String(err?.message || '') const code = String((err as any)?.code || '') const isContextError = code === 'context_length_exceeded' || /context length|too many tokens|maximum context/i.test(msg) - const status = isContextError ? 413 : 502 + + // Check for vision/image support errors + const isVisionError = + /vision|image|multimodal|not supported/i.test(msg) || + /invalid.*content.*type/i.test(msg) + + const status = isContextError ? 413 : (isVisionError ? 400 : 502) const body = { error: isContextError ? 'Your message is too long for this model. Please shorten it or switch models.' - : 'Failed to generate a response. Please try again.', + : isVisionError + ? `This model does not support image inputs. Please select a vision-capable model (e.g., GPT-4 Vision, Claude 3, Gemini Pro Vision). Details: ${msg}` + : `Failed to generate a response. Error: ${msg}`, } return new Response(JSON.stringify(body), { status, diff --git a/app/api/v1/chats/route.ts b/app/api/v1/chats/route.ts index ec79b64..ef362da 100644 --- a/app/api/v1/chats/route.ts +++ b/app/api/v1/chats/route.ts @@ -1,6 +1,7 @@ import { auth } from "@/lib/auth"; import { NextRequest, NextResponse } from 'next/server'; import { ChatStore } from '@/lib/modules/chat'; +import { z } from 'zod'; // Allow streaming responses up to 30 seconds export const maxDuration = 30; @@ -12,6 +13,49 @@ export const runtime = 'nodejs'; * post: * tags: [Chats] * summary: Create a new chat + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: object + * properties: + * text: + * type: string + * model: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * profile_image_url: + * type: string + * nullable: true + * attachments: + * type: array + * items: + * oneOf: + * - type: object + * required: [type, image, mediaType] + * properties: + * type: { type: string, enum: [image] } + * image: { type: string, description: "Signed URL or data: URI" } + * mediaType: { type: string } + * fileId: { type: string } + * localId: { type: string } + * - type: object + * required: [type, data, mediaType, filename] + * properties: + * type: { type: string, enum: [file] } + * data: { type: string, description: "Signed URL or data: URI" } + * mediaType: { type: string } + * filename: { type: string } + * fileId: { type: string } + * localId: { type: string } * responses: * 200: * description: Chat created @@ -45,15 +89,47 @@ export async function POST(req: NextRequest) { // Optionally seed chat with an initial user message let initialMessage: any | undefined = undefined; try { - const body = await req.json(); - if (body && typeof body === 'object' && body.message && typeof body.message.text === 'string') { + const BodySchema = z.object({ + message: z.object({ + text: z.string(), + model: z.object({ + id: z.string(), + name: z.string().optional(), + profile_image_url: z.string().nullable().optional(), + }).optional(), + attachments: z.array( + z.union([ + z.object({ + type: z.literal('image'), + image: z.string(), + mediaType: z.string(), + fileId: z.string().optional(), + localId: z.string().optional(), + }), + z.object({ + type: z.literal('file'), + data: z.string(), + mediaType: z.string(), + filename: z.string(), + fileId: z.string().optional(), + localId: z.string().optional(), + }), + ]) + ).optional(), + }).optional(), + initialMessage: z.any().optional(), // ignored if provided; we rebuild below + }).optional(); + const raw = await req.json(); + const body = BodySchema.parse(raw); + if (body && body.message && typeof body.message.text === 'string') { const text: string = body.message.text; - const model = body.message.model && typeof body.message.model === 'object' ? body.message.model : undefined; + const model = body.message.model; const modelId = typeof model?.id === 'string' ? model.id : undefined; const modelName = typeof model?.name === 'string' ? model.name : undefined; const modelImage = (typeof model?.profile_image_url === 'string' || model?.profile_image_url === null) ? model.profile_image_url : undefined; + const attachments = Array.isArray(body.message.attachments) ? body.message.attachments : undefined; initialMessage = { id: `msg_${Date.now()}`, @@ -64,6 +140,7 @@ export async function POST(req: NextRequest) { ...(modelId || modelName || typeof modelImage !== 'undefined' ? { model: { id: modelId ?? 'unknown', name: modelName ?? 'Unknown Model', profile_image_url: modelImage ?? null } } : {}), + ...(attachments && attachments.length > 0 ? { attachments } : {}), }, }; } diff --git a/app/api/v1/drive/file/[id]/content/[...filename]/route.ts b/app/api/v1/drive/file/[id]/content/[...filename]/route.ts new file mode 100644 index 0000000..22dfd39 --- /dev/null +++ b/app/api/v1/drive/file/[id]/content/[...filename]/route.ts @@ -0,0 +1,183 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import db from '@/lib/db' +import { Readable } from 'stream' +import { getGoogleDriveFileStream } from '@/lib/modules/drive/providers/google-drive.service' +import { verify } from 'jsonwebtoken' +import { readFile } from 'fs/promises' +import path from 'path' + +function getMimeTypeFromFilename(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() + const mimeTypes: Record = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'json': 'application/json', + 'xml': 'text/xml', + 'mp4': 'video/mp4', + 'mp3': 'audio/mpeg', + } + return mimeTypes[ext || ''] || 'application/octet-stream' +} + +/** + * @swagger + * /api/v1/drive/file/{id}/content/{filename}: + * get: + * tags: [Drive] + * summary: Stream a Google Drive file (supports signed URLs) + * description: | + * Streams the raw bytes of a Google Drive file. Authenticated users can access their own files directly. + * For external consumers (e.g., AI model web fetch), provide a `token` query param containing a short-lived signed token. + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * - in: path + * name: filename + * required: false + * schema: + * type: string + * description: Optional filename for pretty URLs; ignored by server. + * - in: query + * name: token + * required: false + * schema: + * type: string + * description: Signed access token for cookie-less access (e.g., external fetchers). + * responses: + * 200: + * description: File stream + * content: + * application/octet-stream: + * schema: + * type: string + * format: binary + * 400: + * description: Invalid parameters + * 401: + * description: Unauthorized + * 404: + * description: File not found + * 500: + * description: Failed to fetch file + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ id: string; filename?: string[] }> } +) { + try { + const { id } = await params + if (!id) { + return NextResponse.json({ error: 'File ID required' }, { status: 400 }) + } + + const url = new URL(req.url) + const tokenParam = url.searchParams.get('token') + + let userId: string | null = null + + if (tokenParam) { + // Verify signed token for cookie-less access + const secret = process.env.TOKEN_SECRET || process.env.AUTH_SECRET || '' + try { + const decoded = verify(tokenParam, secret) as { sub?: string; userId?: string } + if (!decoded || (decoded.sub !== id && decoded.userId == null)) { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }) + } + // Prefer explicit userId from token; otherwise resolve from DB by id + userId = decoded.userId ?? null + } catch (err) { + return NextResponse.json({ error: 'Invalid or expired token' }, { status: 401 }) + } + } + + if (!userId) { + // Fallback to session auth + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + userId = session.user.id + } + + // If userId still not known (token without userId), try resolving owner from DB + if (!userId) { + const fileRow = await db.file.findFirst({ where: { id }, select: { userId: true } }) + if (!fileRow?.userId) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + userId = fileRow.userId + } + + // Check if file is stored locally or in Google Drive + const fileRecord = await db.file.findFirst({ + where: { id, userId }, + select: { path: true, filename: true, meta: true } + }) + + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + // If path starts with /data/files/, it's a local file + if (fileRecord.path && fileRecord.path.startsWith('/data/files/')) { + const localPath = path.join(process.cwd(), fileRecord.path) + + try { + const buffer = await readFile(localPath) + + // Determine mime type + const mimeType = (fileRecord.meta as any)?.mimeType || + getMimeTypeFromFilename(fileRecord.filename) || + 'application/octet-stream' + + const headers: Record = { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + 'Content-Length': buffer.length.toString() + } + + // Stream the buffer as a Web ReadableStream (widely accepted BodyInit) + const nodeStream = Readable.from(buffer) + const webStream = Readable.toWeb(nodeStream) as ReadableStream + return new NextResponse(webStream, { headers }) + } catch (err) { + console.error('Error reading local file:', err) + return NextResponse.json({ error: 'File not found on disk' }, { status: 404 }) + } + } + + // Otherwise, fetch from Google Drive + const { stream, mimeType, size } = await getGoogleDriveFileStream(userId, id) + + const webStream = Readable.toWeb(stream as any) as ReadableStream + + const headers: Record = { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600', + 'Access-Control-Allow-Origin': '*', + } + + if (size) headers['Content-Length'] = size.toString() + + return new NextResponse(webStream, { headers }) + } catch (error: any) { + console.error('Error streaming Drive file (content):', error) + return NextResponse.json( + { error: error?.message ?? 'Failed to fetch file' }, + { status: 500 } + ) + } +} + + diff --git a/app/api/v1/drive/file/[id]/download/route.ts b/app/api/v1/drive/file/[id]/download/route.ts index 7a9255e..548c461 100644 --- a/app/api/v1/drive/file/[id]/download/route.ts +++ b/app/api/v1/drive/file/[id]/download/route.ts @@ -34,7 +34,7 @@ import db from '@/lib/db' */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth() @@ -42,16 +42,16 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { fileId } = await params + const { id } = await params - if (!fileId) { + if (!id) { return NextResponse.json({ error: 'File ID required' }, { status: 400 }) } // Get file metadata from database to get the filename const file = await db.file.findFirst({ where: { - id: fileId, + id: id, userId: session.user.id }, select: { @@ -67,7 +67,7 @@ export async function GET( let filename: string try { - const result = await getGoogleDriveFileStream(session.user.id, fileId) + const result = await getGoogleDriveFileStream(session.user.id, id) stream = result.stream mimeType = result.mimeType size = result.size diff --git a/app/api/v1/drive/file/[id]/export/route.ts b/app/api/v1/drive/file/[id]/export/route.ts index 6b8ebf5..077cd36 100644 --- a/app/api/v1/drive/file/[id]/export/route.ts +++ b/app/api/v1/drive/file/[id]/export/route.ts @@ -39,7 +39,7 @@ import db from '@/lib/db' */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth() @@ -47,16 +47,16 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { fileId } = await params + const { id } = await params - if (!fileId) { + if (!id) { return NextResponse.json({ error: 'File ID required' }, { status: 400 }) } // Get file metadata from database to determine mime type const file = await db.file.findFirst({ where: { - id: fileId, + id: id, userId: session.user.id }, select: { @@ -89,7 +89,7 @@ export async function GET( const { stream, mimeType } = await exportGoogleDriveFile( session.user.id, - fileId, + id, exportMimeType ) diff --git a/app/api/v1/drive/file/[id]/route.ts b/app/api/v1/drive/file/[id]/route.ts index 936ac16..3622eb4 100644 --- a/app/api/v1/drive/file/[id]/route.ts +++ b/app/api/v1/drive/file/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { auth } from '@/lib/auth' import { getGoogleDriveFileStream } from '@/lib/modules/drive/providers/google-drive.service' import { Readable } from 'stream' +import db from '@/lib/db' /** * @swagger @@ -32,7 +33,7 @@ import { Readable } from 'stream' */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth() @@ -40,15 +41,53 @@ export async function GET( return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const { fileId } = await params + const { id } = await params - if (!fileId) { + if (!id) { return NextResponse.json({ error: 'File ID required' }, { status: 400 }) } + // Check if file is stored locally or in Google Drive + const fileRecord = await db.file.findFirst({ + where: { id, userId: session.user.id }, + select: { path: true, filename: true, meta: true } + }) + + if (!fileRecord) { + return NextResponse.json({ error: 'File not found' }, { status: 404 }) + } + + // If path starts with /data/files/, it's a local file + if (fileRecord.path && fileRecord.path.startsWith('/data/files/')) { + const { readFile } = await import('fs/promises') + const path = await import('path') + const localPath = path.join(process.cwd(), fileRecord.path) + + try { + const buffer = await readFile(localPath) + + // Determine mime type + const mimeType = (fileRecord.meta as any)?.mimeType || + getMimeTypeFromFilename(fileRecord.filename) || + 'application/octet-stream' + + const headers: Record = { + 'Content-Type': mimeType, + 'Cache-Control': 'public, max-age=3600', + 'Content-Length': buffer.length.toString() + } + + return new NextResponse(new Uint8Array(buffer), { headers }) + } catch (err) { + console.error('Error reading local file:', err) + return NextResponse.json({ error: 'File not found on disk' }, { status: 404 }) + } + } + + // Otherwise, fetch from Google Drive const { stream, mimeType, size } = await getGoogleDriveFileStream( session.user.id, - fileId + id ) // Convert Node.js stream to Web ReadableStream @@ -75,3 +114,22 @@ export async function GET( } } +function getMimeTypeFromFilename(filename: string): string { + const ext = filename.toLowerCase().split('.').pop() + const mimeTypes: Record = { + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'png': 'image/png', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'svg': 'image/svg+xml', + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'json': 'application/json', + 'xml': 'text/xml', + 'mp4': 'video/mp4', + 'mp3': 'audio/mpeg', + } + return mimeTypes[ext || ''] || 'application/octet-stream' +} + diff --git a/app/api/v1/drive/file/[id]/signed-url/route.ts b/app/api/v1/drive/file/[id]/signed-url/route.ts new file mode 100644 index 0000000..1609e43 --- /dev/null +++ b/app/api/v1/drive/file/[id]/signed-url/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import db from '@/lib/db' +import { sign } from 'jsonwebtoken' + +interface BodyInput { + filename?: string + ttlSec?: number +} + +/** + * @swagger + * /api/v1/drive/file/{id}/signed-url: + * post: + * tags: [Drive] + * summary: Create a short-lived signed URL for streaming a Drive file + * description: Returns a URL that streams the file via `/content/{filename}?token=...` for cookie-less access (e.g., AI model fetch). + * security: + * - BearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * filename: + * type: string + * description: Optional filename to include in the URL path. + * ttlSec: + * type: integer + * description: Time-to-live in seconds (default 3600, max 86400). + * responses: + * 200: + * description: Signed URL generated + * 400: + * description: Validation error + * 401: + * description: Unauthorized + * 404: + * description: File not found + * 500: + * description: Internal server error + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + if (!id) return NextResponse.json({ error: 'File ID required' }, { status: 400 }) + + // Ensure the file belongs to the current user + const file = await db.file.findFirst({ where: { id, userId: session.user.id }, select: { filename: true } }) + if (!file) return NextResponse.json({ error: 'File not found' }, { status: 404 }) + + const body = (await req.json().catch(() => ({}))) as BodyInput + const rawTtl = typeof body.ttlSec === 'number' ? body.ttlSec : 3600 + const ttlSec = Math.min(Math.max(rawTtl, 60), 86400) + const name = (body.filename && typeof body.filename === 'string') ? body.filename : file.filename + + const secret = process.env.TOKEN_SECRET || process.env.AUTH_SECRET || '' + if (!secret) return NextResponse.json({ error: 'Server secret not configured' }, { status: 500 }) + + const token = sign({ sub: id, userId: session.user.id }, secret, { expiresIn: ttlSec }) + + const base = new URL(req.url) + // Construct content URL: /api/v1/drive/file/{id}/content/{filename}?token=... + const filename = encodeURIComponent(name) + const contentPath = `/api/v1/drive/file/${encodeURIComponent(id)}/content/${filename}` + const signedUrl = new URL(contentPath, `${base.protocol}//${base.host}`) + signedUrl.searchParams.set('token', token) + + return NextResponse.json({ url: signedUrl.toString(), expiresIn: ttlSec }) + } catch (error) { + console.error('POST /api/v1/drive/file/{id}/signed-url error:', error) + return NextResponse.json({ error: 'Failed to generate signed URL' }, { status: 500 }) + } +} + + diff --git a/app/api/v1/drive/file/[id]/sync/route.ts b/app/api/v1/drive/file/[id]/sync/route.ts index 4c4b0b4..a03fcd5 100644 --- a/app/api/v1/drive/file/[id]/sync/route.ts +++ b/app/api/v1/drive/file/[id]/sync/route.ts @@ -74,15 +74,15 @@ async function streamToString(stream: NodeJS.ReadableStream): Promise { */ export async function GET( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { fileId } = await params; - if (!fileId) + const { id } = await params; + if (!id) return NextResponse.json({ error: "File ID required" }, { status: 400 }); const { searchParams } = new URL(req.url); @@ -91,7 +91,7 @@ export async function GET( if (mode === "meta") { const modifiedMs = await getGoogleFileModifiedTime( session.user.id, - fileId + id ); return NextResponse.json({ modifiedMs }); } @@ -99,7 +99,7 @@ export async function GET( if (mode === "html") { const { stream } = await exportGoogleDriveFile( session.user.id, - fileId, + id, "text/html" ); const html = await streamToString(stream); @@ -119,15 +119,15 @@ export async function GET( export async function POST( req: NextRequest, - { params }: { params: Promise<{ fileId: string }> } + { params }: { params: Promise<{ id: string }> } ) { try { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - const { fileId } = await params; - if (!fileId) + const { id } = await params; + if (!id) return NextResponse.json({ error: "File ID required" }, { status: 400 }); const body = (await req.json().catch(() => null)) as { html?: string }; @@ -140,7 +140,7 @@ export async function POST( const html = body.html; // Preserve basic formatting in Google Docs - await updateGoogleDocFromHTML(session.user.id, fileId, html); + await updateGoogleDocFromHTML(session.user.id, id, html); return NextResponse.json({ ok: true }); } catch (error: any) { return NextResponse.json( diff --git a/app/api/v1/drive/file/route.ts b/app/api/v1/drive/file/route.ts new file mode 100644 index 0000000..6890a9f --- /dev/null +++ b/app/api/v1/drive/file/route.ts @@ -0,0 +1,72 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { z } from 'zod' +import { listFilesByParent, getRootFolderId } from '@/lib/modules/drive' + +export const runtime = 'nodejs' + +const Query = z.object({ + parent: z.string().optional(), +}) + +/** + * @swagger + * /api/v1/drive/file: + * get: + * tags: [Drive] + * summary: List files for a parent folder (or root if omitted) + * parameters: + * - in: query + * name: parent + * required: false + * schema: + * type: string + * responses: + * 200: + * description: List of files for the specified parent + * content: + * application/json: + * schema: + * type: object + * properties: + * parentId: + * type: string + * files: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * 401: + * description: Unauthorized + * 400: + * description: Validation error + * 500: + * description: Failed to list files + */ +export async function GET(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const raw = { parent: searchParams.get('parent') || undefined } + const parsed = Query.safeParse(raw) + if (!parsed.success) { + return NextResponse.json({ error: 'Invalid query' }, { status: 400 }) + } + + const parent = parsed.data.parent + try { + const effectiveParentId = parent && parent.length > 0 ? parent : await getRootFolderId(session.user.id) + const entries = await listFilesByParent(session.user.id, effectiveParentId) + const files = entries.map(e => ({ id: e.id, name: e.name })) + return NextResponse.json({ parentId: effectiveParentId, files }) + } catch (err: any) { + return NextResponse.json({ error: err?.message || 'Failed to list files' }, { status: 500 }) + } +} + + diff --git a/app/api/v1/drive/file/upload/route.ts b/app/api/v1/drive/file/upload/route.ts index 9275f06..b5c18cb 100644 --- a/app/api/v1/drive/file/upload/route.ts +++ b/app/api/v1/drive/file/upload/route.ts @@ -17,16 +17,19 @@ function ensureDirSync(dir: string) { } export async function POST(req: Request) { + console.log('[DEBUG] Upload POST received') const session = await auth() + console.log('[DEBUG] Upload auth check:', { hasSession: !!session, userId: session?.user?.id }) if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) return new Promise((resolve) => { try { const headers = Object.fromEntries(req.headers as any) + console.log('[DEBUG] Creating Busboy with headers:', { contentType: headers['content-type'] }) const bb = Busboy({ headers }) let parent = '' - const saved: { name: string; path: string }[] = [] + const saved: { id: string; name: string; path: string }[] = [] const inserts: Promise[] = [] const insertErrors: any[] = [] @@ -35,7 +38,9 @@ export async function POST(req: Request) { }) bb.on('file', (_name, fileStream, info) => { + console.log('[DEBUG] Busboy file event:', { name: _name, filename: info.filename }) const filename = info.filename || 'file' + const fileId = randomUUID() // Save under data/files//; targetDir updated once we know parent let targetDir = LOCAL_BASE_DIR ensureDirSync(targetDir) @@ -52,7 +57,7 @@ export async function POST(req: Request) { fileStream.pipe(writeStream) writeStream.on('close', () => { const relativeFilePath = path.relative(LOCAL_BASE_DIR, finalPath) - saved.push({ name: path.basename(finalPath), path: relativeFilePath }) + saved.push({ id: fileId, name: path.basename(finalPath), path: relativeFilePath }) // Prepare DB insert for this file const userId = session.user!.id as string @@ -89,7 +94,7 @@ export async function POST(req: Request) { if (client?.file?.create) { await client.file.create({ data: { - id: randomUUID(), + id: fileId, userId, filename: path.basename(finalPath), parentId: resolvedParentId, @@ -101,7 +106,7 @@ export async function POST(req: Request) { }) } else { await db.$executeRaw`INSERT INTO "file" (id, user_id, filename, parent_id, meta, created_at, updated_at, path) - VALUES (${randomUUID()}, ${userId}, ${path.basename(finalPath)}, ${resolvedParentId}, ${JSON.stringify({})}, ${nowSec}, ${nowSec}, ${`/data/files/${resolvedParentId}/${path.basename(finalPath)}`})` + VALUES (${fileId}, ${userId}, ${path.basename(finalPath)}, ${resolvedParentId}, ${JSON.stringify({})}, ${nowSec}, ${nowSec}, ${`/data/files/${resolvedParentId}/${path.basename(finalPath)}`})` } } catch (err) { insertErrors.push(err) @@ -114,12 +119,18 @@ export async function POST(req: Request) { bb.on('finish', async () => { try { + console.log('[DEBUG] Upload finish, saved files:', saved.length) + console.log('[DEBUG] Waiting for DB inserts:', inserts.length) await Promise.all(inserts) + console.log('[DEBUG] DB inserts complete, errors:', insertErrors.length) if (insertErrors.length > 0) { + console.error('[DEBUG] Insert errors:', insertErrors) return resolve(NextResponse.json({ ok: false, error: 'Failed to save some files to the database' }, { status: 500 })) } + console.log('[DEBUG] Returning success response with files:', saved) resolve(NextResponse.json({ ok: true, files: saved })) } catch (e) { + console.error('[DEBUG] Finish handler error:', e) resolve(NextResponse.json({ ok: false, error: 'Database error' }, { status: 500 })) } }) diff --git a/app/api/v1/drive/files/recent/route.ts b/app/api/v1/drive/files/recent/route.ts new file mode 100644 index 0000000..30aa368 --- /dev/null +++ b/app/api/v1/drive/files/recent/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { z } from 'zod' +import { listRecentFiles } from '@/lib/db/drive.db' + +export const runtime = 'nodejs' + +const Query = z.object({ + limit: z.coerce.number().int().min(1).max(50).optional(), +}) + +/** + * @swagger + * /api/v1/drive/files/recent: + * get: + * tags: [Drive] + * summary: List recently edited files (current user) + * parameters: + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * responses: + * 200: + * description: List of recent files + * content: + * application/json: + * schema: + * type: object + * properties: + * files: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * 401: + * description: Unauthorized + * 500: + * description: Failed to list recent files + */ +export async function GET(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const raw = { limit: searchParams.get('limit') || undefined } + const parsed = Query.safeParse(raw) + if (!parsed.success) return NextResponse.json({ error: 'Invalid query' }, { status: 400 }) + const { limit } = parsed.data + + try { + const files = await listRecentFiles(session.user.id, limit ?? 5) + return NextResponse.json({ files }) + } catch (err: any) { + return NextResponse.json({ error: err?.message || 'Failed to list recent files' }, { status: 500 }) + } +} + + diff --git a/app/api/v1/drive/files/search/route.ts b/app/api/v1/drive/files/search/route.ts new file mode 100644 index 0000000..b57f54d --- /dev/null +++ b/app/api/v1/drive/files/search/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { z } from 'zod' +import { searchFilesByName } from '@/lib/db/drive.db' + +export const runtime = 'nodejs' + +const Query = z.object({ + q: z.string().min(1), + limit: z.coerce.number().int().min(1).max(50).optional(), +}) + +/** + * @swagger + * /api/v1/drive/files/search: + * get: + * tags: [Drive] + * summary: Search files by name (current user) + * parameters: + * - in: query + * name: q + * required: true + * schema: + * type: string + * - in: query + * name: limit + * required: false + * schema: + * type: integer + * minimum: 1 + * maximum: 50 + * responses: + * 200: + * description: List of matching files + * content: + * application/json: + * schema: + * type: object + * properties: + * files: + * type: array + * items: + * type: object + * properties: + * id: + * type: string + * name: + * type: string + * 401: + * description: Unauthorized + * 400: + * description: Validation error + * 500: + * description: Failed to search files + */ +export async function GET(req: Request) { + const session = await auth() + if (!session?.user?.id) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + + const { searchParams } = new URL(req.url) + const raw = { q: searchParams.get('q') || '', limit: searchParams.get('limit') || undefined } + const parsed = Query.safeParse(raw) + if (!parsed.success) return NextResponse.json({ error: 'Invalid query' }, { status: 400 }) + const { q, limit } = parsed.data + + try { + const files = await searchFilesByName(session.user.id, q, limit ?? 10) + return NextResponse.json({ files }) + } catch (err: any) { + return NextResponse.json({ error: err?.message || 'Failed to search files' }, { status: 500 }) + } +} + + + diff --git a/app/api/v1/drive/roots/route.ts b/app/api/v1/drive/roots/route.ts new file mode 100644 index 0000000..79e298e --- /dev/null +++ b/app/api/v1/drive/roots/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { getRootFolderId, getGoogleRootFolderId } from '@/lib/modules/drive' + +export const runtime = 'nodejs' + +/** + * @swagger + * /api/v1/drive/roots: + * get: + * tags: [Drive] + * summary: Get root folder ids for Local and Google Drive (current user) + * description: Returns the Local root id and, if connected, the Google Drive root id for the authenticated user. + * responses: + * 200: + * description: Root ids fetched + * content: + * application/json: + * schema: + * type: object + * properties: + * localRootId: + * type: string + * nullable: true + * googleRootId: + * type: string + * nullable: true + * 401: + * description: Unauthorized + * 500: + * description: Failed to fetch roots + */ +export async function GET() { + try { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + const userId = session.user.id + const [localRootId, googleRootId] = await Promise.all([ + getRootFolderId(userId).catch(() => null), + getGoogleRootFolderId(userId).catch(() => null), + ]) + return NextResponse.json({ + localRootId: localRootId || null, + googleRootId: googleRootId || null, + }) + } catch (error: any) { + return NextResponse.json( + { error: error?.message || 'Failed to fetch roots' }, + { status: 500 } + ) + } +} + + diff --git a/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/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 2d2c7f6..fd308ec 100644 --- a/components/ai/message.tsx +++ b/components/ai/message.tsx @@ -16,7 +16,7 @@ export const Message = ({ className, from, ...props }: MessageProps) => ( className={cn( 'group flex w-full items-end justify-end gap-2 py-4', from === 'user' ? 'is-user' : 'is-assistant flex-row-reverse justify-end', - '[&>div]:max-w-[80%]', + '[&>div]:max-w-[100%]', className )} {...props} diff --git a/components/chat/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 ? ( - - ) : ( - + ) : ( <> -
-