diff --git a/apps/web/app/dashboard/settings/page.tsx b/apps/web/app/dashboard/settings/page.tsx index e33a57ab..3c02df2e 100644 --- a/apps/web/app/dashboard/settings/page.tsx +++ b/apps/web/app/dashboard/settings/page.tsx @@ -1,7 +1,9 @@ +import Link from "next/link"; import ApiKeySettings from "@/components/dashboard/settings/ApiKeySettings"; import { ChangePassword } from "@/components/dashboard/settings/ChangePassword"; import ImportExport from "@/components/dashboard/settings/ImportExport"; import UserDetails from "@/components/dashboard/settings/UserDetails"; +import { ExternalLink } from "lucide-react"; export default async function Settings() { return ( @@ -13,6 +15,15 @@ export default async function Settings() {
+
+ + Inference Settings + + +
diff --git a/apps/web/app/dashboard/settings/prompts/page.tsx b/apps/web/app/dashboard/settings/prompts/page.tsx new file mode 100644 index 00000000..ba1c3f4f --- /dev/null +++ b/apps/web/app/dashboard/settings/prompts/page.tsx @@ -0,0 +1,325 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; +import { FullPageSpinner } from "@/components/ui/full-page-spinner"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { toast } from "@/components/ui/use-toast"; +import { useClientConfig } from "@/lib/clientConfig"; +import { api } from "@/lib/trpc"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Plus, Save, Trash2 } from "lucide-react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { buildImagePrompt, buildTextPrompt } from "@hoarder/shared/prompts"; +import { + zNewPromptSchema, + ZPrompt, + zUpdatePromptSchema, +} from "@hoarder/shared/types/prompts"; + +export function PromptEditor() { + const apiUtils = api.useUtils(); + + const form = useForm>({ + resolver: zodResolver(zNewPromptSchema), + defaultValues: { + text: "", + appliesTo: "all", + }, + }); + + const { mutateAsync: createPrompt, isPending: isCreating } = + api.prompts.create.useMutation({ + onSuccess: () => { + toast({ + description: "Prompt has been created!", + }); + apiUtils.prompts.list.invalidate(); + }, + }); + + return ( +
+ { + await createPrompt(value); + form.resetField("text"); + })} + > + { + return ( + + + + + + + ); + }} + /> + + { + return ( + + + + + + + ); + }} + /> + + + Add + + + + ); +} + +export function PromptRow({ prompt }: { prompt: ZPrompt }) { + const apiUtils = api.useUtils(); + const { mutateAsync: updatePrompt, isPending: isUpdating } = + api.prompts.update.useMutation({ + onSuccess: () => { + toast({ + description: "Prompt has been updated!", + }); + apiUtils.prompts.list.invalidate(); + }, + }); + const { mutate: deletePrompt, isPending: isDeleting } = + api.prompts.delete.useMutation({ + onSuccess: () => { + toast({ + description: "Prompt has been deleted!", + }); + apiUtils.prompts.list.invalidate(); + }, + }); + + const form = useForm>({ + resolver: zodResolver(zUpdatePromptSchema), + defaultValues: { + promptId: prompt.id, + text: prompt.text, + appliesTo: prompt.appliesTo, + }, + }); + + return ( +
+ { + await updatePrompt(value); + })} + > + { + return ( + + + + + + + ); + }} + /> + { + return ( + + + + + + + ); + }} + /> + + { + return ( + + + + + + + ); + }} + /> + + + Save + + deletePrompt({ promptId: prompt.id })} + className="items-center" + type="button" + > + + Delete + + + + ); +} + +export function CustomPrompts() { + const { data: prompts, isLoading } = api.prompts.list.useQuery(); + + return ( +
+
Custom Prompts
+

+ Prompts that you add here will be included as rules to the model during + tag generation. You can view the final prompts in the prompt preview + section. +

+ {isLoading && } + {prompts && prompts.length == 0 && ( +

+ You don't have any custom prompts yet. +

+ )} + {prompts && + prompts.map((prompt) => )} + +
+ ); +} + +export function PromptDemo() { + const { data: prompts } = api.prompts.list.useQuery(); + const clientConfig = useClientConfig(); + return ( +
+
+ Prompt Preview +
+

Text Prompt

+ + {buildTextPrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "text" || p.appliesTo == "all") + .map((p) => p.text), + "\n\n", + ).trim()} + +

Image Prompt

+ + {buildImagePrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "images" || p.appliesTo == "all") + .map((p) => p.text), + ).trim()} + +
+ ); +} + +export default function PromptsPage() { + return ( + <> +
+
+
+ Inference Settings +
+ +
+
+
+ +
+ + ); +} diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx index 50e9774d..31395199 100644 --- a/apps/web/lib/clientConfig.tsx +++ b/apps/web/lib/clientConfig.tsx @@ -7,6 +7,9 @@ export const ClientConfigCtx = createContext({ auth: { disableSignups: false, }, + inference: { + inferredTagLang: "english", + }, serverVersion: undefined, disableNewReleaseCheck: true, }); diff --git a/apps/workers/openaiWorker.ts b/apps/workers/openaiWorker.ts index 9b352811..6c6104f3 100644 --- a/apps/workers/openaiWorker.ts +++ b/apps/workers/openaiWorker.ts @@ -7,12 +7,14 @@ import { bookmarkAssets, bookmarks, bookmarkTags, + customPrompts, tagsOnBookmarks, } from "@hoarder/db/schema"; import { DequeuedJob, Runner } from "@hoarder/queue"; import { readAsset } from "@hoarder/shared/assetdb"; import serverConfig from "@hoarder/shared/config"; import logger from "@hoarder/shared/logger"; +import { buildImagePrompt, buildTextPrompt } from "@hoarder/shared/prompts"; import { OpenAIQueue, triggerSearchReindex, @@ -89,31 +91,10 @@ export class OpenAiWorker { } } -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. The tags language must be ${serverConfig.inference.inferredTagLang}. -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 between the sentences "CONTENT START HERE" and "CONTENT END 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. The tags language must be ${serverConfig.inference.inferredTagLang}. 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. -The content can include text for cookie consent and privacy policy, ignore those while tagging. -CONTENT START HERE -`; - -const TEXT_PROMPT_INSTRUCTIONS = ` -CONTENT END HERE -You must respond in JSON with the key "tags" and the value is an array of string tags. -Aim for 3-5 tags. If there are no good tags, leave the array empty. -`; - -function buildPrompt( +async function buildPrompt( bookmark: NonNullable>>, ) { + const prompts = await fetchCustomPrompts(bookmark.userId, "text"); if (bookmark.link) { if (!bookmark.link.description && !bookmark.link.content) { throw new Error( @@ -125,23 +106,24 @@ function buildPrompt( if (content) { content = truncateContent(content); } - return ` -${TEXT_PROMPT_BASE} -URL: ${bookmark.link.url} + return buildTextPrompt( + serverConfig.inference.inferredTagLang, + prompts, + `URL: ${bookmark.link.url} Title: ${bookmark.link.title ?? ""} Description: ${bookmark.link.description ?? ""} -Content: ${content ?? ""} -${TEXT_PROMPT_INSTRUCTIONS}`; +Content: ${content ?? ""}`, + ); } if (bookmark.text) { const content = truncateContent(bookmark.text.text ?? ""); // TODO: Ensure that the content doesn't exceed the context length of openai - return ` -${TEXT_PROMPT_BASE} -${content} -${TEXT_PROMPT_INSTRUCTIONS} - `; + return buildTextPrompt( + serverConfig.inference.inferredTagLang, + prompts, + content, + ); } throw new Error("Unknown bookmark type"); @@ -175,12 +157,32 @@ async function inferTagsFromImage( } const base64 = asset.toString("base64"); return inferenceClient.inferFromImage( - IMAGE_PROMPT_BASE, + buildImagePrompt( + serverConfig.inference.inferredTagLang, + await fetchCustomPrompts(bookmark.userId, "images"), + ), metadata.contentType, base64, ); } +async function fetchCustomPrompts( + userId: string, + appliesTo: "text" | "images", +) { + const prompts = await db.query.customPrompts.findMany({ + where: and( + eq(customPrompts.userId, userId), + inArray(customPrompts.appliesTo, ["all", appliesTo]), + ), + columns: { + text: true, + }, + }); + + return prompts.map((p) => p.text); +} + async function inferTagsFromPDF( jobId: string, bookmark: NonNullable>>, @@ -210,10 +212,11 @@ async function inferTagsFromPDF( }) .where(eq(bookmarkAssets.id, bookmark.id)); - const prompt = `${TEXT_PROMPT_BASE} -Content: ${truncateContent(pdfParse.text)} -${TEXT_PROMPT_INSTRUCTIONS} -`; + const prompt = buildTextPrompt( + serverConfig.inference.inferredTagLang, + await fetchCustomPrompts(bookmark.userId, "text"), + `Content: ${truncateContent(pdfParse.text)}`, + ); return inferenceClient.inferFromText(prompt); } @@ -221,7 +224,7 @@ async function inferTagsFromText( bookmark: NonNullable>>, inferenceClient: InferenceClient, ) { - return await inferenceClient.inferFromText(buildPrompt(bookmark)); + return await inferenceClient.inferFromText(await buildPrompt(bookmark)); } async function inferTags( diff --git a/packages/db/drizzle/0027_cute_talon.sql b/packages/db/drizzle/0027_cute_talon.sql new file mode 100644 index 00000000..695f9442 --- /dev/null +++ b/packages/db/drizzle/0027_cute_talon.sql @@ -0,0 +1,11 @@ +CREATE TABLE `customPrompts` ( + `id` text PRIMARY KEY NOT NULL, + `text` text NOT NULL, + `enabled` integer NOT NULL, + `attachedBy` text NOT NULL, + `createdAt` integer NOT NULL, + `userId` text NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `customPrompts_userId_idx` ON `customPrompts` (`userId`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0027_snapshot.json b/packages/db/drizzle/meta/0027_snapshot.json new file mode 100644 index 00000000..ad4a3ca1 --- /dev/null +++ b/packages/db/drizzle/meta/0027_snapshot.json @@ -0,0 +1,1166 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "486882c9-79bd-4665-948b-831834e3509c", + "prevId": "472a0256-4c40-464f-a2ff-851cdebb63c9", + "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 + }, + "bookmarkId": { + "name": "bookmarkId", + "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 + } + }, + "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" + } + }, + "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 + } + }, + "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'" + }, + "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 6b6e932f..4ecbc478 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -190,6 +190,13 @@ "when": 1720334457344, "tag": "0026_silky_imperial_guard", "breakpoints": true + }, + { + "idx": 27, + "version": "6", + "when": 1727572281889, + "tag": "0027_cute_talon", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 4398523a..6514c277 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -297,6 +297,27 @@ export const bookmarksInLists = sqliteTable( }), ); + +export const customPrompts = sqliteTable( + "customPrompts", + { + id: text("id") + .notNull() + .primaryKey() + .$defaultFn(() => createId()), + text: text("text").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull(), + appliesTo: text("attachedBy", { enum: ["all", "text", "images"] }).notNull(), + createdAt: createdAtField(), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + }, + (bl) => ({ + userIdIdx: index("customPrompts_userId_idx").on(bl.userId), + }), +); + export const config = sqliteTable("config", { key: text("key").notNull().primaryKey(), value: text("value").notNull(), diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 21cdb1c8..b87babbd 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -113,6 +113,9 @@ export const clientConfig = { auth: { disableSignups: serverConfig.auth.disableSignups, }, + inference: { + inferredTagLang: serverConfig.inference.inferredTagLang, + }, serverVersion: serverConfig.serverVersion, disableNewReleaseCheck: serverConfig.disableNewReleaseCheck, }; diff --git a/packages/shared/prompts.ts b/packages/shared/prompts.ts new file mode 100644 index 00000000..cf6d48b6 --- /dev/null +++ b/packages/shared/prompts.ts @@ -0,0 +1,33 @@ +export function buildImagePrompt(lang: string, customPrompts: string[]) { + return ` +You are a bot in a read-it-later app and your responsibility is to help with automatic tagging. +Please analyze the attached image and suggest relevant tags that describe its key themes, topics, and main ideas. The rules are: +- Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. +- The tags language must be in ${lang}. +- 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. +${customPrompts && customPrompts.map((p) => `- ${p}`).join("\n")} +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.`; +} + +export function buildTextPrompt( + lang: string, + customPrompts: string[], + content: string, +) { + return ` +You are a bot in a read-it-later app and your responsibility is to help with automatic tagging. +Please analyze the text between the sentences "CONTENT START HERE" and "CONTENT END HERE" and suggest relevant tags that describe its key themes, topics, and main ideas. The rules are: +- Aim for a variety of tags, including broad categories, specific keywords, and potential sub-genres. +- The tags language must be in ${lang}. +- 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. +- The content can include text for cookie consent and privacy policy, ignore those while tagging. +- Aim for 3-5 tags. +- If there are no good tags, leave the array empty. +${customPrompts && customPrompts.map((p) => `- ${p}`).join("\n")} +CONTENT START HERE +${content} +CONTENT END HERE +You must respond in JSON with the key "tags" and the value is an array of string tags.`; +} diff --git a/packages/shared/types/prompts.ts b/packages/shared/types/prompts.ts new file mode 100644 index 00000000..a288c65d --- /dev/null +++ b/packages/shared/types/prompts.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +const MAX_PROMPT_TEXT_LENGTH = 100; + +export const zAppliesToEnumSchema = z.enum(["all", "text", "images"]); + +export const zPromptSchema = z.object({ + id: z.string(), + text: z.string(), + enabled: z.boolean(), + appliesTo: zAppliesToEnumSchema, +}); + +export type ZPrompt = z.infer; + +export const zNewPromptSchema = z.object({ + text: z.string().min(1).max(MAX_PROMPT_TEXT_LENGTH), + appliesTo: zAppliesToEnumSchema, +}); + +export const zUpdatePromptSchema = z.object({ + promptId: z.string(), + text: z.string().max(MAX_PROMPT_TEXT_LENGTH).optional(), + appliesTo: zAppliesToEnumSchema.optional(), + enabled: z.boolean().optional(), +}); diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index 577b523e..01c92e6a 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -3,6 +3,7 @@ import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; import { listsAppRouter } from "./lists"; +import { promptsAppRouter } from "./prompts"; import { tagsAppRouter } from "./tags"; import { usersAppRouter } from "./users"; @@ -12,6 +13,7 @@ export const appRouter = router({ users: usersAppRouter, lists: listsAppRouter, tags: tagsAppRouter, + prompts: promptsAppRouter, admin: adminAppRouter, }); // export type definition of API diff --git a/packages/trpc/routers/prompts.ts b/packages/trpc/routers/prompts.ts new file mode 100644 index 00000000..629d5829 --- /dev/null +++ b/packages/trpc/routers/prompts.ts @@ -0,0 +1,114 @@ +import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { customPrompts } from "@hoarder/db/schema"; +import { + zNewPromptSchema, + zPromptSchema, + zUpdatePromptSchema, +} from "@hoarder/shared/types/prompts"; + +import { authedProcedure, Context, router } from "../index"; + +export const ensurePromptOwnership = experimental_trpcMiddleware<{ + ctx: Context; + input: { promptId: string }; +}>().create(async (opts) => { + const prompt = await opts.ctx.db.query.customPrompts.findFirst({ + where: eq(customPrompts.id, opts.input.promptId), + columns: { + userId: true, + }, + }); + if (!opts.ctx.user) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "User is not authorized", + }); + } + if (!prompt) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Prompt not found", + }); + } + if (prompt.userId != opts.ctx.user.id) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "User is not allowed to access resource", + }); + } + + return opts.next(); +}); + +export const promptsAppRouter = router({ + create: authedProcedure + .input(zNewPromptSchema) + .output(zPromptSchema) + .mutation(async ({ input, ctx }) => { + const [prompt] = await ctx.db + .insert(customPrompts) + .values({ + text: input.text, + appliesTo: input.appliesTo, + userId: ctx.user.id, + enabled: true, + }) + .returning(); + return prompt; + }), + update: authedProcedure + .input(zUpdatePromptSchema) + .output(zPromptSchema) + .use(ensurePromptOwnership) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db + .update(customPrompts) + .set({ + text: input.text, + appliesTo: input.appliesTo, + enabled: input.enabled, + }) + .where( + and( + eq(customPrompts.userId, ctx.user.id), + eq(customPrompts.id, input.promptId), + ), + ) + .returning(); + if (res.length == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + return res[0]; + }), + list: authedProcedure + .output(z.array(zPromptSchema)) + .query(async ({ ctx }) => { + const prompts = await ctx.db.query.customPrompts.findMany({ + where: eq(customPrompts.userId, ctx.user.id), + }); + return prompts; + }), + delete: authedProcedure + .input( + z.object({ + promptId: z.string(), + }), + ) + .use(ensurePromptOwnership) + .mutation(async ({ input, ctx }) => { + const res = await ctx.db + .delete(customPrompts) + .where( + and( + eq(customPrompts.userId, ctx.user.id), + eq(customPrompts.id, input.promptId), + ), + ); + if (res.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + }), +});