mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-10-17 13:19:18 +02:00
Closes #1176. Salvages @gabegraves's work from #1178, rebased onto the
RagPipelineService refactor that landed after it was written.
Adds JPEG/PNG/WebP attachments to chat over multipart, leaving text-only JSON
requests on their existing path. Images are normalized server-side to bounded
JPEG with metadata stripped, capped on count/size/pixels/output, and the
temporary uploads are cleaned up on every validation path. They are attached
only to the latest user message, are excluded from RAG, titles, logs and
persisted history, and are never written to storage.
Vision support is resolved per model from what the backend actually reports --
OpenAI-compatible /v1/models metadata, then Ollama /api/show, then llama.cpp
/props -- never inferred from a model name. A model that reports no vision
support has attachments disabled with a visible explanation; a backend that
reports nothing is allowed with a warning and its rejection is translated into
plain compatibility guidance rather than a raw error.
Changes from the original:
- Prompt assembly moved into RagPipelineService in the meantime, so the
controller half is rebuilt against the trace and the multipart-aware
collection filter is now the only one (dev's request.input() version would
have silently dropped the collection on every image request).
- Added a native-path converter. dev split chat into native and OpenAI-compat
paths after this was written; Ollama's native /api/chat does not take OpenAI
content parts, it takes a sibling `images` array of bare base64. Without this
every image request on a native Ollama backend would fail.
- Dropped the model-recommendation change (selectRecommendedModels and the
Qwen3-VL fallback entry). Displacing a recommended model at first-time setup
is a product decision and is being taken separately. The "Supports images"
label in Easy Setup is kept: it describes what is already on offer rather
than changing it.
- Dropped the DEFAULT_QUERY_REWRITE_MODEL download prompt his branch carried,
which dev deleted in d022c2d.
- Removed a duplicate checkModelHasThinking; kept dev's memoized getModelInfo
path, since thinking detection is not part of this feature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F8Mgg1vd2eKSs8StZiuyMV
126 lines
3.6 KiB
TypeScript
126 lines
3.6 KiB
TypeScript
import type { MultipartFile } from '@adonisjs/bodyparser/types'
|
|
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js'
|
|
import sharp from 'sharp'
|
|
|
|
export const MAX_CHAT_IMAGES = 4
|
|
export const MAX_CHAT_IMAGE_BYTES = 8 * 1024 * 1024
|
|
export const MAX_CHAT_IMAGE_PIXELS = 40_000_000
|
|
export const MAX_NORMALIZED_IMAGE_BYTES = 4 * 1024 * 1024
|
|
export const MAX_NORMALIZED_IMAGE_DIMENSION = 2048
|
|
|
|
const SUPPORTED_IMAGE_FORMATS = new Set(['jpeg', 'png', 'webp'])
|
|
|
|
export class ChatImageError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public readonly status: 413 | 415 | 422
|
|
) {
|
|
super(message)
|
|
this.name = 'ChatImageError'
|
|
}
|
|
}
|
|
|
|
export type NormalizedChatImage = {
|
|
name: string
|
|
dataUrl: string
|
|
}
|
|
|
|
export async function normalizeChatImages(files: MultipartFile[]): Promise<NormalizedChatImage[]> {
|
|
if (files.length > MAX_CHAT_IMAGES) {
|
|
throw new ChatImageError(`Attach no more than ${MAX_CHAT_IMAGES} images.`, 422)
|
|
}
|
|
|
|
const normalized: NormalizedChatImage[] = []
|
|
for (const file of files) {
|
|
normalized.push(await normalizeChatImage(file))
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
async function normalizeChatImage(file: MultipartFile): Promise<NormalizedChatImage> {
|
|
if (!file.tmpPath) {
|
|
throw new ChatImageError(`Could not process "${file.clientName}".`, 422)
|
|
}
|
|
if (file.size > MAX_CHAT_IMAGE_BYTES) {
|
|
throw new ChatImageError(`"${file.clientName}" exceeds the 8 MB per-image limit.`, 413)
|
|
}
|
|
if (!file.isValid) {
|
|
throw new ChatImageError(`"${file.clientName}" is not a valid image upload.`, 422)
|
|
}
|
|
|
|
try {
|
|
const pipeline = sharp(file.tmpPath, {
|
|
animated: false,
|
|
failOn: 'warning',
|
|
limitInputPixels: MAX_CHAT_IMAGE_PIXELS,
|
|
})
|
|
const metadata = await pipeline.metadata()
|
|
|
|
if (!metadata.format || !SUPPORTED_IMAGE_FORMATS.has(metadata.format)) {
|
|
throw new ChatImageError(
|
|
`"${file.clientName}" is not supported. Use JPEG, PNG, or WebP.`,
|
|
415
|
|
)
|
|
}
|
|
if ((metadata.pages ?? 1) > 1) {
|
|
throw new ChatImageError(`Animated images are not supported.`, 415)
|
|
}
|
|
|
|
const normalized = await pipeline
|
|
.rotate()
|
|
.resize({
|
|
width: MAX_NORMALIZED_IMAGE_DIMENSION,
|
|
height: MAX_NORMALIZED_IMAGE_DIMENSION,
|
|
fit: 'inside',
|
|
withoutEnlargement: true,
|
|
})
|
|
.flatten({ background: '#ffffff' })
|
|
.jpeg({ quality: 85 })
|
|
.toBuffer()
|
|
|
|
if (normalized.byteLength > MAX_NORMALIZED_IMAGE_BYTES) {
|
|
throw new ChatImageError(
|
|
`"${file.clientName}" remains too large after image processing.`,
|
|
413
|
|
)
|
|
}
|
|
|
|
return {
|
|
name: file.clientName,
|
|
dataUrl: `data:image/jpeg;base64,${normalized.toString('base64')}`,
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof ChatImageError) throw error
|
|
throw new ChatImageError(`"${file.clientName}" could not be decoded as an image.`, 422)
|
|
}
|
|
}
|
|
|
|
export function attachImagesToLatestUserMessage(
|
|
messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
|
|
images: NormalizedChatImage[]
|
|
): ChatCompletionMessageParam[] {
|
|
if (images.length === 0) return messages
|
|
|
|
let latestUserIndex = -1
|
|
messages.forEach((message, index) => {
|
|
if (message.role === 'user') latestUserIndex = index
|
|
})
|
|
if (latestUserIndex < 0) {
|
|
throw new ChatImageError('Images require a user message.', 422)
|
|
}
|
|
|
|
return messages.map((message, index) => {
|
|
if (index !== latestUserIndex) return message
|
|
return {
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'text', text: message.content },
|
|
...images.map((image) => ({
|
|
type: 'image_url' as const,
|
|
image_url: { url: image.dataUrl },
|
|
})),
|
|
],
|
|
}
|
|
})
|
|
}
|