ai impl
Some checks failed
ci / docker_image (push) Failing after 1s
ci / deploy (push) Has been skipped

This commit is contained in:
Drake Marino 2026-02-06 02:39:23 -06:00
parent 156e74450a
commit 90bbe4bfa4
11 changed files with 207 additions and 59 deletions

2
.gitignore vendored
View File

@ -4,6 +4,8 @@ node_modules
uploads
llm-models
# Output
.output
.vercel

View File

@ -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:

View File

@ -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 @@
</Field.Group>
<Dialog.Footer class="mt-4">
<Dialog.Close
type="button"
class={buttonVariants({ variant: "outline" })}
>
Cancel
</Dialog.Close>
<Button type="submit">Submit</Button>
<div class="flex justify-between w-full">
<Button variant="destructive"
onclick={() => {approveDenyItem({id: item?.id || 0, approved: false}); invalidateAll();}}>
Delete
</Button>
<div>
<Dialog.Close
type="button"
class={buttonVariants({ variant: "outline" })}
>
Cancel
</Dialog.Close>
<Button type="submit" class="ml-1">Submit</Button>
</div>
</div>
</Dialog.Footer>
</form>
{#if item?.threads}

View File

@ -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
/>

View File

@ -31,7 +31,7 @@
</script>
<div
class="h-full bg-card text-card-foreground flex flex-col gap-2 rounded-xl border shadow-sm max-w-sm overflow-hidden min-2-3xs">
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}
<img src="https://fbla26.marinodev.com/uploads/{item.id}.jpg" class="object-cover min-h-48 max-h-48"
alt="Lost item">

View File

@ -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;
}

View File

@ -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(

View File

@ -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}/`
// });
// }

44
src/lib/llm/llm.server.ts Normal file
View File

@ -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;
}

View File

@ -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;

View File

@ -70,39 +70,59 @@
</div>
</div>
<div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(16rem,max-content))] justify-center">
{#if data.items.length === 0}
{#if data.user && data.items.some(
(item) => item.approvedDate === null
)}
<FieldSeparator class="col-span-full text-lg h-8 my-2">
Pending items
</FieldSeparator>
{/if}
<h1 class="text-xl text-center font-semibold mt-16">No items found!</h1>
<p class="text-center">Try broadening your search terms to get more results.</p>
{:else}
<div class="grid gap-4 grid-cols-[repeat(auto-fill,minmax(18rem,max-content))] justify-center">
{#each data.items as item (item.id)}
{#if item.approvedDate === null}
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{#if data.user && data.items.some(
(item) => item.approvedDate === null
)}
<FieldSeparator class="col-span-full text-lg h-8 my-2">
Pending items
</FieldSeparator>
{/if}
{/each}
{#if data.user && data.items.some(
(item) => item.approvedDate !== null
)}
<FieldSeparator class="col-span-full text-lg my-2 h-8 w-full">
Public items
</FieldSeparator>
{/if}
{#each data.items as item (item.id)}
{#if item.approvedDate === null}
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if}
{/each}
{#each data.items as item (item.id)}
{#if item.approvedDate !== null}
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{#if data.user && data.items.some(
(item) => item.approvedDate !== null && item.claimedDate === null
)}
<FieldSeparator class="col-span-full text-lg my-2 h-8 w-full">
Public items
</FieldSeparator>
{/if}
{/each}
</div>
{#each data.items as item (item.id)}
{#if item.approvedDate !== null}
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if}
{/each}
{#if data.user && data.items.some(
(item) => item.claimedDate !== null
)}
<FieldSeparator class="col-span-full text-lg my-2 h-8 w-full">
Claimed items
</FieldSeparator>
{#each data.items as item (item.id)}
{#if item.claimedDate !== null}
<ItemListing item={item} admin={data.user !== null} editCallback={openEditDialog}
inquireCallback={openInquireDialog} claimCallback={openClaimDialog} />
{/if}
{/each}
{/if}
</div>
{/if}
</div>
<Button class="fixed shadow-lg bottom-6 right-6 rounded-xl md:hidden p-6 text-xl" size="default"