From af3010abaa37f7db4144820469422bdbb432adfc Mon Sep 17 00:00:00 2001 From: Mohamed Bassem Date: Sat, 27 Dec 2025 16:30:11 +0200 Subject: feat: add customizable tag styles (#2312) * feat: add customizable tag styles * add tag lang setting * ui settings cleanup * fix migration * change look of the field * more fixes * fix tests --- apps/web/components/settings/AISettings.tsx | 465 ++++++++++++++++++++-------- 1 file changed, 343 insertions(+), 122 deletions(-) (limited to 'apps/web/components/settings') diff --git a/apps/web/components/settings/AISettings.tsx b/apps/web/components/settings/AISettings.tsx index d8adcb76..48c45633 100644 --- a/apps/web/components/settings/AISettings.tsx +++ b/apps/web/components/settings/AISettings.tsx @@ -1,17 +1,33 @@ "use client"; import { ActionButton } from "@/components/ui/action-button"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldContent, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, + FieldTitle, +} from "@/components/ui/field"; import { Form, FormControl, - FormDescription, FormField, FormItem, - FormLabel, 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, @@ -26,9 +42,10 @@ import { useClientConfig } from "@/lib/clientConfig"; import { useTranslation } from "@/lib/i18n/client"; import { api } from "@/lib/trpc"; import { useUserSettings } from "@/lib/userSettings"; +import { cn } from "@/lib/utils"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Plus, Save, Trash2 } from "lucide-react"; -import { useForm } from "react-hook-form"; +import { Info, Plus, Save, Trash2 } from "lucide-react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users"; @@ -44,12 +61,33 @@ import { } from "@karakeep/shared/types/prompts"; import { zUpdateUserSettingsSchema } from "@karakeep/shared/types/users"; +function SettingsSection({ + title, + description, + children, +}: { + title?: string; + description?: string; + children: React.ReactNode; + className?: string; +}) { + return ( + + + {title && {title}} + {description && {description}} + + {children} + + ); +} + export function AIPreferences() { const { t } = useTranslation(); const clientConfig = useClientConfig(); const settings = useUserSettings(); - const { mutate: updateSettings } = useUpdateUserSettings({ + const { mutate: updateSettings, isPending } = useUpdateUserSettings({ onSuccess: () => { toast({ description: "Settings updated successfully!", @@ -67,6 +105,7 @@ export function AIPreferences() { resolver: zodResolver(zUpdateUserSettingsSchema), values: settings ? { + inferredTagLang: settings.inferredTagLang ?? "", autoTaggingEnabled: settings.autoTaggingEnabled, autoSummarizationEnabled: settings.autoSummarizationEnabled, } @@ -76,72 +115,227 @@ export function AIPreferences() { const showAutoTagging = clientConfig.inference.enableAutoTagging; const showAutoSummarization = clientConfig.inference.enableAutoSummarization; - // Don't show the section if neither feature is enabled on the server - if (!showAutoTagging && !showAutoSummarization) { - return null; - } + const onSubmit = (data: z.infer) => { + updateSettings(data); + }; return ( -
-

- {t("settings.ai.ai_preferences_description")} -

-
- + + + + ( + + + + {t("settings.ai.inference_language")} + + + {t("settings.ai.inference_language_description")} + + + + field.onChange( + e.target.value.length > 0 ? e.target.value : null, + ) + } + aria-invalid={fieldState.invalid} + placeholder={`Default (${clientConfig.inference.inferredTagLang})`} + type="text" + /> + {fieldState.invalid && ( + + )} + + )} + /> + {showAutoTagging && ( - ( - -
- {t("settings.ai.auto_tagging")} - + control={form.control} + render={({ field, fieldState }) => ( + + + + {t("settings.ai.auto_tagging")} + + {t("settings.ai.auto_tagging_description")} - -
- - { - field.onChange(checked); - updateSettings({ autoTaggingEnabled: checked }); - }} - /> - -
+ + + + {fieldState.invalid && ( + + )} + )} /> )} {showAutoSummarization && ( - ( - -
- {t("settings.ai.auto_summarization")} - + control={form.control} + render={({ field, fieldState }) => ( + + + + {t("settings.ai.auto_summarization")} + + {t("settings.ai.auto_summarization_description")} - -
- - { - field.onChange(checked); - updateSettings({ autoSummarizationEnabled: checked }); - }} - /> - -
+ + + + {fieldState.invalid && ( + + )} + )} /> )} - - -
+ +
+ + + {t("actions.save")} + +
+ + + + ); +} + +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 ( + + { + updateSettings({ tagStyle: value as typeof selectedStyle }); + }} + disabled={isUpdating} + className="grid gap-3 sm:grid-cols-2" + > + {tagStyleOptions.map((option) => ( + + + + {option.label} +
+ {option.examples.map((example) => ( + + {example} + + ))} +
+
+ +
+
+ ))} +
+
); } @@ -384,89 +578,116 @@ export function TaggingRules() { const { data: prompts, isLoading } = api.prompts.list.useQuery(); return ( -
-
- {t("settings.ai.tagging_rules")} -
-

- {t("settings.ai.tagging_rule_description")} -

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

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

+
+ +

You don't have any custom prompts yet.

+
)} - {prompts && - prompts.map((prompt) => )} - -
+
+ {isLoading && } + {prompts && + prompts.map((prompt) => ( + + ))} + +
+ ); } export function PromptDemo() { const { t } = useTranslation(); const { data: prompts } = api.prompts.list.useQuery(); + const settings = useUserSettings(); const clientConfig = useClientConfig(); + const tagStyle = settings?.tagStyle ?? "as-generated"; + const inferredTagLang = + settings?.inferredTagLang ?? clientConfig.inference.inferredTagLang; + return ( -
-
- {t("settings.ai.prompt_preview")} + +
+
+

+ {t("settings.ai.text_prompt")} +

+ + {buildTextPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => p.appliesTo == "text" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + "\n\n", + tagStyle, + ).trim()} + +
+
+

+ {t("settings.ai.images_prompt")} +

+ + {buildImagePrompt( + inferredTagLang, + (prompts ?? []) + .filter( + (p) => + p.appliesTo == "images" || p.appliesTo == "all_tagging", + ) + .map((p) => p.text), + tagStyle, + ).trim()} + +
+
+

+ {t("settings.ai.summarization_prompt")} +

+ + {buildSummaryPromptUntruncated( + inferredTagLang, + (prompts ?? []) + .filter((p) => p.appliesTo == "summary") + .map((p) => p.text), + "\n\n", + ).trim()} + +
-

{t("settings.ai.text_prompt")}

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

{t("settings.ai.images_prompt")}

- - {buildImagePrompt( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter( - (p) => p.appliesTo == "images" || p.appliesTo == "all_tagging", - ) - .map((p) => p.text), - ).trim()} - -

{t("settings.ai.summarization_prompt")}

- - {buildSummaryPromptUntruncated( - clientConfig.inference.inferredTagLang, - (prompts ?? []) - .filter((p) => p.appliesTo == "summary") - .map((p) => p.text), - "\n\n", - ).trim()} - -
+ ); } export default function AISettings() { const { t } = useTranslation(); return ( - <> -
-
-
- {t("settings.ai.ai_settings")} -
- - -
-
-
- -
- +
+

+ {t("settings.ai.ai_settings")} +

+ + {/* AI Preferences */} + + + {/* Tag Style */} + + + {/* Tagging Rules */} + + + {/* Prompt Preview */} + +
); } -- cgit v1.2.3-70-g09d2