aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/dashboard/settings/page.tsx11
-rw-r--r--apps/web/app/dashboard/settings/prompts/page.tsx325
-rw-r--r--apps/web/lib/clientConfig.tsx3
-rw-r--r--apps/workers/openaiWorker.ts81
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&apos;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(