diff --git a/.gitignore b/.gitignore index 40af263..552bc4e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ node_modules uploads +llm-models + # Output .output .vercel diff --git a/docker-compose.yml b/docker-compose.yml index f25671d..24b7331 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: - fbla26 fbla26: image: git.marinodev.com/marinodev/fbla26_ci:latest - restart: on-failure + restart: unless-stopped env_file: ".env" ports: - "${FBLA26_PORT}:3000" @@ -22,6 +22,29 @@ services: volumes: - ./.env:/srv/fbla26/.env - /var/fbla26/uploads/:/srv/fbla26/uploads/ + llama: + image: ghcr.io/ggml-org/llama.cpp:server + restart: unless-stopped + env_file: ".env" + ports: + - "${LLAMA_PORT}:8080" + networks: + - fbla26 + volumes: + - /home/drake/llama/models:/models:ro + command: + - -m + - /models/Qwen3VL-2B-Instruct-Q4_K_M.gguf + - --mmproj + - /models/mmproj-Qwen3VL-2B-Instruct-Q8_0.gguf + - --host + - 0.0.0.0 + - --port + - "8080" + - -n + - "512" + + networks: fbla26: diff --git a/src/lib/components/custom/edit-item-dialog.svelte b/src/lib/components/custom/edit-item-dialog.svelte index 8ade4c6..67858c5 100644 --- a/src/lib/components/custom/edit-item-dialog.svelte +++ b/src/lib/components/custom/edit-item-dialog.svelte @@ -8,7 +8,7 @@ import { Separator } from '$lib/components/ui/separator'; import TrashIcon from '@lucide/svelte/icons/trash'; import ImageUpload from '$lib/components/custom/image-upload/image-upload.svelte'; - import { genDescription } from '$lib/db/items.remote'; + import { approveDenyItem, genDescription } from '$lib/db/items.remote'; import { EMAIL_REGEX_STRING } from '$lib/consts'; import type { Item } from '$lib/types/item.server'; import { Textarea } from '$lib/components/ui/textarea'; @@ -26,9 +26,9 @@ let description: string = $derived(item ? item.description : ''); let isGenerating = $state(false); - async function onSelect() { + async function onSelect(file: File) { isGenerating = true; - description = await genDescription(); + description = await genDescription(file); isGenerating = false; } @@ -110,13 +110,23 @@ - - Cancel - - +
+ + +
+ + + Cancel + + +
+
{#if item?.threads} diff --git a/src/lib/components/custom/image-upload/image-upload.svelte b/src/lib/components/custom/image-upload/image-upload.svelte index 64341ff..4aed224 100644 --- a/src/lib/components/custom/image-upload/image-upload.svelte +++ b/src/lib/components/custom/image-upload/image-upload.svelte @@ -14,7 +14,6 @@ export let previewUrl: string | null = null; let dragging = false; - console.log(previewUrl); function openFileDialog() { if (!disabled) { @@ -69,6 +68,7 @@ disabled={disabled} capture="environment" on:change={onInputChange} + formaction="/item?/describe" hidden /> diff --git a/src/lib/components/custom/item-listing.svelte b/src/lib/components/custom/item-listing.svelte index 98c54c9..33fe4d1 100644 --- a/src/lib/components/custom/item-listing.svelte +++ b/src/lib/components/custom/item-listing.svelte @@ -31,7 +31,7 @@
+ class="h-full bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-w-2xs"> {#if item.image} Lost item diff --git a/src/lib/components/custom/submit-item-dialog.svelte b/src/lib/components/custom/submit-item-dialog.svelte index 548b8ac..147bedd 100644 --- a/src/lib/components/custom/submit-item-dialog.svelte +++ b/src/lib/components/custom/submit-item-dialog.svelte @@ -16,9 +16,15 @@ let description: string | undefined = $state(); let isGenerating = $state(false); - async function onSelect() { + async function onSelect(file: File) { isGenerating = true; - description = await genDescription(); + + const buffer = await file.arrayBuffer(); + const base64 = btoa( + String.fromCharCode(...new Uint8Array(buffer)) + ); + + description = await genDescription(base64); isGenerating = false; } diff --git a/src/lib/db/items.remote.ts b/src/lib/db/items.remote.ts index fea83be..8bd49f8 100644 --- a/src/lib/db/items.remote.ts +++ b/src/lib/db/items.remote.ts @@ -1,12 +1,31 @@ -import { command, getRequestEvent, query } from '$app/server'; +import { command, getRequestEvent } from '$app/server'; import * as v from 'valibot'; import sql from '$lib/db/db.server'; import { verifyJWT } from '$lib/auth/index.server'; +import sharp from 'sharp'; +import { LLMDescribe } from '$lib/llm/llm.server'; -export const genDescription = query(async () => { - await new Promise((f) => setTimeout(f, 1000)); +export const genDescription = command(v.string(), async (data) => { + const inputBuffer = Buffer.from(data, 'base64'); - return 'A matte black water bottle with a black lid and a "BKLYN BENTO" logo on the side, resting on a tree trunk in a forest.'; + // Convert File → Buffer + // const inputBuffer = Buffer.from(await file.arrayBuffer()); + + // Detect format (Sharp does this internally) + const image = sharp(inputBuffer).rotate(); + + const outputBuffer = await image + .rotate() + .resize({ fit: 'outside', height: 400, width: 400 }) + .jpeg({ quality: 80 }) // adjust if needed + .toBuffer(); + + const description = await LLMDescribe( + `data:image/jpeg;base64,${outputBuffer.toString('base64')}` + ); + console.log(description); + + return description; }); export const approveDenyItem = command( diff --git a/src/lib/email/sender.server.ts b/src/lib/email/sender.server.ts index 8a986b8..37be8dc 100644 --- a/src/lib/email/sender.server.ts +++ b/src/lib/email/sender.server.ts @@ -1,7 +1,7 @@ import nodemailer from 'nodemailer'; // Create a transporter object using SMTP transport -const transporter = nodemailer.createTransport({ +export const transporter = nodemailer.createTransport({ host: process.env.EMAIL_HOST, port: Number(process.env.EMAIL_PORT), secure: true, // true for 465, false for other ports @@ -11,13 +11,13 @@ const transporter = nodemailer.createTransport({ } }); -export async function sendEmployerNotificationEmail() { - // Send mail with defined transport object - await transporter.sendMail({ - from: `CareerConnect Notifications <${process.env.EMAIL_USER}>`, - // to: info.emails.join(', '), // EMAILING OF REAL COMPANIES DISABLED, UNCOMMENT TO ENABLE - to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING - subject: 'New Application Received!', - text: `A new application has been received for the posting ''!\n\nCheck it out at ${process.env.BASE_URL}/` - }); -} +// export async function sendEmail() { +// // Send mail with defined transport object +// await transporter.sendMail({ +// from: `Westuffind Notifier <${process.env.EMAIL_USER}>`, +// // to: info.emails.join(', '), // EMAILING OF REAL COMPANIES DISABLED, UNCOMMENT TO ENABLE +// to: 'drake@marinodev.com', // TEMPORARY EMAIL FOR TESTING +// subject: 'New Application Received!', +// text: `A new application has been received for the posting ''!\n\nCheck it out at ${process.env.BASE_URL}/` +// }); +// } diff --git a/src/lib/llm/llm.server.ts b/src/lib/llm/llm.server.ts new file mode 100644 index 0000000..113e72d --- /dev/null +++ b/src/lib/llm/llm.server.ts @@ -0,0 +1,44 @@ +export async function LLMDescribe(imageData: string) { + const payload = { + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'Describe only the main object in this image in one sentence. Include its color, shape, brand, or any text on it. Focus solely on the object itself. Example: A blue Hydroflask water bottle with a dented lid and a sticker of a mountain on the side.' + }, + { + type: 'image_url', + image_url: { + url: imageData + } + } + ] + } + ], + max_tokens: 128, + temperature: 0.2 + }; + + console.log('AIing it'); + + const res = await fetch( + `http://${process.env.LLAMA_HOST!}:${process.env.LLAMA_PORT!}/v1/chat/completions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + } + ); + + if (!res.ok) { + console.error(await res.text()); + process.exit(1); + } + + const data = await res.json(); + return data.choices[0].message.content; +} diff --git a/src/routes/items/+page.server.ts b/src/routes/items/+page.server.ts index 5b2d8f8..acce700 100644 --- a/src/routes/items/+page.server.ts +++ b/src/routes/items/+page.server.ts @@ -8,7 +8,7 @@ import type { Item } from '$lib/types/item.server'; import { getFormString, getRequiredFormString } from '$lib/shared'; export const load: PageServerLoad = async ({ url, locals }) => { - const searchQuery = url.searchParams.get('search') as string; + const searchQuery = url.searchParams.get('search'); // If the user is logged in, fetch items together with their threads (each thread contains its first message) if (locals && locals.user) { @@ -33,6 +33,7 @@ export const load: PageServerLoad = async ({ url, locals }) => { FROM inquiry_messages im WHERE im.thread_id = t.id ) m ON TRUE + ${searchQuery ? sql`WHERE word_similarity(${searchQuery}, i.description) > 0.3` : sql``} GROUP BY i.id; `; items.forEach((item) => @@ -265,7 +266,6 @@ export const actions: Actions = { let outputBuffer: Buffer | undefined; if (file) { - console.log(file); if (!(file instanceof File) || file.size === 0) { return fail(400, { message: 'No file uploaded or file is invalid', success: false }); } @@ -315,7 +315,6 @@ export const actions: Actions = { inquire: async ({ request, url }) => { const id = url.searchParams.get('id'); if (!id) { - console.log('No id provided!'); return fail(400, { message: 'Missing id!', success: false }); } @@ -370,4 +369,29 @@ export const actions: Actions = { }); } } + // describe: async ({ request }) => { + // const data = await request.formData(); + // + // let image = data.get('image'); + // if (!(image instanceof File) || image.size === 0) { + // return fail(400, { message: 'No file uploaded or file is invalid', success: false }); + // } + // + // // Convert File → Buffer + // const inputBuffer = Buffer.from(await file.arrayBuffer()); + // + // // Detect format (Sharp does this internally) + // const image = sharp(inputBuffer).rotate(); + // + // const outputBuffer = await image + // .rotate() + // .resize({ fit: 'outside', height: 400, width: 400 }) + // .jpeg({ quality: 90 }) // adjust if needed + // .toBuffer(); + // + // const description = LLMDescribe(`data:image/jpeg;base64,${outputBuffer.toString('base64')}`); + // console.log(description); + // + // return description; + // } } satisfies Actions; diff --git a/src/routes/items/+page.svelte b/src/routes/items/+page.svelte index cbc4c33..05f259d 100644 --- a/src/routes/items/+page.svelte +++ b/src/routes/items/+page.svelte @@ -70,39 +70,59 @@
-
+ {#if data.items.length === 0} - {#if data.user && data.items.some( - (item) => item.approvedDate === null - )} - - Pending items - - {/if} +

No items found!

+

Try broadening your search terms to get more results.

+ {:else} +
- {#each data.items as item (item.id)} - {#if item.approvedDate === null} - + {#if data.user && data.items.some( + (item) => item.approvedDate === null + )} + + Pending items + {/if} - {/each} - {#if data.user && data.items.some( - (item) => item.approvedDate !== null - )} - - Public items - - {/if} + {#each data.items as item (item.id)} + {#if item.approvedDate === null} + + {/if} + {/each} - {#each data.items as item (item.id)} - {#if item.approvedDate !== null} - + {#if data.user && data.items.some( + (item) => item.approvedDate !== null && item.claimedDate === null + )} + + Public items + {/if} - {/each} -
+ {#each data.items as item (item.id)} + {#if item.approvedDate !== null} + + {/if} + {/each} + + {#if data.user && data.items.some( + (item) => item.claimedDate !== null + )} + + Claimed items + + {#each data.items as item (item.id)} + {#if item.claimedDate !== null} + + {/if} + {/each} + {/if} + +
+ {/if}