diff options
| -rw-r--r-- | apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx | 151 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/BookmarkPreview.tsx | 2 | ||||
| -rw-r--r-- | apps/workers/openaiWorker.ts | 9 | ||||
| -rw-r--r-- | packages/db/drizzle/0031_yummy_famine.sql | 1 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/0031_snapshot.json | 1229 | ||||
| -rw-r--r-- | packages/db/drizzle/meta/_journal.json | 7 | ||||
| -rw-r--r-- | packages/db/schema.ts | 1 | ||||
| -rw-r--r-- | packages/shared-react/hooks/bookmarks.ts | 15 | ||||
| -rw-r--r-- | packages/shared/inference.ts | 47 | ||||
| -rw-r--r-- | packages/shared/prompts.ts | 14 | ||||
| -rw-r--r-- | packages/shared/types/bookmarks.ts | 2 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 69 |
12 files changed, 1536 insertions, 11 deletions
diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx new file mode 100644 index 00000000..c1f3a18a --- /dev/null +++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx @@ -0,0 +1,151 @@ +import React from "react"; +import { ActionButton } from "@/components/ui/action-button"; +import { Button } from "@/components/ui/button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { toast } from "@/components/ui/use-toast"; +import { cn } from "@/lib/utils"; +import { + ChevronDown, + ChevronUp, + Loader2, + RefreshCw, + Trash2, +} from "lucide-react"; + +import { + useSummarizeBookmark, + useUpdateBookmark, +} from "@hoarder/shared-react/hooks/bookmarks"; +import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks"; + +function AISummary({ + bookmarkId, + summary, +}: { + bookmarkId: string; + summary: string; +}) { + const [isExpanded, setIsExpanded] = React.useState(false); + const { mutate: resummarize, isPending: isResummarizing } = + useSummarizeBookmark({ + onError: () => { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + }, + }); + const { mutate: updateBookmark, isPending: isUpdatingBookmark } = + useUpdateBookmark({ + onError: () => { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + }, + }); + return ( + <div className="w-full p-1"> + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + <div + className={` + relative overflow-hidden rounded-lg p-4 + transition-all duration-300 ease-in-out + ${isExpanded ? "h-auto" : "h-[4.5em] cursor-pointer"} + bg-gradient-to-r from-purple-400 via-pink-500 to-red-500 p-[2px] + `} + onClick={() => !isExpanded && setIsExpanded(true)} + > + <div className="h-full rounded-lg bg-background p-3"> + <p + className={`text-sm text-gray-700 dark:text-gray-300 ${!isExpanded && "line-clamp-3"}`} + > + {summary} + </p> + {!isExpanded && ( + <div className="absolute bottom-0 left-0 right-0 h-8 bg-gradient-to-t from-white to-transparent dark:from-gray-800" /> + )} + <span className="absolute bottom-2 right-2 flex gap-2"> + {isExpanded && ( + <> + <ActionButton + variant="none" + size="none" + spinner={<LoadingSpinner className="size-4" />} + className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" + aria-label={isExpanded ? "Collapse" : "Expand"} + loading={isResummarizing} + onClick={() => resummarize({ bookmarkId })} + > + <RefreshCw size={16} /> + </ActionButton> + <ActionButton + size="none" + variant="none" + spinner={<LoadingSpinner className="size-4" />} + className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" + aria-label={isExpanded ? "Collapse" : "Expand"} + loading={isUpdatingBookmark} + onClick={() => updateBookmark({ bookmarkId, summary: null })} + > + <Trash2 size={16} /> + </ActionButton> + </> + )} + <button + className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400" + aria-label={isExpanded ? "Collapse" : "Expand"} + onClick={() => setIsExpanded(!isExpanded)} + > + {isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />} + </button> + </span> + </div> + </div> + </div> + ); +} + +export default function SummarizeBookmarkArea({ + bookmark, +}: { + bookmark: ZBookmark; +}) { + const { mutate, isPending } = useSummarizeBookmark({ + onError: () => { + toast({ + description: "Something went wrong", + variant: "destructive", + }); + }, + }); + + if (bookmark.content.type !== BookmarkTypes.LINK) { + return null; + } + + if (bookmark.summary) { + return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />; + } else { + return ( + <div className="flex w-full items-center gap-4"> + <Button + onClick={() => mutate({ bookmarkId: bookmark.id })} + className={cn( + `relative w-full overflow-hidden bg-opacity-30 bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500 transition-all duration-300`, + isPending ? "text-transparent" : "text-gray-300", + )} + disabled={isPending} + > + {isPending && ( + <div className="absolute inset-0 flex items-center justify-center"> + <div className="animate-gradient-x background-animate h-full w-full bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500"></div> + <Loader2 className="absolute h-5 w-5 animate-spin text-white" /> + </div> + )} + <span className="relative z-10">Summarize with AI</span> + </Button> + </div> + ); + } +} diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 5e2764f4..e37c4b86 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -24,6 +24,7 @@ import { } from "@hoarder/shared-react/utils/bookmarkUtils"; import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks"; +import SummarizeBookmarkArea from "../bookmarks/SummarizeBookmarkArea"; import ActionBar from "./ActionBar"; import { AssetContentSection } from "./AssetContentSection"; import AttachmentBox from "./AttachmentBox"; @@ -137,6 +138,7 @@ export default function BookmarkPreview({ </div> <CreationTime createdAt={bookmark.createdAt} /> + <SummarizeBookmarkArea bookmark={bookmark} /> <div className="flex items-center gap-4"> <p className="text-sm text-gray-400">Tags</p> <BookmarkTagsEditor bookmark={bookmark} /> diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts index b1394f73..4fe74f44 100644 --- a/apps/workers/openaiWorker.ts +++ b/apps/workers/openaiWorker.ts @@ -180,6 +180,7 @@ async function inferTagsFromImage( ), metadata.contentType, base64, + { json: true }, ); } @@ -235,14 +236,16 @@ async function inferTagsFromPDF( `Content: ${pdfParse.text}`, serverConfig.inference.contextLength, ); - return inferenceClient.inferFromText(prompt); + return inferenceClient.inferFromText(prompt, { json: true }); } async function inferTagsFromText( bookmark: NonNullable<Awaited<ReturnType<typeof fetchBookmark>>>, inferenceClient: InferenceClient, ) { - return await inferenceClient.inferFromText(await buildPrompt(bookmark)); + return await inferenceClient.inferFromText(await buildPrompt(bookmark), { + json: true, + }); } async function inferTags( @@ -290,7 +293,7 @@ async function inferTags( return tags; } catch (e) { - const responseSneak = response.response.substr(0, 20); + const responseSneak = response.response.substring(0, 20); throw new Error( `[inference][${jobId}] The model ignored our prompt and didn't respond with the expected JSON: ${JSON.stringify(e)}. Here's a sneak peak from the response: ${responseSneak}`, ); diff --git a/packages/db/drizzle/0031_yummy_famine.sql b/packages/db/drizzle/0031_yummy_famine.sql new file mode 100644 index 00000000..e45d3491 --- /dev/null +++ b/packages/db/drizzle/0031_yummy_famine.sql @@ -0,0 +1 @@ +ALTER TABLE `bookmarks` ADD `summary` text;
\ No newline at end of file diff --git a/packages/db/drizzle/meta/0031_snapshot.json b/packages/db/drizzle/meta/0031_snapshot.json new file mode 100644 index 00000000..86e67583 --- /dev/null +++ b/packages/db/drizzle/meta/0031_snapshot.json @@ -0,0 +1,1229 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b79989eb-b62a-4ea7-aa4b-f3de839cb15e", + "prevId": "3188359f-c324-4fca-be40-4903de779a2d", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "assets": { + "name": "assets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "contentType": { + "name": "contentType", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "assets_bookmarkId_idx": { + "name": "assets_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "assets_assetType_idx": { + "name": "assets_assetType_idx", + "columns": [ + "assetType" + ], + "isUnique": false + }, + "assets_userId_idx": { + "name": "assets_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "assets_bookmarkId_bookmarks_id_fk": { + "name": "assets_bookmarkId_bookmarks_id_fk", + "tableFrom": "assets", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "assets_userId_user_id_fk": { + "name": "assets_userId_user_id_fk", + "tableFrom": "assets", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkAssets": { + "name": "bookmarkAssets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "assetType": { + "name": "assetType", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "assetId": { + "name": "assetId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fileName": { + "name": "fileName", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkAssets_id_bookmarks_id_fk": { + "name": "bookmarkAssets_id_bookmarks_id_fk", + "tableFrom": "bookmarkAssets", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "htmlContent": { + "name": "htmlContent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawlStatus": { + "name": "crawlStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + } + }, + "indexes": { + "bookmarkLinks_url_idx": { + "name": "bookmarkLinks_url_idx", + "columns": [ + "url" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parentId": { + "name": "parentId", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_userId_idx": { + "name": "bookmarkLists_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarkLists_parentId_bookmarkLists_id_fk": { + "name": "bookmarkLists_parentId_bookmarkLists_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "parentId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_name_idx": { + "name": "bookmarkTags_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "bookmarkTags_userId_idx": { + "name": "bookmarkTags_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sourceUrl": { + "name": "sourceUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "taggingStatus": { + "name": "taggingStatus", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarks_userId_idx": { + "name": "bookmarks_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "bookmarks_archived_idx": { + "name": "bookmarks_archived_idx", + "columns": [ + "archived" + ], + "isUnique": false + }, + "bookmarks_favourited_idx": { + "name": "bookmarks_favourited_idx", + "columns": [ + "favourited" + ], + "isUnique": false + }, + "bookmarks_createdAt_idx": { + "name": "bookmarks_createdAt_idx", + "columns": [ + "createdAt" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "bookmarksInLists_bookmarkId_idx": { + "name": "bookmarksInLists_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "bookmarksInLists_listId_idx": { + "name": "bookmarksInLists_listId_idx", + "columns": [ + "listId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {} + }, + "config": { + "name": "config", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "customPrompts": { + "name": "customPrompts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "customPrompts_userId_idx": { + "name": "customPrompts_userId_idx", + "columns": [ + "userId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "customPrompts_userId_user_id_fk": { + "name": "customPrompts_userId_user_id_fk", + "tableFrom": "customPrompts", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tagsOnBookmarks_tagId_idx": { + "name": "tagsOnBookmarks_tagId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + }, + "tagsOnBookmarks_bookmarkId_idx": { + "name": "tagsOnBookmarks_bookmarkId_idx", + "columns": [ + "bookmarkId" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +}
\ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 0a357e2d..5edfb39a 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -218,6 +218,13 @@ "when": 1728220453621, "tag": "0030_blue_synch", "breakpoints": true + }, + { + "idx": 31, + "version": "6", + "when": 1729980727614, + "tag": "0031_yummy_famine", + "breakpoints": true } ] }
\ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 6098feb1..033295bf 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -118,6 +118,7 @@ export const bookmarks = sqliteTable( taggingStatus: text("taggingStatus", { enum: ["pending", "failure", "success"], }).default("pending"), + summary: text("summary"), note: text("note"), type: text("type", { enum: [BookmarkTypes.LINK, BookmarkTypes.TEXT, BookmarkTypes.ASSET], diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts index cba4d107..f4dd203c 100644 --- a/packages/shared-react/hooks/bookmarks.ts +++ b/packages/shared-react/hooks/bookmarks.ts @@ -96,6 +96,21 @@ export function useUpdateBookmarkText( }); } +export function useSummarizeBookmark( + ...opts: Parameters<typeof api.bookmarks.summarizeBookmark.useMutation> +) { + const apiUtils = api.useUtils(); + return api.bookmarks.summarizeBookmark.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + export function useRecrawlBookmark( ...opts: Parameters<typeof api.bookmarks.recrawlBookmark.useMutation> ) { diff --git a/packages/shared/inference.ts b/packages/shared/inference.ts index f34c2880..e09076db 100644 --- a/packages/shared/inference.ts +++ b/packages/shared/inference.ts @@ -9,12 +9,24 @@ export interface InferenceResponse { totalTokens: number | undefined; } +export interface InferenceOptions { + json: boolean; +} + +const defaultInferenceOptions: InferenceOptions = { + json: true, +}; + export interface InferenceClient { - inferFromText(prompt: string): Promise<InferenceResponse>; + inferFromText( + prompt: string, + opts: InferenceOptions, + ): Promise<InferenceResponse>; inferFromImage( prompt: string, contentType: string, image: string, + opts: InferenceOptions, ): Promise<InferenceResponse>; } @@ -41,11 +53,14 @@ class OpenAIInferenceClient implements InferenceClient { }); } - async inferFromText(prompt: string): Promise<InferenceResponse> { + async inferFromText( + prompt: string, + opts: InferenceOptions = defaultInferenceOptions, + ): Promise<InferenceResponse> { const chatCompletion = await this.openAI.chat.completions.create({ messages: [{ role: "user", content: prompt }], model: serverConfig.inference.textModel, - response_format: { type: "json_object" }, + response_format: opts.json ? { type: "json_object" } : undefined, }); const response = chatCompletion.choices[0].message.content; @@ -59,10 +74,11 @@ class OpenAIInferenceClient implements InferenceClient { prompt: string, contentType: string, image: string, + opts: InferenceOptions = defaultInferenceOptions, ): Promise<InferenceResponse> { const chatCompletion = await this.openAI.chat.completions.create({ model: serverConfig.inference.imageModel, - response_format: { type: "json_object" }, + response_format: opts.json ? { type: "json_object" } : undefined, messages: [ { role: "user", @@ -98,10 +114,15 @@ class OllamaInferenceClient implements InferenceClient { }); } - async runModel(model: string, prompt: string, image?: string) { + async runModel( + model: string, + prompt: string, + image?: string, + opts: InferenceOptions = defaultInferenceOptions, + ) { const chatCompletion = await this.ollama.chat({ model: model, - format: "json", + format: opts.json ? "json" : undefined, stream: true, keep_alive: serverConfig.inference.ollamaKeepAlive, options: { @@ -137,19 +158,29 @@ class OllamaInferenceClient implements InferenceClient { return { response, totalTokens }; } - async inferFromText(prompt: string): Promise<InferenceResponse> { - return await this.runModel(serverConfig.inference.textModel, prompt); + async inferFromText( + prompt: string, + opts: InferenceOptions = defaultInferenceOptions, + ): Promise<InferenceResponse> { + return await this.runModel( + serverConfig.inference.textModel, + prompt, + undefined, + opts, + ); } async inferFromImage( prompt: string, _contentType: string, image: string, + opts: InferenceOptions = defaultInferenceOptions, ): Promise<InferenceResponse> { return await this.runModel( serverConfig.inference.imageModel, prompt, image, + opts, ); } } diff --git a/packages/shared/prompts.ts b/packages/shared/prompts.ts index 91bfba3f..d741030a 100644 --- a/packages/shared/prompts.ts +++ b/packages/shared/prompts.ts @@ -50,3 +50,17 @@ You must respond in JSON with the key "tags" and the value is an array of string const truncatedContent = truncateContent(content, contextLength - promptSize); return constructPrompt(truncatedContent); } + +export function buildSummaryPrompt( + lang: string, + content: string, + contextLength: number, +) { + const constructPrompt = (c: string) => ` + Summarize the following content in a 3-4 sentences in ${lang}: + ${c}`; + + const promptSize = calculateNumTokens(constructPrompt("")); + const truncatedContent = truncateContent(content, contextLength - promptSize); + return constructPrompt(truncatedContent); +} diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index c8ab96f9..c731cb32 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -72,6 +72,7 @@ export const zBareBookmarkSchema = z.object({ favourited: z.boolean(), taggingStatus: z.enum(["success", "failure", "pending"]).nullable(), note: z.string().nullish(), + summary: z.string().nullish(), }); export const zBookmarkSchema = zBareBookmarkSchema.merge( @@ -150,6 +151,7 @@ export const zUpdateBookmarksRequestSchema = z.object({ bookmarkId: z.string(), archived: z.boolean().optional(), favourited: z.boolean().optional(), + summary: z.string().nullish(), note: z.string().optional(), title: z.string().max(MAX_TITLE_LENGTH).nullish(), createdAt: z.date().optional(), diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index 6439111a..c5147c70 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -21,6 +21,9 @@ import { tagsOnBookmarks, } from "@hoarder/db/schema"; import { deleteAsset } from "@hoarder/shared/assetdb"; +import serverConfig from "@hoarder/shared/config"; +import { InferenceClientFactory } from "@hoarder/shared/inference"; +import { buildSummaryPrompt } from "@hoarder/shared/prompts"; import { LinkCrawlerQueue, OpenAIQueue, @@ -393,6 +396,7 @@ export const bookmarksAppRouter = router({ archived: input.archived, favourited: input.favourited, note: input.note, + summary: input.summary, createdAt: input.createdAt, }) .where( @@ -969,4 +973,69 @@ export const bookmarksAppRouter = router({ () => ({}), ); }), + summarizeBookmark: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + }), + ) + .output( + z.object({ + summary: z.string(), + }), + ) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + const inferenceClient = InferenceClientFactory.build(); + if (!inferenceClient) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No inference client configured", + }); + } + const bookmark = await ctx.db.query.bookmarkLinks.findFirst({ + where: and(eq(bookmarks.id, input.bookmarkId)), + }); + + if (!bookmark) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Bookmark not found or not a link", + }); + } + + const bookmarkDetails = ` +Title: ${bookmark.title ?? ""} +Description: ${bookmark.description ?? ""} +Content: ${bookmark.content ?? ""} +`; + + const summaryPrompt = buildSummaryPrompt( + serverConfig.inference.inferredTagLang, + bookmarkDetails, + serverConfig.inference.contextLength, + ); + + const summary = await inferenceClient.inferFromText(summaryPrompt, { + json: false, + }); + + if (!summary.response) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to summarize bookmark", + }); + } + await ctx.db + .update(bookmarks) + .set({ + summary: summary.response, + }) + .where(eq(bookmarks.id, input.bookmarkId)); + + return { + bookmarkId: input.bookmarkId, + summary: summary.response, + }; + }), }); |
