diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/mobile/lib/upload.ts | 6 | ||||
| -rw-r--r-- | apps/mobile/package.json | 2 | ||||
| -rw-r--r-- | apps/web/app/api/assets/route.ts | 7 | ||||
| -rw-r--r-- | apps/web/components/dashboard/UploadDropzone.tsx | 7 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/AssetCard.tsx | 7 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/AssetContentSection.tsx | 39 | ||||
| -rw-r--r-- | apps/workers/openaiWorker.ts | 69 | ||||
| -rw-r--r-- | apps/workers/package.json | 2 | ||||
| -rw-r--r-- | apps/workers/searchWorker.ts | 7 | ||||
| -rw-r--r-- | apps/workers/utils.ts | 32 |
10 files changed, 141 insertions, 37 deletions
diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts index 56b2c7a5..f9d05967 100644 --- a/apps/mobile/lib/upload.ts +++ b/apps/mobile/lib/upload.ts @@ -38,7 +38,7 @@ export function useUploadAsset( mutationFn: async (file: { type: string; name: string; uri: string }) => { const formData = new FormData(); // @ts-expect-error This is a valid api in react native - formData.append("image", { + formData.append("file", { uri: file.uri, name: file.name, type: file.type, @@ -57,7 +57,9 @@ export function useUploadAsset( }, onSuccess: (resp) => { const assetId = resp.assetId; - createBookmark({ type: "asset", assetId, assetType: "image" }); + const assetType = + resp.contentType === "application/pdf" ? "pdf" : "image"; + createBookmark({ type: "asset", assetId, assetType }); }, onError: (e) => { if (options.onError) { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 9f170040..248f1f53 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -31,7 +31,7 @@ "expo-navigation-bar": "~2.8.1", "expo-router": "~3.4.8", "expo-secure-store": "^12.8.1", - "expo-share-intent": "^1.1.0", + "expo-share-intent": "1.1.0", "expo-status-bar": "~1.11.1", "expo-system-ui": "^2.9.3", "expo-web-browser": "^12.8.2", diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts index 0bb2f778..5b72033a 100644 --- a/apps/web/app/api/assets/route.ts +++ b/apps/web/app/api/assets/route.ts @@ -9,6 +9,7 @@ const SUPPORTED_ASSET_TYPES = new Set([ "image/jpeg", "image/png", "image/webp", + "application/pdf", ]); const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024; @@ -26,7 +27,7 @@ export async function POST(request: Request) { }); } const formData = await request.formData(); - const data = formData.get("image"); + const data = formData.get("file") ?? formData.get("image"); let buffer; let contentType; if (data instanceof File) { @@ -46,11 +47,12 @@ export async function POST(request: Request) { } const assetId = crypto.randomUUID(); + const fileName = data.name; await saveAsset({ userId: ctx.user.id, assetId, - metadata: { contentType }, + metadata: { contentType, fileName }, asset: buffer, }); @@ -58,5 +60,6 @@ export async function POST(request: Request) { assetId, contentType, size: buffer.byteLength, + fileName, } satisfies ZUploadResponse); } diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx index bd08d2cf..f6243885 100644 --- a/apps/web/components/dashboard/UploadDropzone.tsx +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -29,7 +29,7 @@ function useUploadAsset({ onComplete }: { onComplete: () => void }) { const { mutateAsync: runUpload } = useMutation({ mutationFn: async (file: File) => { const formData = new FormData(); - formData.append("image", file); + formData.append("file", file); const resp = await fetch("/api/assets", { method: "POST", body: formData, @@ -40,8 +40,9 @@ function useUploadAsset({ onComplete }: { onComplete: () => void }) { return zUploadResponseSchema.parse(await resp.json()); }, onSuccess: async (resp) => { - const assetId = resp.assetId; - return createBookmark({ type: "asset", assetId, assetType: "image" }); + const assetType = + resp.contentType === "application/pdf" ? "pdf" : "image"; + return createBookmark({ ...resp, type: "asset", assetType }); }, onError: (error, req) => { const err = zUploadErrorSchema.parse(JSON.parse(error.message)); diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx index 460dbe98..8997a7e2 100644 --- a/apps/web/components/dashboard/bookmarks/AssetCard.tsx +++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx @@ -59,6 +59,13 @@ export default function AssetCard({ /> </div> )} + {bookmarkedAsset.assetType == "pdf" && ( + <iframe + title={bookmarkedAsset.assetId} + className="h-56 max-h-56 w-full" + src={`/api/assets/${bookmarkedAsset.assetId}`} + /> + )} <div className="flex flex-col gap-y-1 overflow-hidden p-2"> <div className="flex h-full flex-wrap gap-1 overflow-hidden"> <TagList diff --git a/apps/web/components/dashboard/preview/AssetContentSection.tsx b/apps/web/components/dashboard/preview/AssetContentSection.tsx index 3fbbc519..4a025f9d 100644 --- a/apps/web/components/dashboard/preview/AssetContentSection.tsx +++ b/apps/web/components/dashboard/preview/AssetContentSection.tsx @@ -7,25 +7,30 @@ export function AssetContentSection({ bookmark }: { bookmark: ZBookmark }) { throw new Error("Invalid content type"); } - let content; switch (bookmark.content.assetType) { case "image": { - switch (bookmark.content.assetType) { - case "image": { - content = ( - <div className="relative h-full min-w-full"> - <Image - alt="asset" - fill={true} - className="object-contain" - src={`/api/assets/${bookmark.content.assetId}`} - /> - </div> - ); - } - } - break; + return ( + <div className="relative h-full min-w-full"> + <Image + alt="asset" + fill={true} + className="object-contain" + src={`/api/assets/${bookmark.content.assetId}`} + /> + </div> + ); + } + case "pdf": { + return ( + <iframe + title={bookmark.content.assetId} + className="h-full w-full" + src={`/api/assets/${bookmark.content.assetId}`} + /> + ); + } + default: { + return <div>Unsupported asset type</div>; } } - return content; } diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts index c7b519e2..b07e02fe 100644 --- a/apps/workers/openaiWorker.ts +++ b/apps/workers/openaiWorker.ts @@ -5,7 +5,12 @@ import { z } from "zod"; import type { ZOpenAIRequest } from "@hoarder/shared/queues"; import { db } from "@hoarder/db"; -import { bookmarks, bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; +import { + bookmarkAssets, + bookmarks, + bookmarkTags, + tagsOnBookmarks, +} from "@hoarder/db/schema"; import { readAsset } from "@hoarder/shared/assetdb"; import serverConfig from "@hoarder/shared/config"; import logger from "@hoarder/shared/logger"; @@ -18,6 +23,7 @@ import { import type { InferenceClient } from "./inference"; import { InferenceClientFactory } from "./inference"; +import { readPDFText, truncateContent } from "./utils"; const openAIResponseSchema = z.object({ tags: z.array(z.string()), @@ -91,14 +97,6 @@ CONTENT START HERE: function buildPrompt( bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, ) { - const truncateContent = (content: string) => { - let words = content.split(" "); - if (words.length > 1500) { - words = words.slice(1500); - content = words.join(" "); - } - return content; - }; if (bookmark.link) { if (!bookmark.link.description && !bookmark.link.content) { throw new Error( @@ -158,14 +156,48 @@ async function inferTagsFromImage( ); } const base64 = asset.toString("base64"); - - return await inferenceClient.inferFromImage( + return inferenceClient.inferFromImage( IMAGE_PROMPT_BASE, metadata.contentType, base64, ); } +async function inferTagsFromPDF( + jobId: string, + bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, + inferenceClient: InferenceClient, +) { + const { asset } = await readAsset({ + userId: bookmark.userId, + assetId: bookmark.asset.assetId, + }); + if (!asset) { + throw new Error( + `[inference][${jobId}] AssetId ${bookmark.asset.assetId} for bookmark ${bookmark.id} not found`, + ); + } + const pdfParse = await readPDFText(asset); + if (!pdfParse?.text) { + throw new Error( + `[inference][${jobId}] PDF text is empty. Please make sure that the PDF includes text and not just images.`, + ); + } + + await db + .update(bookmarkAssets) + .set({ + content: pdfParse.text, + metadata: pdfParse.metadata ? JSON.stringify(pdfParse.metadata) : null, + }) + .where(eq(bookmarkAssets.id, bookmark.id)); + + const prompt = `${TEXT_PROMPT_BASE} +Content: ${truncateContent(pdfParse.text)} +`; + return inferenceClient.inferFromText(prompt); +} + async function inferTagsFromText( bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, inferenceClient: InferenceClient, @@ -182,11 +214,24 @@ async function inferTags( if (bookmark.link || bookmark.text) { response = await inferTagsFromText(bookmark, inferenceClient); } else if (bookmark.asset) { - response = await inferTagsFromImage(jobId, bookmark, inferenceClient); + switch (bookmark.asset.assetType) { + case "image": + response = await inferTagsFromImage(jobId, bookmark, inferenceClient); + break; + case "pdf": + response = await inferTagsFromPDF(jobId, bookmark, inferenceClient); + break; + default: + throw new Error(`[inference][${jobId}] Unsupported bookmark type`); + } } else { throw new Error(`[inference][${jobId}] Unsupported bookmark type`); } + if (!response) { + throw new Error(`[inference][${jobId}] Inference response is empty`); + } + try { let tags = openAIResponseSchema.parse(JSON.parse(response.response)).tags; logger.info( diff --git a/apps/workers/package.json b/apps/workers/package.json index c9de43a4..e14c576b 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -26,6 +26,8 @@ "metascraper-url": "^5.43.4", "ollama": "^0.5.0", "openai": "^4.29.0", + "pdf2json": "^3.0.5", + "pdfjs-dist": "^4.0.379", "puppeteer": "^22.0.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-adblocker": "^2.13.6", diff --git a/apps/workers/searchWorker.ts b/apps/workers/searchWorker.ts index 79b0c8c1..fcef7a1b 100644 --- a/apps/workers/searchWorker.ts +++ b/apps/workers/searchWorker.ts @@ -48,6 +48,7 @@ async function runIndex( with: { link: true, text: true, + asset: true, tagsOnBookmarks: { with: { tag: true, @@ -72,6 +73,12 @@ async function runIndex( content: bookmark.link.content, } : undefined), + ...(bookmark.asset + ? { + content: bookmark.asset.content, + metadata: bookmark.asset.metadata, + } + : undefined), ...(bookmark.text ? { content: bookmark.text.text } : undefined), note: bookmark.note, createdAt: bookmark.createdAt.toISOString(), diff --git a/apps/workers/utils.ts b/apps/workers/utils.ts index 2f56d3f0..f8c48408 100644 --- a/apps/workers/utils.ts +++ b/apps/workers/utils.ts @@ -1,3 +1,5 @@ +import PDFParser from "pdf2json"; + export function withTimeout<T, Ret>( func: (param: T) => Promise<Ret>, timeoutSec: number, @@ -14,3 +16,33 @@ export function withTimeout<T, Ret>( ]); }; } + +export async function readPDFText(buffer: Buffer): Promise<{ + text: string; + metadata: Record<string, string>; +}> { + return new Promise((resolve, reject) => { + // Need raw text flag represents as number (1), reference : https://github.com/modesty/pdf2json/issues/76#issuecomment-236569265 + const pdfParser = new PDFParser(null, 1); + pdfParser.on("pdfParser_dataError", reject); + pdfParser.on("pdfParser_dataReady", (pdfData) => { + // eslint-disable-next-line + resolve({ + // The type isn't set correctly, reference : https://github.com/modesty/pdf2json/issues/327 + // eslint-disable-next-line + text: (pdfParser as any).getRawTextContent(), + metadata: pdfData.Meta, + }); + }); + pdfParser.parseBuffer(buffer); + }); +} + +export function truncateContent(content: string, length = 1500) { + let words = content.split(" "); + if (words.length > length) { + words = words.slice(length); + content = words.join(" "); + } + return content; +} |
