"use client";
import React from "react";
import { TagsEditor } from "@/components/dashboard/bookmarks/TagsEditor";
import { ActionButton } from "@/components/ui/action-button";
import { Badge } from "@/components/ui/badge";
import {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldTitle,
} from "@/components/ui/field";
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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "@/components/ui/sonner";
import { Switch } from "@/components/ui/switch";
import { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
import { useUserSettings } from "@/lib/userSettings";
import { cn } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Info, Plus, Save, Trash2 } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import type { ZBookmarkTags } from "@karakeep/shared/types/tags";
import { useDebounce } from "@karakeep/shared-react/hooks/use-debounce";
import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users";
import { useTRPC } from "@karakeep/shared-react/trpc";
import {
buildImagePrompt,
buildSummaryPromptUntruncated,
buildTextPromptUntruncated,
} from "@karakeep/shared/prompts";
import {
zNewPromptSchema,
ZPrompt,
zUpdatePromptSchema,
} from "@karakeep/shared/types/prompts";
import { zUpdateUserSettingsSchema } from "@karakeep/shared/types/users";
import { SettingsPage, SettingsSection } from "./SettingsPage";
export function AIPreferences() {
const { t } = useTranslation();
const clientConfig = useClientConfig();
const settings = useUserSettings();
const { mutate: updateSettings, isPending } = useUpdateUserSettings({
onSuccess: () => {
toast({
description: "Settings updated successfully!",
});
},
onError: () => {
toast({
description: "Failed to update settings",
variant: "destructive",
});
},
});
const form = useForm<z.infer<typeof zUpdateUserSettingsSchema>>({
resolver: zodResolver(zUpdateUserSettingsSchema),
values: settings
? {
inferredTagLang: settings.inferredTagLang ?? "",
autoTaggingEnabled: settings.autoTaggingEnabled,
autoSummarizationEnabled: settings.autoSummarizationEnabled,
}
: undefined,
});
const showAutoTagging = clientConfig.inference.enableAutoTagging;
const showAutoSummarization = clientConfig.inference.enableAutoSummarization;
const onSubmit = (data: z.infer<typeof zUpdateUserSettingsSchema>) => {
updateSettings(data);
};
return (
<SettingsSection title="AI preferences">
<form onSubmit={form.handleSubmit(onSubmit)}>
<FieldGroup className="gap-3">
<Controller
name="inferredTagLang"
control={form.control}
render={({ field, fieldState }) => (
<Field
className="rounded-lg border p-3"
data-invalid={fieldState.invalid}
>
<FieldContent>
<FieldLabel htmlFor="inferredTagLang">
{t("settings.ai.inference_language")}
</FieldLabel>
<FieldDescription>
{t("settings.ai.inference_language_description")}
</FieldDescription>
</FieldContent>
<Input
{...field}
id="inferredTagLang"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value.length > 0 ? e.target.value : null,
)
}
aria-invalid={fieldState.invalid}
placeholder={`Default (${clientConfig.inference.inferredTagLang})`}
type="text"
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
{showAutoTagging && (
<Controller
name="autoTaggingEnabled"
control={form.control}
render={({ field, fieldState }) => (
<Field
orientation="horizontal"
className="rounded-lg border p-3"
data-invalid={fieldState.invalid}
>
<FieldContent>
<FieldLabel htmlFor="autoTaggingEnabled">
{t("settings.ai.auto_tagging")}
</FieldLabel>
<FieldDescription>
{t("settings.ai.auto_tagging_description")}
</FieldDescription>
</FieldContent>
<Switch
id="autoTaggingEnabled"
name={field.name}
checked={field.value ?? true}
onCheckedChange={field.onChange}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
)}
{showAutoSummarization && (
<Controller
name="autoSummarizationEnabled"
control={form.control}
render={({ field, fieldState }) => (
<Field
orientation="horizontal"
className="rounded-lg border p-3"
data-invalid={fieldState.invalid}
>
<FieldContent>
<FieldLabel htmlFor="autoSummarizationEnabled">
{t("settings.ai.auto_summarization")}
</FieldLabel>
<FieldDescription>
{t("settings.ai.auto_summarization_description")}
</FieldDescription>
</FieldContent>
<Switch
id="autoSummarizationEnabled"
name={field.name}
checked={field.value ?? true}
onCheckedChange={field.onChange}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
)}
<div className="flex justify-end pt-4">
<ActionButton type="submit" loading={isPending} variant="default">
<Save className="mr-2 size-4" />
{t("actions.save")}
</ActionButton>
</div>
</FieldGroup>
</form>
</SettingsSection>
);
}
export function TagStyleSelector() {
const { t } = useTranslation();
const settings = useUserSettings();
const { mutate: updateSettings, isPending: isUpdating } =
useUpdateUserSettings({
onSuccess: () => {
toast({
description: "Tag style updated successfully!",
});
},
onError: () => {
toast({
description: "Failed to update tag style",
variant: "destructive",
});
},
});
const tagStyleOptions = [
{
value: "lowercase-hyphens",
label: t("settings.ai.lowercase_hyphens"),
examples: ["machine-learning", "web-development"],
},
{
value: "lowercase-spaces",
label: t("settings.ai.lowercase_spaces"),
examples: ["machine learning", "web development"],
},
{
value: "lowercase-underscores",
label: t("settings.ai.lowercase_underscores"),
examples: ["machine_learning", "web_development"],
},
{
value: "titlecase-spaces",
label: t("settings.ai.titlecase_spaces"),
examples: ["Machine Learning", "Web Development"],
},
{
value: "titlecase-hyphens",
label: t("settings.ai.titlecase_hyphens"),
examples: ["Machine-Learning", "Web-Development"],
},
{
value: "camelCase",
label: t("settings.ai.camelCase"),
examples: ["machineLearning", "webDevelopment"],
},
{
value: "as-generated",
label: t("settings.ai.no_preference"),
examples: ["Machine Learning", "web development", "AI_generated"],
},
] as const;
const selectedStyle = settings?.tagStyle ?? "as-generated";
return (
<SettingsSection
title={t("settings.ai.tag_style")}
description={t("settings.ai.tag_style_description")}
>
<RadioGroup
value={selectedStyle}
onValueChange={(value) => {
updateSettings({ tagStyle: value as typeof selectedStyle });
}}
disabled={isUpdating}
className="grid gap-3 sm:grid-cols-2"
>
{tagStyleOptions.map((option) => (
<FieldLabel
key={option.value}
htmlFor={option.value}
className={cn(selectedStyle === option.value && "ring-1")}
>
<Field orientation="horizontal">
<FieldContent>
<FieldTitle>{option.label}</FieldTitle>
<div className="flex flex-wrap gap-1">
{option.examples.map((example) => (
<Badge
key={example}
variant="secondary"
className="text-xs font-light"
>
{example}
</Badge>
))}
</div>
</FieldContent>
<RadioGroupItem value={option.value} id={option.value} />
</Field>
</FieldLabel>
))}
</RadioGroup>
</SettingsSection>
);
}
export function CuratedTagsSelector() {
const api = useTRPC();
const { t } = useTranslation();
const settings = useUserSettings();
const { mutate: updateSettings, isPending: isUpdatingCuratedTags } =
useUpdateUserSettings({
onSuccess: () => {
toast({
description: t("settings.ai.curated_tags_updated"),
});
},
onError: () => {
toast({
description: t("settings.ai.curated_tags_update_failed"),
variant: "destructive",
});
},
});
const areTagIdsEqual = React.useCallback((a: string[], b: string[]) => {
return a.length === b.length && a.every((id, index) => id === b[index]);
}, []);
const curatedTagIds = React.useMemo(
() => settings?.curatedTagIds ?? [],
[settings?.curatedTagIds],
);
const [localCuratedTagIds, setLocalCuratedTagIds] =
React.useState<string[]>(curatedTagIds);
const debouncedCuratedTagIds = useDebounce(localCuratedTagIds, 300);
const lastServerCuratedTagIdsRef = React.useRef(curatedTagIds);
const lastSubmittedCuratedTagIdsRef = React.useRef<string[] | null>(null);
React.useEffect(() => {
const hadUnsyncedLocalChanges = !areTagIdsEqual(
localCuratedTagIds,
lastServerCuratedTagIdsRef.current,
);
if (
!hadUnsyncedLocalChanges &&
!areTagIdsEqual(localCuratedTagIds, curatedTagIds)
) {
setLocalCuratedTagIds(curatedTagIds);
}
lastServerCuratedTagIdsRef.current = curatedTagIds;
}, [areTagIdsEqual, curatedTagIds, localCuratedTagIds]);
React.useEffect(() => {
if (isUpdatingCuratedTags) {
return;
}
if (areTagIdsEqual(debouncedCuratedTagIds, curatedTagIds)) {
lastSubmittedCuratedTagIdsRef.current = null;
return;
}
if (
lastSubmittedCuratedTagIdsRef.current &&
areTagIdsEqual(
lastSubmittedCuratedTagIdsRef.current,
debouncedCuratedTagIds,
)
) {
return;
}
lastSubmittedCuratedTagIdsRef.current = debouncedCuratedTagIds;
updateSettings({
curatedTagIds:
debouncedCuratedTagIds.length > 0 ? debouncedCuratedTagIds : null,
});
}, [
areTagIdsEqual,
curatedTagIds,
debouncedCuratedTagIds,
isUpdatingCuratedTags,
updateSettings,
]);
// Fetch selected tags to display their names
const { data: selectedTagsData } = useQuery(
api.tags.list.queryOptions(
{ ids: localCuratedTagIds },
{ enabled: localCuratedTagIds.length > 0 },
),
);
const selectedTags: ZBookmarkTags[] = React.useMemo(() => {
const tagsMap = new Map(
(selectedTagsData?.tags ?? []).map((tag) => [tag.id, tag]),
);
// Preserve the order from curatedTagIds instead of server sort order
return localCuratedTagIds
.map((id) => tagsMap.get(id))
.filter((tag): tag is NonNullable<typeof tag> => tag != null)
.map((tag) => ({
id: tag.id,
name: tag.name,
attachedBy: "human" as const,
}));
}, [selectedTagsData?.tags, localCuratedTagIds]);
return (
<SettingsSection
title={t("settings.ai.curated_tags")}
description={t("settings.ai.curated_tags_description")}
>
<TagsEditor
tags={selectedTags}
placeholder="Select curated tags..."
onAttach={(tag) => {
const tagId = tag.tagId;
if (tagId) {
setLocalCuratedTagIds((prev) => {
if (prev.includes(tagId)) {
return prev;
}
return [...prev, tagId];
});
}
}}
onDetach={(tag) => {
setLocalCuratedTagIds((prev) => {
return prev.filter((id) => id !== tag.tagId);
});
}}
allowCreation={false}
/>
</SettingsSection>
);
}
export function PromptEditor() {
const api = useTRPC();
const { t } = useTranslation();
const queryClient = useQueryClient();
const form = useForm<z.infer<typeof zNewPromptSchema>>({
resolver: zodResolver(zNewPromptSchema),
defaultValues: {
text: "",
appliesTo: "all_tagging",
},
});
const { mutateAsync: createPrompt, isPending: isCreating } = useMutation(
api.prompts.create.mutationOptions({
onSuccess: () => {
toast({
description: "Prompt has been created!",
});
queryClient.invalidateQueries(api.prompts.list.pathFilter());
},
}),
);
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_tagging">
{t("settings.ai.all_tagging")}
</SelectItem>
<SelectItem value="text">
{t("settings.ai.text_tagging")}
</SelectItem>
<SelectItem value="images">
{t("settings.ai.image_tagging")}
</SelectItem>
<SelectItem value="summary">
{t("settings.ai.summarization")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<ActionButton
type="submit"
loading={isCreating}
variant="default"
className="items-center"
>
<Plus className="mr-2 size-4" />
{t("actions.add")}
</ActionButton>
</form>
</Form>
);
}
export function PromptRow({ prompt }: { prompt: ZPrompt }) {
const api = useTRPC();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { mutateAsync: updatePrompt, isPending: isUpdating } = useMutation(
api.prompts.update.mutationOptions({
onSuccess: () => {
toast({
description: "Prompt has been updated!",
});
queryClient.invalidateQueries(api.prompts.list.pathFilter());
},
}),
);
const { mutate: deletePrompt, isPending: isDeleting } = useMutation(
api.prompts.delete.mutationOptions({
onSuccess: () => {
toast({
description: "Prompt has been deleted!",
});
queryClient.invalidateQueries(api.prompts.list.pathFilter());
},
}),
);
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 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_tagging">
{t("settings.ai.all_tagging")}
</SelectItem>
<SelectItem value="text">
{t("settings.ai.text_tagging")}
</SelectItem>
<SelectItem value="images">
{t("settings.ai.image_tagging")}
</SelectItem>
<SelectItem value="summary">
{t("settings.ai.summarization")}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<ActionButton
loading={isUpdating}
variant="secondary"
type="submit"
className="items-center"
>
<Save className="mr-2 size-4" />
{t("actions.save")}
</ActionButton>
<ActionButton
loading={isDeleting}
variant="destructive"
onClick={() => deletePrompt({ promptId: prompt.id })}
className="items-center"
type="button"
>
<Trash2 className="mr-2 size-4" />
{t("actions.delete")}
</ActionButton>
</form>
</Form>
);
}
export function TaggingRules() {
const api = useTRPC();
const { t } = useTranslation();
const { data: prompts, isLoading } = useQuery(
api.prompts.list.queryOptions(),
);
return (
<SettingsSection
title={t("settings.ai.tagging_rules")}
description={t("settings.ai.tagging_rule_description")}
>
{prompts && prompts.length == 0 && (
<div className="flex items-start gap-2 rounded-md bg-muted p-4 text-sm text-muted-foreground">
<Info className="size-4 flex-shrink-0" />
<p>You don't have any custom prompts yet.</p>
</div>
)}
<div className="flex flex-col gap-2">
{isLoading && <FullPageSpinner />}
{prompts &&
prompts.map((prompt) => (
<PromptRow key={prompt.id} prompt={prompt} />
))}
<PromptEditor />
</div>
</SettingsSection>
);
}
export function PromptDemo() {
const api = useTRPC();
const { t } = useTranslation();
const { data: prompts } = useQuery(api.prompts.list.queryOptions());
const settings = useUserSettings();
const clientConfig = useClientConfig();
const tagStyle = settings?.tagStyle ?? "as-generated";
const curatedTagIds = settings?.curatedTagIds ?? [];
const { data: tagsData } = useQuery(
api.tags.list.queryOptions(
{ ids: curatedTagIds },
{ enabled: curatedTagIds.length > 0 },
),
);
const inferredTagLang =
settings?.inferredTagLang ?? clientConfig.inference.inferredTagLang;
// Resolve curated tag names for preview
const curatedTagNames =
curatedTagIds.length > 0 && tagsData?.tags
? curatedTagIds
.map((id) => tagsData.tags.find((tag) => tag.id === id)?.name)
.filter((name): name is string => Boolean(name))
: undefined;
return (
<SettingsSection
title={t("settings.ai.prompt_preview")}
description="Preview the actual prompts sent to AI based on your settings"
>
<div className="space-y-4">
<div>
<p className="mb-2 text-sm font-medium">
{t("settings.ai.text_prompt")}
</p>
<code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
{buildTextPromptUntruncated(
inferredTagLang,
(prompts ?? [])
.filter(
(p) => p.appliesTo == "text" || p.appliesTo == "all_tagging",
)
.map((p) => p.text),
"\n<CONTENT_HERE>\n",
tagStyle,
curatedTagNames,
).trim()}
</code>
</div>
<div>
<p className="mb-2 text-sm font-medium">
{t("settings.ai.images_prompt")}
</p>
<code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
{buildImagePrompt(
inferredTagLang,
(prompts ?? [])
.filter(
(p) =>
p.appliesTo == "images" || p.appliesTo == "all_tagging",
)
.map((p) => p.text),
tagStyle,
curatedTagNames,
).trim()}
</code>
</div>
<div>
<p className="mb-2 text-sm font-medium">
{t("settings.ai.summarization_prompt")}
</p>
<code className="block whitespace-pre-wrap rounded-md bg-muted p-3 text-sm text-muted-foreground">
{buildSummaryPromptUntruncated(
inferredTagLang,
(prompts ?? [])
.filter((p) => p.appliesTo == "summary")
.map((p) => p.text),
"\n<CONTENT_HERE>\n",
).trim()}
</code>
</div>
</div>
</SettingsSection>
);
}
export default function AISettings() {
const { t } = useTranslation();
return (
<SettingsPage title={t("settings.ai.ai_settings")}>
<AIPreferences />
<TagStyleSelector />
<CuratedTagsSelector />
<TaggingRules />
<PromptDemo />
</SettingsPage>
);
}