Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
66e0cf6
feat: enhance Drive layout with mobile navigation and responsive desi…
apensotti Nov 2, 2025
45daf1d
feat: implement video streaming support and error handling in VideoPr…
apensotti Nov 2, 2025
0d5b03b
feat: add rename functionality and optimistic UI updates for file sta…
apensotti Nov 2, 2025
1bb18fd
feat: implement mobile layout for folder, starred, trash pages with r…
apensotti Nov 2, 2025
8266c29
feat: add mobile floating action button to drive pages and refactor F…
apensotti Nov 2, 2025
6b02575
feat: add filename truncation for better display in FilesResultsTable…
apensotti Nov 2, 2025
b45e13b
feat: add sidebar toggle button to FilesSearchBar for improved naviga…
apensotti Nov 2, 2025
53aa45d
Merge pull request #105 from openchatui/drive-improvements
apensotti Nov 2, 2025
3e273a3
Mobile UX & Navigation: Responsive layouts, sidebar toggle, Drive roo…
apensotti Nov 3, 2025
5c04fb5
Video generation: show locally saved video in chat; enrich status end…
apensotti Nov 3, 2025
fe8f45c
RELEASE: Update v0.1.30 CHANGELOG
github-actions[bot] Nov 3, 2025
1255768
fix: update profile image URL path in POST request response
apensotti Nov 4, 2025
fc8c860
feat: implement local state management for model activation toggle
apensotti Nov 4, 2025
c612369
feat: add toast notifications for toggle actions in admin components
apensotti Nov 4, 2025
c766c00
feat: enhance usePinnedModels hook to support initial pinned models a…
apensotti Nov 4, 2025
70f0d72
fix: adjust padding in ChatInput and PromptSuggestions components for…
apensotti Nov 4, 2025
1d13e4c
fix: update max-width for message component and prevent auto-focus on…
apensotti Nov 4, 2025
a11d08c
Merge admin-panel-fixes-enhancements into dev
apensotti Nov 4, 2025
31f7f55
Merge sidebar-pinned-fix into dev
apensotti Nov 4, 2025
e45b36f
feat: integrate active Ollama models fetching and enhance model activ…
apensotti Nov 4, 2025
3687f5e
Merge documents-attachments-and-context into dev (#117)
apensotti Nov 12, 2025
ea1842a
Drive trash fixes (#118)
apensotti Nov 13, 2025
6bdd26d
Dev rebase 2025 11 13 (#119)
apensotti Nov 13, 2025
4a74d09
Dev: prepare clean merge to main by updating 4 files only (#122)
apensotti Nov 13, 2025
f828fe2
Merge remote-tracking branch 'origin/dev' into merge/dev-into-main-ov…
apensotti Nov 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ node_modules/
# editor folders
.cursor/
.vscode/

# local docker compose overrides
docker-compose.override.yml
docker-compose.local.yml
120 changes: 120 additions & 0 deletions app/api/v1/chat/attachments/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 })
}
}

123 changes: 116 additions & 7 deletions app/api/v1/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MessageMetadata>[]): 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<MessageMetadata>[]
),
messages: modelMessages,
system: combinedSystem,
experimental_transform: smoothStream({
delayInMs: 10, // optional: defaults to 10ms
Expand Down Expand Up @@ -202,23 +297,37 @@ export async function POST(req: NextRequest) {
tools: mergedTools as any,
});
const toUIArgs = StreamUtils.buildToUIMessageStreamArgs(
validatedFull as UIMessage<MessageMetadata>[],
fullMessages as UIMessage<MessageMetadata>[],
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,
Expand Down
Loading
Loading