diff options
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/dashboard/settings/page.tsx | 11 | ||||
| -rw-r--r-- | apps/web/app/dashboard/settings/prompts/page.tsx | 325 | ||||
| -rw-r--r-- | apps/web/lib/clientConfig.tsx | 3 | ||||
| -rw-r--r-- | apps/workers/openaiWorker.ts | 81 |
4 files changed, 381 insertions, 39 deletions
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 ( @@ -14,6 +16,15 @@ export default async function Settings() { <ImportExport /> </div> <div className="mt-4 rounded-md border bg-background p-4"> + <Link + className="flex items-center gap-2 text-lg font-medium" + href="/dashboard/settings/prompts" + > + Inference Settings + <ExternalLink /> + </Link> + </div> + <div className="mt-4 rounded-md border bg-background p-4"> <ApiKeySettings /> </div> </> 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<z.infer<typeof zNewPromptSchema>>({ + 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 ( + <Form {...form}> + <form + className="flex gap-2" + onSubmit={form.handleSubmit(async (value) => { + await createPrompt(value); + form.resetField("text"); + })} + > + <FormField + control={form.control} + name="text" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input + placeholder="Add a custom prompt" + type="text" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="appliesTo" + render={({ field }) => { + return ( + <FormItem className="flex-0"> + <FormControl> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Applies To" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="all">All</SelectItem> + <SelectItem value="text">Text</SelectItem> + <SelectItem value="images">Images</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + type="submit" + loading={isCreating} + variant="default" + className="items-center" + > + <Plus className="mr-2 size-4" /> + Add + </ActionButton> + </form> + </Form> + ); +} + +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<z.infer<typeof zUpdatePromptSchema>>({ + resolver: zodResolver(zUpdatePromptSchema), + defaultValues: { + promptId: prompt.id, + text: prompt.text, + appliesTo: prompt.appliesTo, + }, + }); + + return ( + <Form {...form}> + <form + className="flex gap-2" + onSubmit={form.handleSubmit(async (value) => { + await updatePrompt(value); + })} + > + <FormField + control={form.control} + name="promptId" + render={({ field }) => { + return ( + <FormItem className="hidden"> + <FormControl> + <Input + placeholder="Add a custom prompt" + type="hidden" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <FormField + control={form.control} + name="text" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input + placeholder="Add a custom prompt" + type="text" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + + <FormField + control={form.control} + name="appliesTo" + render={({ field }) => { + return ( + <FormItem className="flex-0"> + <FormControl> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <SelectTrigger> + <SelectValue placeholder="Applies To" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="all">All</SelectItem> + <SelectItem value="text">Text</SelectItem> + <SelectItem value="images">Images</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> + <ActionButton + loading={isUpdating} + variant="secondary" + type="submit" + className="items-center" + > + <Save className="mr-2 size-4" /> + Save + </ActionButton> + <ActionButton + loading={isDeleting} + variant="destructive" + onClick={() => deletePrompt({ promptId: prompt.id })} + className="items-center" + type="button" + > + <Trash2 className="mr-2 size-4" /> + Delete + </ActionButton> + </form> + </Form> + ); +} + +export function CustomPrompts() { + const { data: prompts, isLoading } = api.prompts.list.useQuery(); + + return ( + <div className="mt-2 flex flex-col gap-2"> + <div className="w-full text-xl font-medium sm:w-1/3">Custom Prompts</div> + <p className="mb-1 text-xs italic text-muted-foreground"> + 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. + </p> + {isLoading && <FullPageSpinner />} + {prompts && prompts.length == 0 && ( + <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground"> + You don't have any custom prompts yet. + </p> + )} + {prompts && + prompts.map((prompt) => <PromptRow key={prompt.id} prompt={prompt} />)} + <PromptEditor /> + </div> + ); +} + +export function PromptDemo() { + const { data: prompts } = api.prompts.list.useQuery(); + const clientConfig = useClientConfig(); + return ( + <div className="flex flex-col gap-2"> + <div className="mb-4 w-full text-xl font-medium sm:w-1/3"> + Prompt Preview + </div> + <p>Text Prompt</p> + <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildTextPrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "text" || p.appliesTo == "all") + .map((p) => p.text), + "\n<CONTENT_HERE>\n", + ).trim()} + </code> + <p>Image Prompt</p> + <code className="whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground"> + {buildImagePrompt( + clientConfig.inference.inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "images" || p.appliesTo == "all") + .map((p) => p.text), + ).trim()} + </code> + </div> + ); +} + +export default function PromptsPage() { + return ( + <> + <div className="rounded-md border bg-background p-4"> + <div className="mb-2 flex flex-col gap-3"> + <div className="w-full text-2xl font-medium sm:w-1/3"> + Inference Settings + </div> + <CustomPrompts /> + </div> + </div> + <div className="mt-4 rounded-md border bg-background p-4"> + <PromptDemo /> + </div> + </> + ); +} 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<ClientConfig>({ 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<Awaited<ReturnType<typeof fetchBookmark>>>, ) { + 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<Awaited<ReturnType<typeof fetchBookmark>>>, @@ -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<Awaited<ReturnType<typeof fetchBookmark>>>, inferenceClient: InferenceClient, ) { - return await inferenceClient.inferFromText(buildPrompt(bookmark)); + return await inferenceClient.inferFromText(await buildPrompt(bookmark)); } async function inferTags( |
