From 5f4f7780d1bd5d3121dbd5975f9d7418e9cd5fe7 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Tue, 31 Mar 2026 18:34:10 +0200 Subject: [PATCH] Fix create_prompt is_public bug, conditional version bump, and add Jotai prompts atoms - Pass is_public from request body in create_prompt route - Only bump version on content field changes (name, prompt, mode), not on is_public toggle - Add prompts query and mutation atoms (atomWithQuery/atomWithMutation) with TanStack Query caching, replacing manual useEffect fetches - Update PromptPicker, PromptsContent, and CommunityPromptsContent to consume shared atoms instead of local state --- .../app/routes/prompts_routes.py | 10 ++- .../components/CommunityPromptsContent.tsx | 58 ++++++------- .../components/PromptsContent.tsx | 87 ++++++++++--------- .../atoms/prompts/prompts-mutation.atoms.ts | 71 +++++++++++++++ .../atoms/prompts/prompts-query.atoms.ts | 23 +++++ .../components/new-chat/prompt-picker.tsx | 19 ++-- surfsense_web/lib/query-client/cache-keys.ts | 4 + 7 files changed, 184 insertions(+), 88 deletions(-) create mode 100644 surfsense_web/atoms/prompts/prompts-mutation.atoms.ts create mode 100644 surfsense_web/atoms/prompts/prompts-query.atoms.ts diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py index 810ec2f18..0625e3611 100644 --- a/surfsense_backend/app/routes/prompts_routes.py +++ b/surfsense_backend/app/routes/prompts_routes.py @@ -41,6 +41,7 @@ async def create_prompt( name=body.name, prompt=body.prompt, mode=body.mode, + is_public=body.is_public, ) session.add(prompt) await session.commit() @@ -65,10 +66,15 @@ async def update_prompt( if not prompt: raise HTTPException(status_code=404, detail="Prompt not found") - for field, value in body.model_dump(exclude_unset=True).items(): + updates = body.model_dump(exclude_unset=True) + content_fields = {"name", "prompt", "mode"} + has_content_change = bool(updates.keys() & content_fields) + + for field, value in updates.items(): setattr(prompt, field, value) - prompt.version = (prompt.version or 0) + 1 + if has_content_change: + prompt.version = (prompt.version or 0) + 1 session.add(prompt) await session.commit() diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx index 363919b43..79384465d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx @@ -1,38 +1,34 @@ "use client"; +import { useAtomValue } from "jotai"; import { Copy, Globe, Sparkles } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; -import { toast } from "sonner"; +import { useCallback, useState } from "react"; +import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms"; +import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import type { PublicPromptRead } from "@/contracts/types/prompts.types"; -import { promptsApiService } from "@/lib/apis/prompts-api.service"; export function CommunityPromptsContent() { - const [prompts, setPrompts] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const { data: prompts, isLoading } = useAtomValue(publicPromptsAtom); + const { mutateAsync: copyPrompt, isPending: isCopying } = useAtomValue(copyPromptMutationAtom); const [copyingId, setCopyingId] = useState(null); const [expandedId, setExpandedId] = useState(null); - useEffect(() => { - promptsApiService - .listPublic() - .then(setPrompts) - .catch(() => toast.error("Failed to load community prompts")) - .finally(() => setIsLoading(false)); - }, []); + const handleCopy = useCallback( + async (id: number) => { + setCopyingId(id); + try { + await copyPrompt(id); + } catch { + // toast handled by mutation atom + } finally { + setCopyingId(null); + } + }, + [copyPrompt] + ); - const handleCopy = useCallback(async (id: number) => { - setCopyingId(id); - try { - await promptsApiService.copy(id); - toast.success("Prompt added to your collection"); - } catch { - toast.error("Failed to copy prompt"); - } finally { - setCopyingId(null); - } - }, []); + const list = prompts ?? []; if (isLoading) { return ( @@ -48,7 +44,7 @@ export function CommunityPromptsContent() { Prompts shared by other users. Add any to your collection with one click.

- {prompts.length === 0 && ( + {list.length === 0 && (

No community prompts yet

@@ -58,9 +54,9 @@ export function CommunityPromptsContent() {
)} - {prompts.length > 0 && ( + {list.length > 0 && (
- {prompts.map((prompt) => ( + {list.map((prompt) => (
)}
-

+

{prompt.prompt}

{prompt.prompt.length > 100 && ( @@ -97,10 +95,10 @@ export function CommunityPromptsContent() { variant="outline" size="sm" className="shrink-0 gap-1.5" - disabled={copyingId === prompt.id} + disabled={copyingId === prompt.id && isCopying} onClick={() => handleCopy(prompt.id)} > - {copyingId === prompt.id ? ( + {copyingId === prompt.id && isCopying ? ( ) : ( diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx index e8c45a31a..919af3e79 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -1,15 +1,21 @@ "use client"; +import { useAtomValue } from "jotai"; import { Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; import { toast } from "sonner"; +import { + createPromptMutationAtom, + deletePromptMutationAtom, + updatePromptMutationAtom, +} from "@/atoms/prompts/prompts-mutation.atoms"; +import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import type { PromptRead } from "@/contracts/types/prompts.types"; -import { promptsApiService } from "@/lib/apis/prompts-api.service"; interface PromptFormData { name: string; @@ -21,22 +27,17 @@ interface PromptFormData { const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false }; export function PromptsContent() { - const [prompts, setPrompts] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const { data: prompts, isLoading } = useAtomValue(promptsAtom); + const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom); + const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom); + const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom); + const [showForm, setShowForm] = useState(false); const [editingId, setEditingId] = useState(null); const [formData, setFormData] = useState(EMPTY_FORM); const [isSaving, setIsSaving] = useState(false); const [expandedId, setExpandedId] = useState(null); - useEffect(() => { - promptsApiService - .list() - .then(setPrompts) - .catch(() => toast.error("Failed to load prompts")) - .finally(() => setIsLoading(false)); - }, []); - const handleSave = useCallback(async () => { if (!formData.name.trim() || !formData.prompt.trim()) { toast.error("Name and prompt are required"); @@ -46,23 +47,19 @@ export function PromptsContent() { setIsSaving(true); try { if (editingId) { - const updated = await promptsApiService.update(editingId, formData); - setPrompts((prev) => prev.map((p) => (p.id === editingId ? updated : p))); - toast.success("Prompt updated"); + await updatePrompt({ id: editingId, ...formData }); } else { - const created = await promptsApiService.create(formData); - setPrompts((prev) => [created, ...prev]); - toast.success("Prompt created"); + await createPrompt(formData); } setShowForm(false); setFormData(EMPTY_FORM); setEditingId(null); } catch { - toast.error("Failed to save prompt"); + // toast handled by mutation atoms } finally { setIsSaving(false); } - }, [formData, editingId]); + }, [formData, editingId, createPrompt, updatePrompt]); const handleEdit = useCallback((prompt: PromptRead) => { setFormData({ @@ -75,15 +72,27 @@ export function PromptsContent() { setShowForm(true); }, []); - const handleDelete = useCallback(async (id: number) => { - try { - await promptsApiService.delete(id); - setPrompts((prev) => prev.filter((p) => p.id !== id)); - toast.success("Prompt deleted"); - } catch { - toast.error("Failed to delete prompt"); - } - }, []); + const handleDelete = useCallback( + async (id: number) => { + try { + await deletePrompt(id); + } catch { + // toast handled by mutation atom + } + }, + [deletePrompt] + ); + + const handleTogglePublic = useCallback( + async (prompt: PromptRead) => { + try { + await updatePrompt({ id: prompt.id, is_public: !prompt.is_public }); + } catch { + // toast handled by mutation atom + } + }, + [updatePrompt] + ); const handleCancel = useCallback(() => { setShowForm(false); @@ -91,6 +100,8 @@ export function PromptsContent() { setEditingId(null); }, []); + const list = prompts ?? []; + if (isLoading) { return (
@@ -195,7 +206,7 @@ export function PromptsContent() {
)} - {prompts.length === 0 && !showForm && ( + {list.length === 0 && !showForm && (

No prompts yet

@@ -205,9 +216,9 @@ export function PromptsContent() {
)} - {prompts.length > 0 && ( + {list.length > 0 && (
- {prompts.map((prompt) => ( + {list.map((prompt) => (
{ - try { - const updated = await promptsApiService.update(prompt.id, { - is_public: !prompt.is_public, - }); - setPrompts((prev) => prev.map((p) => (p.id === prompt.id ? updated : p))); - toast.success(updated.is_public ? "Shared with community" : "Made private"); - } catch { - toast.error("Failed to update"); - } - }} + onClick={() => handleTogglePublic(prompt)} className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" > {prompt.is_public ? ( diff --git a/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts new file mode 100644 index 000000000..b1f102329 --- /dev/null +++ b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts @@ -0,0 +1,71 @@ +import { atomWithMutation } from "jotai-tanstack-query"; +import { toast } from "sonner"; +import type { + PromptCreateRequest, + PromptRead, + PromptUpdateRequest, +} from "@/contracts/types/prompts.types"; +import { promptsApiService } from "@/lib/apis/prompts-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; +import { queryClient } from "@/lib/query-client/client"; + +export const createPromptMutationAtom = atomWithMutation(() => ({ + mutationKey: ["prompts", "create"], + mutationFn: async (request: PromptCreateRequest) => { + return promptsApiService.create(request); + }, + onSuccess: () => { + toast.success("Prompt created"); + queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to create prompt"); + }, +})); + +export const updatePromptMutationAtom = atomWithMutation(() => ({ + mutationKey: ["prompts", "update"], + mutationFn: async ({ id, ...data }: PromptUpdateRequest & { id: number }) => { + return promptsApiService.update(id, data); + }, + onSuccess: () => { + toast.success("Prompt updated"); + queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() }); + queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to update prompt"); + }, +})); + +export const deletePromptMutationAtom = atomWithMutation(() => ({ + mutationKey: ["prompts", "delete"], + mutationFn: async (id: number) => { + return promptsApiService.delete(id); + }, + onSuccess: (_: unknown, id: number) => { + toast.success("Prompt deleted"); + queryClient.setQueryData(cacheKeys.prompts.all(), (old: PromptRead[] | undefined) => { + if (!old) return old; + return old.filter((p) => p.id !== id); + }); + queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to delete prompt"); + }, +})); + +export const copyPromptMutationAtom = atomWithMutation(() => ({ + mutationKey: ["prompts", "copy"], + mutationFn: async (promptId: number) => { + return promptsApiService.copy(promptId); + }, + onSuccess: () => { + toast.success("Prompt added to your collection"); + queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() }); + }, + onError: (error: Error) => { + toast.error(error.message || "Failed to copy prompt"); + }, +})); diff --git a/surfsense_web/atoms/prompts/prompts-query.atoms.ts b/surfsense_web/atoms/prompts/prompts-query.atoms.ts new file mode 100644 index 000000000..f9042c03a --- /dev/null +++ b/surfsense_web/atoms/prompts/prompts-query.atoms.ts @@ -0,0 +1,23 @@ +import { atomWithQuery } from "jotai-tanstack-query"; +import { promptsApiService } from "@/lib/apis/prompts-api.service"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +export const promptsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.prompts.all(), + staleTime: 5 * 60 * 1000, + queryFn: async () => { + return promptsApiService.list(); + }, + }; +}); + +export const publicPromptsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.prompts.public(), + staleTime: 2 * 60 * 1000, + queryFn: async () => { + return promptsApiService.listPublic(); + }, + }; +}); diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index a24ec5a08..ec84363dd 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -1,6 +1,6 @@ "use client"; -import { useSetAtom } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { Plus, Zap } from "lucide-react"; import { forwardRef, @@ -12,9 +12,8 @@ import { useState, } from "react"; +import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; -import type { PromptRead } from "@/contracts/types/prompts.types"; -import { promptsApiService } from "@/lib/apis/prompts-api.service"; import { cn } from "@/lib/utils"; export interface PromptPickerRef { @@ -35,22 +34,16 @@ export const PromptPicker = forwardRef(funct ref ) { const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); + const { data: prompts } = useAtomValue(promptsAtom); const [highlightedIndex, setHighlightedIndex] = useState(0); - const [prompts, setPrompts] = useState([]); const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(false); const itemRefs = useRef>(new Map()); - useEffect(() => { - promptsApiService - .list() - .then(setPrompts) - .catch(() => {}); - }, []); - const filtered = useMemo(() => { - if (!externalSearch) return prompts; - return prompts.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); + const list = prompts ?? []; + if (!externalSearch) return list; + return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); }, [prompts, externalSearch]); const prevSearchRef = useRef(externalSearch); diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 17f0e5d1a..754886618 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -91,6 +91,10 @@ export const cacheKeys = { bySearchSpace: (searchSpaceId: number) => ["public-chat-snapshots", "search-space", searchSpaceId] as const, }, + prompts: { + all: () => ["prompts"] as const, + public: () => ["prompts", "public"] as const, + }, notifications: { search: (searchSpaceId: number | null, search: string, tab: string) => ["notifications", "search", searchSpaceId, search, tab] as const,