diff options
| author | Mohamed Bassem <me@mbassem.com> | 2024-03-19 00:33:11 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-03-19 00:33:11 +0000 |
| commit | 785a5b574992296e187a66412dd42f7b4a686353 (patch) | |
| tree | 64b608927cc63d7494395f639636fd4b36e5a977 /apps | |
| parent | 549520919c482e72cdf7adae5ba852d1b6cbe5aa (diff) | |
| download | karakeep-785a5b574992296e187a66412dd42f7b4a686353.tar.zst | |
Feature: Add support for uploading images and automatically inferring their tags (#2)
* feature: Experimental support for asset uploads
* feature(web): Add new bookmark type asset
* feature: Add support for automatically tagging images
* fix: Add support for image assets in preview page
* use next Image for fetching the images
* Fix auth and error codes in the route handlers
* Add support for image uploads on mobile
* Fix typing of upload requests
* Remove the ugly dragging box
* Bump mobile version to 1.3
* Change the editor card placeholder to mention uploading images
* Fix a typo
* Change ios icon for photo library
* Silence typescript error
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/mobile/app.json | 12 | ||||
| -rw-r--r-- | apps/mobile/app/dashboard/(tabs)/index.tsx | 36 | ||||
| -rw-r--r-- | apps/mobile/app/sharing.tsx | 34 | ||||
| -rw-r--r-- | apps/mobile/components/bookmarks/BookmarkCard.tsx | 33 | ||||
| -rw-r--r-- | apps/mobile/lib/upload.ts | 72 | ||||
| -rw-r--r-- | apps/mobile/package.json | 1 | ||||
| -rw-r--r-- | apps/mobile/tsconfig.json | 2 | ||||
| -rw-r--r-- | apps/web/app/api/assets/[assetId]/route.ts | 29 | ||||
| -rw-r--r-- | apps/web/app/api/assets/route.ts | 52 | ||||
| -rw-r--r-- | apps/web/app/api/trpc/[trpc]/route.ts | 19 | ||||
| -rw-r--r-- | apps/web/app/dashboard/layout.tsx | 3 | ||||
| -rw-r--r-- | apps/web/components/dashboard/UploadDropzone.tsx | 79 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/AssetCard.tsx | 76 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx | 17 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx | 4 | ||||
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/EditorCard.tsx | 4 | ||||
| -rw-r--r-- | apps/web/package.json | 1 | ||||
| -rw-r--r-- | apps/web/server/api/client.ts | 18 | ||||
| -rw-r--r-- | apps/workers/openaiWorker.ts | 104 | ||||
| -rw-r--r-- | apps/workers/package.json | 5 |
20 files changed, 551 insertions, 50 deletions
diff --git a/apps/mobile/app.json b/apps/mobile/app.json index e674f8b5..9df4c895 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -3,7 +3,7 @@ "name": "Hoarder App", "slug": "hoarder", "scheme": "hoarder", - "version": "1.2.4", + "version": "1.3.0", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -35,13 +35,19 @@ "iosActivationRules": { "NSExtensionActivationSupportsWebURLWithMaxCount": 1, "NSExtensionActivationSupportsWebPageWithMaxCount": 1, - "NSExtensionActivationSupportsImageWithMaxCount": 0, + "NSExtensionActivationSupportsImageWithMaxCount": 1, "NSExtensionActivationSupportsMovieWithMaxCount": 0, "NSExtensionActivationSupportsText": true } } ], - "expo-secure-store" + "expo-secure-store", + [ + "expo-image-picker", + { + "photosPermission": "The app access your photo gallary on your request to hoard them." + } + ] ], "extra": { "router": { diff --git a/apps/mobile/app/dashboard/(tabs)/index.tsx b/apps/mobile/app/dashboard/(tabs)/index.tsx index 7f70af6b..a840ca93 100644 --- a/apps/mobile/app/dashboard/(tabs)/index.tsx +++ b/apps/mobile/app/dashboard/(tabs)/index.tsx @@ -1,21 +1,45 @@ import { Platform, SafeAreaView, View } from "react-native"; import * as Haptics from "expo-haptics"; +import * as ImagePicker from "expo-image-picker"; import { useRouter } from "expo-router"; import BookmarkList from "@/components/bookmarks/BookmarkList"; import PageTitle from "@/components/ui/PageTitle"; +import useAppSettings from "@/lib/settings"; +import { useUploadAsset } from "@/lib/upload"; import { MenuView } from "@react-native-menu/menu"; import { SquarePen } from "lucide-react-native"; +import { useToast } from "@/components/ui/Toast"; function HeaderRight() { + const {toast} = useToast(); const router = useRouter(); + const { settings } = useAppSettings(); + const { uploadAsset } = useUploadAsset(settings, { + onError: (e) => { + toast({message: e, variant: "destructive"}); + }, + }); return ( <MenuView - onPressAction={({ nativeEvent }) => { + onPressAction={async ({ nativeEvent }) => { Haptics.selectionAsync(); if (nativeEvent.event === "note") { router.navigate("dashboard/add-note"); } else if (nativeEvent.event === "link") { router.navigate("dashboard/add-link"); + } else if (nativeEvent.event === "library") { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + quality: 0, + allowsMultipleSelection: false, + }); + if (!result.canceled) { + uploadAsset({ + type: result.assets[0].mimeType ?? "", + name: result.assets[0].fileName ?? "", + uri: result.assets[0].uri, + }); + } } }} actions={[ @@ -31,10 +55,18 @@ function HeaderRight() { id: "note", title: "New Note", image: Platform.select({ - ios: "note", + ios: "note.text", android: "ic_menu_note", }), }, + { + id: "library", + title: "Photo Library", + image: Platform.select({ + ios: "photo", + android: "ic_menu_photo", + }), + }, ]} shouldOpenOnLongPress={false} > diff --git a/apps/mobile/app/sharing.tsx b/apps/mobile/app/sharing.tsx index e8b0ad09..2f9dbb27 100644 --- a/apps/mobile/app/sharing.tsx +++ b/apps/mobile/app/sharing.tsx @@ -2,8 +2,11 @@ import { useEffect, useState } from "react"; import { Text, View } from "react-native"; import { Link, useRouter } from "expo-router"; import { useShareIntentContext } from "expo-share-intent"; +import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; +import { useUploadAsset } from "@/lib/upload"; import { z } from "zod"; +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; type Mode = | { type: "idle" } @@ -11,12 +14,28 @@ type Mode = | { type: "error" }; function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { - const { hasShareIntent, shareIntent, resetShareIntent } = useShareIntentContext(); + const onSaved = (d: ZBookmark) => { + invalidateAllBookmarks(); + setMode({ type: "success", bookmarkId: d.id }); + }; + + const { hasShareIntent, shareIntent, resetShareIntent } = + useShareIntentContext(); + const { settings, isLoading } = useAppSettings(); + const { uploadAsset } = useUploadAsset(settings, { + onSuccess: onSaved, + onError: () => { + setMode({ type: "error" }); + }, + }); const invalidateAllBookmarks = api.useUtils().bookmarks.getBookmarks.invalidate; useEffect(() => { + if (isLoading) { + return; + } if (!isPending && shareIntent?.text) { const val = z.string().url(); if (val.safeParse(shareIntent.text).success) { @@ -25,17 +44,20 @@ function SaveBookmark({ setMode }: { setMode: (mode: Mode) => void }) { } else { mutate({ type: "text", text: shareIntent.text }); } + } else if (!isPending && shareIntent?.files) { + uploadAsset({ + type: shareIntent.files[0].type, + name: shareIntent.files[0].fileName ?? "", + uri: shareIntent.files[0].path, + }); } if (hasShareIntent) { resetShareIntent(); } - }, []); + }, [isLoading]); const { mutate, isPending } = api.bookmarks.createBookmark.useMutation({ - onSuccess: (d) => { - invalidateAllBookmarks(); - setMode({ type: "success", bookmarkId: d.id }); - }, + onSuccess: onSaved, onError: () => { setMode({ type: "error" }); }, diff --git a/apps/mobile/components/bookmarks/BookmarkCard.tsx b/apps/mobile/components/bookmarks/BookmarkCard.tsx index a969bc8b..ac6eaea4 100644 --- a/apps/mobile/components/bookmarks/BookmarkCard.tsx +++ b/apps/mobile/components/bookmarks/BookmarkCard.tsx @@ -11,6 +11,7 @@ import Markdown from "react-native-markdown-display"; import * as Haptics from "expo-haptics"; import { Link } from "expo-router"; import * as WebBrowser from "expo-web-browser"; +import useAppSettings from "@/lib/settings"; import { api } from "@/lib/trpc"; import { MenuView } from "@react-native-menu/menu"; import { Ellipsis, Star } from "lucide-react-native"; @@ -239,6 +240,35 @@ function TextCard({ bookmark }: { bookmark: ZBookmark }) { ); } +function AssetCard({ bookmark }: { bookmark: ZBookmark }) { + const { settings } = useAppSettings(); + if (bookmark.content.type !== "asset") { + throw new Error("Wrong content type rendered"); + } + + return ( + <View className="flex gap-2"> + <Image + source={{ + uri: `${settings.address}/api/assets/${bookmark.content.assetId}`, + headers: { + Authorization: `Bearer ${settings.apiKey}`, + }, + }} + className="h-56 min-h-56 w-full object-cover" + /> + <View className="flex gap-2 p-2"> + <TagList bookmark={bookmark} /> + <Divider orientation="vertical" className="mt-2 h-0.5 w-full" /> + <View className="mt-2 flex flex-row justify-between px-2 pb-2"> + <View /> + <ActionBar bookmark={bookmark} /> + </View> + </View> + </View> + ); +} + export default function BookmarkCard({ bookmark: initialData, }: { @@ -272,6 +302,9 @@ export default function BookmarkCard({ case "text": comp = <TextCard bookmark={bookmark} />; break; + case "asset": + comp = <AssetCard bookmark={bookmark} />; + break; } return <View className="border-b border-gray-300 bg-white">{comp}</View>; diff --git a/apps/mobile/lib/upload.ts b/apps/mobile/lib/upload.ts new file mode 100644 index 00000000..d511becc --- /dev/null +++ b/apps/mobile/lib/upload.ts @@ -0,0 +1,72 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { Settings } from "./settings"; +import { api } from "./trpc"; +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; +import { zUploadResponseSchema, zUploadErrorSchema } from "@hoarder/trpc/types/uploads"; + +export function useUploadAsset( + settings: Settings, + options: { onSuccess?: (bookmark: ZBookmark) => void; onError?: (e: string) => void }, +) { + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { + mutate: createBookmark, + isPending: isCreatingBookmark, + } = api.bookmarks.createBookmark.useMutation({ + onSuccess: (d) => { + invalidateAllBookmarks(); + if (options.onSuccess) { + options.onSuccess(d); + } + }, + onError: (e) => { + if (options.onError) { + options.onError(e.message); + } + }, + }); + + const { + mutate: uploadAsset, + isPending: isUploading, + } = useMutation({ + 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", { + uri: file.uri, + name: file.name, + type: file.type, + }); + const resp = await fetch(`${settings.address}/api/assets`, { + method: "POST", + body: formData, + headers: { + Authorization: `Bearer ${settings.apiKey}`, + }, + }); + if (!resp.ok) { + throw new Error(await resp.text()); + } + return zUploadResponseSchema.parse(await resp.json()); + }, + onSuccess: (resp) => { + const assetId = resp.assetId; + createBookmark({ type: "asset", assetId, assetType: "image" }); + }, + onError: (e) => { + if (options.onError) { + const err = zUploadErrorSchema.parse(JSON.parse(e.message)); + options.onError(err.error); + } + }, + }); + + return { + uploadAsset, + isPending: isUploading || isCreatingBookmark, + }; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b35c420c..cf097c09 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -23,6 +23,7 @@ "expo-dev-client": "^3.3.9", "expo-haptics": "^12.8.1", "expo-image": "^1.10.6", + "expo-image-picker": "^14.7.1", "expo-linking": "~6.2.2", "expo-router": "~3.4.8", "expo-secure-store": "^12.8.1", diff --git a/apps/mobile/tsconfig.json b/apps/mobile/tsconfig.json index 3bcf5741..77379cd1 100644 --- a/apps/mobile/tsconfig.json +++ b/apps/mobile/tsconfig.json @@ -2,7 +2,7 @@ "extends": "expo/tsconfig.base", "compilerOptions": { "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", - "types": ["nativewind/types"], + "types": ["nativewind/types", "react-native"], "incremental": true, "strict": true, "baseUrl": ".", diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts new file mode 100644 index 00000000..6b583e51 --- /dev/null +++ b/apps/web/app/api/assets/[assetId]/route.ts @@ -0,0 +1,29 @@ +import { createContextFromRequest } from "@/server/api/client"; +import { and, eq } from "drizzle-orm"; + +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; + +export const dynamic = "force-dynamic"; +export async function GET( + request: Request, + { params }: { params: { assetId: string } }, +) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const asset = await db.query.assets.findFirst({ + where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)), + }); + + if (!asset) { + return Response.json({ error: "Asset not found" }, { status: 404 }); + } + return new Response(asset.blob as string, { + status: 200, + headers: { + "Content-type": asset.contentType, + }, + }); +} diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts new file mode 100644 index 00000000..2caa4d4c --- /dev/null +++ b/apps/web/app/api/assets/route.ts @@ -0,0 +1,52 @@ +import { createContextFromRequest } from "@/server/api/client"; + +import type { ZUploadResponse } from "@hoarder/trpc/types/uploads"; +import { db } from "@hoarder/db"; +import { assets } from "@hoarder/db/schema"; + +const SUPPORTED_ASSET_TYPES = new Set(["image/jpeg", "image/png"]); + +const MAX_UPLOAD_SIZE_BYTES = 4 * 1024 * 1024; + +export const dynamic = "force-dynamic"; +export async function POST(request: Request) { + const ctx = await createContextFromRequest(request); + if (!ctx.user) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const formData = await request.formData(); + const data = formData.get("image"); + let buffer; + let contentType; + if (data instanceof File) { + contentType = data.type; + if (!SUPPORTED_ASSET_TYPES.has(contentType)) { + return Response.json( + { error: "Unsupported asset type" }, + { status: 400 }, + ); + } + if (data.size > MAX_UPLOAD_SIZE_BYTES) { + return Response.json({ error: "Asset is too big" }, { status: 413 }); + } + buffer = Buffer.from(await data.arrayBuffer()); + } else { + return Response.json({ error: "Bad request" }, { status: 400 }); + } + + const [dbRes] = await db + .insert(assets) + .values({ + encoding: "binary", + contentType: contentType, + blob: buffer, + userId: ctx.user.id, + }) + .returning(); + + return Response.json({ + assetId: dbRes.id, + contentType: dbRes.contentType, + size: buffer.byteLength, + } satisfies ZUploadResponse); +} diff --git a/apps/web/app/api/trpc/[trpc]/route.ts b/apps/web/app/api/trpc/[trpc]/route.ts index 23df286f..1afcb886 100644 --- a/apps/web/app/api/trpc/[trpc]/route.ts +++ b/apps/web/app/api/trpc/[trpc]/route.ts @@ -1,8 +1,6 @@ -import { createContext } from "@/server/api/client"; +import { createContextFromRequest } from "@/server/api/client"; import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; -import { db } from "@hoarder/db"; -import { authenticateApiKey } from "@hoarder/trpc/auth"; import { appRouter } from "@hoarder/trpc/routers/_app"; const handler = (req: Request) => @@ -18,20 +16,7 @@ const handler = (req: Request) => }, createContext: async (opts) => { - // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. - // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. - const authorizationHeader = opts.req.headers.get("Authorization"); - if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { - const token = authorizationHeader.split(" ")[1]; - try { - const user = await authenticateApiKey(token); - return { user, db }; - } catch (e) { - // Fallthrough to cookie-based auth - } - } - - return createContext(); + return await createContextFromRequest(opts.req); }, }); export { handler as GET, handler as POST }; diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx index dc3af9c7..27e06955 100644 --- a/apps/web/app/dashboard/layout.tsx +++ b/apps/web/app/dashboard/layout.tsx @@ -1,5 +1,6 @@ import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; import Sidebar from "@/components/dashboard/sidebar/Sidebar"; +import UploadDropzone from "@/components/dashboard/UploadDropzone"; import { Separator } from "@/components/ui/separator"; export default async function Dashboard({ @@ -17,7 +18,7 @@ export default async function Dashboard({ <MobileSidebar /> <Separator /> </div> - {children} + <UploadDropzone>{children}</UploadDropzone> </main> </div> ); diff --git a/apps/web/components/dashboard/UploadDropzone.tsx b/apps/web/components/dashboard/UploadDropzone.tsx new file mode 100644 index 00000000..61db8dc5 --- /dev/null +++ b/apps/web/components/dashboard/UploadDropzone.tsx @@ -0,0 +1,79 @@ +"use client"; + +import React, { useState } from "react"; +import { api } from "@/lib/trpc"; +import { useMutation } from "@tanstack/react-query"; +import DropZone from "react-dropzone"; + +import { + zUploadErrorSchema, + zUploadResponseSchema, +} from "@hoarder/trpc/types/uploads"; + +import { toast } from "../ui/use-toast"; + +export default function UploadDropzone({ + children, +}: { + children: React.ReactNode; +}) { + const invalidateAllBookmarks = + api.useUtils().bookmarks.getBookmarks.invalidate; + + const { mutate: createBookmark } = api.bookmarks.createBookmark.useMutation({ + onSuccess: () => { + toast({ description: "Bookmark uploaded" }); + invalidateAllBookmarks(); + }, + onError: () => { + toast({ description: "Something went wrong", variant: "destructive" }); + }, + }); + + const { mutate: uploadAsset } = useMutation({ + mutationFn: async (file: File) => { + const formData = new FormData(); + formData.append("image", file); + const resp = await fetch("/api/assets", { + method: "POST", + body: formData, + }); + if (!resp.ok) { + throw new Error(await resp.text()); + } + return zUploadResponseSchema.parse(await resp.json()); + }, + onSuccess: async (resp) => { + const assetId = resp.assetId; + createBookmark({ type: "asset", assetId, assetType: "image" }); + }, + onError: (error) => { + const err = zUploadErrorSchema.parse(JSON.parse(error.message)); + toast({ description: err.error, variant: "destructive" }); + }, + }); + + const [_isDragging, setDragging] = useState(false); + const onDrop = (acceptedFiles: File[]) => { + const file = acceptedFiles[0]; + setDragging(false); + uploadAsset(file); + }; + + return ( + <DropZone + multiple={false} + noClick + onDrop={onDrop} + onDragEnter={() => setDragging(true)} + onDragLeave={() => setDragging(false)} + > + {({ getRootProps, getInputProps }) => ( + <div {...getRootProps()}> + <input {...getInputProps()} hidden /> + {children} + </div> + )} + </DropZone> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx new file mode 100644 index 00000000..460dbe98 --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx @@ -0,0 +1,76 @@ +"use client"; + +import Image from "next/image"; +import { isBookmarkStillTagging } from "@/lib/bookmarkUtils"; +import { api } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; + +import type { ZBookmark } from "@hoarder/trpc/types/bookmarks"; + +import BookmarkActionBar from "./BookmarkActionBar"; +import TagList from "./TagList"; + +export default function AssetCard({ + bookmark: initialData, + className, +}: { + bookmark: ZBookmark; + className?: string; +}) { + const { data: bookmark } = api.bookmarks.getBookmark.useQuery( + { + bookmarkId: initialData.id, + }, + { + initialData, + refetchInterval: (query) => { + const data = query.state.data; + if (!data) { + return false; + } + if (isBookmarkStillTagging(data)) { + return 1000; + } + return false; + }, + }, + ); + const bookmarkedAsset = bookmark.content; + if (bookmarkedAsset.type != "asset") { + throw new Error("Unexpected bookmark type"); + } + + return ( + <div + className={cn( + className, + cn( + "flex h-min max-h-96 flex-col gap-y-1 overflow-hidden rounded-lg shadow-md", + ), + )} + > + {bookmarkedAsset.assetType == "image" && ( + <div className="relative h-56 max-h-56"> + <Image + alt="asset" + src={`/api/assets/${bookmarkedAsset.assetId}`} + fill={true} + className="rounded-t-lg object-cover" + /> + </div> + )} + <div className="flex flex-col gap-y-1 overflow-hidden p-2"> + <div className="flex h-full flex-wrap gap-1 overflow-hidden"> + <TagList + bookmark={bookmark} + loading={isBookmarkStillTagging(bookmark)} + /> + </div> + <div className="flex w-full justify-between"> + <div /> + <BookmarkActionBar bookmark={bookmark} /> + </div> + </div> + </div> + ); +} diff --git a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx index 69aa60a3..4209192e 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarkPreview.tsx @@ -1,5 +1,6 @@ "use client"; +import Image from "next/image"; import Link from "next/link"; import { BackButton } from "@/components/ui/back-button"; import { Skeleton } from "@/components/ui/skeleton"; @@ -70,6 +71,22 @@ export default function BookmarkPreview({ content = <Markdown className="prose">{bookmark.content.text}</Markdown>; break; } + case "asset": { + switch (bookmark.content.assetType) { + case "image": { + content = ( + <div className="relative w-full"> + <Image + alt="asset" + fill={true} + src={`/api/assets/${bookmark.content.assetId}`} + /> + </div> + ); + } + } + break; + } } return ( diff --git a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx index b689a192..b40e6e42 100644 --- a/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx +++ b/apps/web/components/dashboard/bookmarks/BookmarksGrid.tsx @@ -14,6 +14,7 @@ import type { ZGetBookmarksResponse, } from "@hoarder/trpc/types/bookmarks"; +import AssetCard from "./AssetCard"; import EditorCard from "./EditorCard"; import LinkCard from "./LinkCard"; import TextCard from "./TextCard"; @@ -47,6 +48,9 @@ function renderBookmark(bookmark: ZBookmark) { case "text": comp = <TextCard bookmark={bookmark} />; break; + case "asset": + comp = <AssetCard bookmark={bookmark} />; + break; } return <BookmarkCard key={bookmark.id}>{comp}</BookmarkCard>; } diff --git a/apps/web/components/dashboard/bookmarks/EditorCard.tsx b/apps/web/components/dashboard/bookmarks/EditorCard.tsx index 28e8f41f..44c5889b 100644 --- a/apps/web/components/dashboard/bookmarks/EditorCard.tsx +++ b/apps/web/components/dashboard/bookmarks/EditorCard.tsx @@ -69,7 +69,9 @@ export default function EditorCard({ className }: { className?: string }) { <Textarea disabled={isPending} className="h-full w-full resize-none border-none text-lg focus-visible:ring-0" - placeholder={"Paste a link or write a note ..."} + placeholder={ + "Paste a link, write a note or drag and drop an image in here ..." + } onKeyDown={(e) => { if (e.key === "Enter" && e.metaKey) { form.handleSubmit(onSubmit, onError)(); diff --git a/apps/web/package.json b/apps/web/package.json index 4b0b5e0d..53c7014a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -48,6 +48,7 @@ "prettier": "^3.2.5", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-dropzone": "^14.2.3", "react-hook-form": "^7.50.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", diff --git a/apps/web/server/api/client.ts b/apps/web/server/api/client.ts index 8b414d39..6a0a8909 100644 --- a/apps/web/server/api/client.ts +++ b/apps/web/server/api/client.ts @@ -2,8 +2,26 @@ import { getServerAuthSession } from "@/server/auth"; import { db } from "@hoarder/db"; import { Context, createCallerFactory } from "@hoarder/trpc"; +import { authenticateApiKey } from "@hoarder/trpc/auth"; import { appRouter } from "@hoarder/trpc/routers/_app"; +export async function createContextFromRequest(req: Request) { + // TODO: This is a hack until we offer a proper REST API instead of the trpc based one. + // Check if the request has an Authorization token, if it does, assume that API key authentication is requested. + const authorizationHeader = req.headers.get("Authorization"); + if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) { + const token = authorizationHeader.split(" ")[1]; + try { + const user = await authenticateApiKey(token); + return { user, db }; + } catch (e) { + // Fallthrough to cookie-based auth + } + } + + return createContext(); +} + export const createContext = async (database?: typeof db): Promise<Context> => { const session = await getServerAuthSession(); return { diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts index 1ec22d32..428f6027 100644 --- a/apps/workers/openaiWorker.ts +++ b/apps/workers/openaiWorker.ts @@ -1,19 +1,21 @@ +import { Job, Worker } from "bullmq"; +import { and, eq, inArray } from "drizzle-orm"; +import OpenAI from "openai"; +import { z } from "zod"; + +import Base64 from "js-base64"; + import { db } from "@hoarder/db"; -import logger from "@hoarder/shared/logger"; +import { assets, bookmarks, bookmarkTags, tagsOnBookmarks } from "@hoarder/db/schema"; import serverConfig from "@hoarder/shared/config"; +import logger from "@hoarder/shared/logger"; import { OpenAIQueue, + queueConnectionDetails, SearchIndexingQueue, ZOpenAIRequest, - queueConnectionDetails, zOpenAIRequestSchema, } from "@hoarder/shared/queues"; -import { Job } from "bullmq"; -import OpenAI from "openai"; -import { z } from "zod"; -import { Worker } from "bullmq"; -import { bookmarkTags, bookmarks, tagsOnBookmarks } from "@hoarder/db/schema"; -import { and, eq, inArray } from "drizzle-orm"; const openAIResponseSchema = z.object({ tags: z.array(z.string()), @@ -67,7 +69,15 @@ export class OpenAiWorker { } } -const PROMPT_BASE = ` +const IMAGE_PROMPT_BASE = ` +I'm building a read-it-later app and I need your help with automatic tagging. +Please analyze the attached image and suggest relevant tags that describe its key themes, topics, and main ideas. +Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. If it's a famous website +you may also include a tag for the website. If the tag is not generic enough, don't include it. Aim for 10-15 tags. +If there are no good tags, don't emit any. You must respond in valid JSON with the key "tags" and the value is list of tags. +Don't wrap the response in a markdown code.`; + +const TEXT_PROMPT_BASE = ` I'm building a read-it-later app and I need your help with automatic tagging. Please analyze the text after the sentence "CONTENT START HERE:" and suggest relevant tags that describe its key themes, topics, and main ideas. Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. If it's a famous website @@ -96,18 +106,18 @@ function buildPrompt( } } return ` -${PROMPT_BASE} +${TEXT_PROMPT_BASE} URL: ${bookmark.link.url} -Title: ${bookmark.link.title || ""} -Description: ${bookmark.link.description || ""} -Content: ${content || ""} +Title: ${bookmark.link.title ?? ""} +Description: ${bookmark.link.description ?? ""} +Content: ${content ?? ""} `; } if (bookmark.text) { // TODO: Ensure that the content doesn't exceed the context length of openai return ` -${PROMPT_BASE} +${TEXT_PROMPT_BASE} ${bookmark.text.text} `; } @@ -121,11 +131,55 @@ async function fetchBookmark(linkId: string) { with: { link: true, text: true, + asset: true, }, }); } -async function inferTags( +async function inferTagsFromImage( + jobId: string, + bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, + openai: OpenAI, +) { + + const asset = await db.query.assets.findFirst({ + where: eq(assets.id, bookmark.asset.assetId), + }); + + if (!asset) { + throw new Error(`[openai][${jobId}] AssetId ${bookmark.asset.assetId} for bookmark ${bookmark.id} not found`); + } + + const base64 = Base64.encode(asset.blob as string); + + const chatCompletion = await openai.chat.completions.create({ + model: "gpt-4-vision-preview", + messages: [ + { + role: "user", + content: [ + { type: "text", text: IMAGE_PROMPT_BASE }, + { + type: "image_url", + image_url: { + url: `data:image/jpeg;base64,${base64}`, + detail: "low", + }, + }, + ], + }, + ], + max_tokens: 2000, + }); + + const response = chatCompletion.choices[0].message.content; + if (!response) { + throw new Error(`[openai][${jobId}] Got no message content from OpenAI`); + } + return {response, totalTokens: chatCompletion.usage?.total_tokens}; +} + +async function inferTagsFromText( jobId: string, bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, openai: OpenAI, @@ -140,11 +194,27 @@ async function inferTags( if (!response) { throw new Error(`[openai][${jobId}] Got no message content from OpenAI`); } + return {response, totalTokens: chatCompletion.usage?.total_tokens}; +} + +async function inferTags( + jobId: string, + bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, + openai: OpenAI, +) { + let response; + if (bookmark.link || bookmark.text) { + response = await inferTagsFromText(jobId, bookmark, openai); + } else if (bookmark.asset) { + response = await inferTagsFromImage(jobId, bookmark, openai); + } else { + throw new Error(`[openai][${jobId}] Unsupported bookmark type`); + } try { - let tags = openAIResponseSchema.parse(JSON.parse(response)).tags; + let tags = openAIResponseSchema.parse(JSON.parse(response.response)).tags; logger.info( - `[openai][${jobId}] Inferring tag for bookmark "${bookmark.id}" used ${chatCompletion.usage?.total_tokens} tokens and inferred: ${tags}`, + `[openai][${jobId}] Inferring tag for bookmark "${bookmark.id}" used ${response.totalTokens} tokens and inferred: ${tags}`, ); // Sometimes the tags contain the hashtag symbol, let's strip them out if they do. diff --git a/apps/workers/package.json b/apps/workers/package.json index 8c8edc4d..1c3813b8 100644 --- a/apps/workers/package.json +++ b/apps/workers/package.json @@ -6,12 +6,15 @@ "dependencies": { "@hoarder/db": "workspace:^0.1.0", "@hoarder/shared": "workspace:^0.1.0", + "@hoarder/tsconfig": "workspace:^0.1.0", "@mozilla/readability": "^0.5.0", + "@tsconfig/node21": "^21.0.1", "async-mutex": "^0.4.1", "bullmq": "^5.1.9", "dompurify": "^3.0.9", "dotenv": "^16.4.1", "drizzle-orm": "^0.29.4", + "js-base64": "^3.7.7", "jsdom": "^24.0.0", "metascraper": "^5.43.4", "metascraper-description": "^5.43.4", @@ -27,8 +30,6 @@ "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-adblocker": "^2.13.6", "puppeteer-extra-plugin-stealth": "^2.11.2", - "@tsconfig/node21": "^21.0.1", - "@hoarder/tsconfig": "workspace:^0.1.0", "tsx": "^4.7.1", "typescript": "^5.3.3", "zod": "^3.22.4" |
