diff --git a/surfsense_backend/alembic/versions/112_add_is_public_to_prompts.py b/surfsense_backend/alembic/versions/112_add_is_public_to_prompts.py deleted file mode 100644 index 23a71ad9f..000000000 --- a/surfsense_backend/alembic/versions/112_add_is_public_to_prompts.py +++ /dev/null @@ -1,24 +0,0 @@ -"""add is_public to prompts - -Revision ID: 112 -Revises: 111 -""" - -from collections.abc import Sequence - -from alembic import op - -revision: str = "112" -down_revision: str | None = "111" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.execute( - "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false" - ) - - -def downgrade() -> None: - op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public") diff --git a/surfsense_backend/alembic/versions/112_add_prompt_library_schema.py b/surfsense_backend/alembic/versions/112_add_prompt_library_schema.py new file mode 100644 index 000000000..48ab27d51 --- /dev/null +++ b/surfsense_backend/alembic/versions/112_add_prompt_library_schema.py @@ -0,0 +1,54 @@ +"""add prompt library schema: is_public, default_prompt_slug, version, drop icon + +Revision ID: 112 +Revises: 111 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "112" +down_revision: str | None = "111" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" + " is_public BOOLEAN NOT NULL DEFAULT false" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_prompts_is_public" + " ON prompts (is_public) WHERE is_public = true" + ) + op.execute( + "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" + " default_prompt_slug VARCHAR(100)" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug" + " ON prompts (default_prompt_slug)" + ) + op.execute( + "ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug" + " UNIQUE (user_id, default_prompt_slug)" + ) + op.execute( + "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" + " version INTEGER NOT NULL DEFAULT 1" + ) + op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon") + + +def downgrade() -> None: + op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)") + op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version") + op.execute( + "ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug" + ) + op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug") + op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug") + op.execute("DROP INDEX IF EXISTS ix_prompts_is_public") + op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public") diff --git a/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py b/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py deleted file mode 100644 index ed020518b..000000000 --- a/surfsense_backend/alembic/versions/113_add_system_prompt_slug_to_prompts.py +++ /dev/null @@ -1,120 +0,0 @@ -"""add default_prompt_slug, version, drop icon, seed defaults - -Revision ID: 113 -Revises: 112 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "113" -down_revision: str | None = "112" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - -DEFAULTS = [ - ( - "fix-grammar", - "Fix grammar", - "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", - "transform", - ), - ( - "make-shorter", - "Make shorter", - "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", - "transform", - ), - ( - "translate", - "Translate", - "Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}", - "transform", - ), - ( - "rewrite", - "Rewrite", - "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", - "transform", - ), - ( - "summarize", - "Summarize", - "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", - "transform", - ), - ( - "explain", - "Explain", - "Explain the following text in simple terms:\n\n{selection}", - "explore", - ), - ( - "ask-knowledge-base", - "Ask my knowledge base", - "Search my knowledge base for information related to:\n\n{selection}", - "explore", - ), - ( - "look-up-web", - "Look up on the web", - "Search the web for information about:\n\n{selection}", - "explore", - ), -] - - -def upgrade() -> None: - op.execute( - "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS default_prompt_slug VARCHAR(100)" - ) - op.execute( - "CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug" - " ON prompts (default_prompt_slug)" - ) - op.execute( - "ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug" - " UNIQUE (user_id, default_prompt_slug)" - ) - op.execute( - "ALTER TABLE prompts ADD COLUMN IF NOT EXISTS" - " version INTEGER NOT NULL DEFAULT 1" - ) - op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon") - - conn = op.get_bind() - users = conn.execute(sa.text('SELECT id FROM "user"')).fetchall() - - for user_row in users: - user_id = user_row[0] - for slug, name, prompt, mode in DEFAULTS: - conn.execute( - sa.text( - "INSERT INTO prompts" - " (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)" - " VALUES (:user_id, :slug, :name, :prompt, :mode::prompt_mode, :version, false, now())" - " ON CONFLICT (user_id, default_prompt_slug) DO NOTHING" - ), - { - "user_id": user_id, - "slug": slug, - "name": name, - "prompt": prompt, - "mode": mode, - "version": 1, - }, - ) - - -def downgrade() -> None: - op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL") - op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)") - op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version") - op.execute( - "ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug" - ) - op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug") - op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug") diff --git a/surfsense_backend/alembic/versions/113_seed_default_prompts.py b/surfsense_backend/alembic/versions/113_seed_default_prompts.py new file mode 100644 index 000000000..b0fff7a53 --- /dev/null +++ b/surfsense_backend/alembic/versions/113_seed_default_prompts.py @@ -0,0 +1,45 @@ +"""seed default prompts for all existing users + +Revision ID: 113 +Revises: 112 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "113" +down_revision: str | None = "112" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + conn.execute( + sa.text( + """ + INSERT INTO prompts + (user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at) + SELECT u.id, d.slug, d.name, d.prompt, d.mode::prompt_mode, 1, false, now() + FROM "user" u + CROSS JOIN (VALUES + ('fix-grammar', 'Fix grammar', 'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}', 'transform'), + ('make-shorter', 'Make shorter', 'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}', 'transform'), + ('translate', 'Translate', 'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}', 'transform'), + ('rewrite', 'Rewrite', 'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}', 'transform'), + ('summarize', 'Summarize', 'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}', 'transform'), + ('explain', 'Explain', 'Explain the following text in simple terms:\n\n{selection}', 'explore'), + ('ask-knowledge-base','Ask my knowledge base', 'Search my knowledge base for information related to:\n\n{selection}', 'explore'), + ('look-up-web', 'Look up on the web', 'Search the web for information about:\n\n{selection}', 'explore') + ) AS d(slug, name, prompt, mode) + ON CONFLICT (user_id, default_prompt_slug) DO NOTHING + """ + ) + ) + + +def downgrade() -> None: + op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL") diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py index 0625e3611..8dd47537e 100644 --- a/surfsense_backend/app/routes/prompts_routes.py +++ b/surfsense_backend/app/routes/prompts_routes.py @@ -3,7 +3,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.db import Prompt, User, get_async_session +from app.db import Prompt, SearchSpaceMembership, User, get_async_session from app.schemas.prompts import ( PromptCreate, PromptRead, @@ -35,6 +35,19 @@ async def create_prompt( session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): + if body.search_space_id is not None: + membership = await session.execute( + select(SearchSpaceMembership).where( + SearchSpaceMembership.user_id == user.id, + SearchSpaceMembership.search_space_id == body.search_space_id, + ) + ) + if not membership.scalar_one_or_none(): + raise HTTPException( + status_code=403, + detail="You are not a member of this search space", + ) + prompt = Prompt( user_id=user.id, search_space_id=body.search_space_id, @@ -74,7 +87,7 @@ async def update_prompt( setattr(prompt, field, value) if has_content_change: - prompt.version = (prompt.version or 0) + 1 + prompt.version = Prompt.version + 1 session.add(prompt) await session.commit() @@ -111,7 +124,7 @@ async def list_public_prompts( result = await session.execute( select(Prompt) .options(selectinload(Prompt.user)) - .where(Prompt.is_public.is_(True)) + .where(Prompt.is_public.is_(True), Prompt.user_id != user.id) .order_by(Prompt.created_at.desc()) ) prompts = result.scalars().all() diff --git a/surfsense_web/app/(home)/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx index b1380486f..8b3be3805 100644 --- a/surfsense_web/app/(home)/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -1,14 +1,13 @@ "use client"; import { AnimatePresence, motion } from "motion/react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; import { Logo } from "@/components/Logo"; import { useGlobalLoadingEffect } from "@/hooks/use-global-loading"; import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors"; -import { getBearerToken } from "@/lib/auth-utils"; import { AUTH_TYPE } from "@/lib/env-config"; import { AmbientBackground } from "./AmbientBackground"; import { GoogleLoginButton } from "./GoogleLoginButton"; @@ -17,19 +16,11 @@ import { LocalLoginForm } from "./LocalLoginForm"; function LoginContent() { const t = useTranslations("auth"); const tCommon = useTranslations("common"); - const router = useRouter(); const [authType, setAuthType] = useState(null); const [isLoading, setIsLoading] = useState(true); const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null); const searchParams = useSearchParams(); - useEffect(() => { - if (getBearerToken()) { - router.replace("/dashboard"); - return; - } - }, [router]); - useEffect(() => { // Check for various URL parameters that might indicate success or error states const registered = searchParams.get("registered"); 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 79384465d..c3159c8af 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,7 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; -import { Copy, Globe, Sparkles } from "lucide-react"; +import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react"; import { useCallback, useState } from "react"; import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms"; import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms"; @@ -9,20 +9,24 @@ import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; export function CommunityPromptsContent() { - const { data: prompts, isLoading } = useAtomValue(publicPromptsAtom); - const { mutateAsync: copyPrompt, isPending: isCopying } = useAtomValue(copyPromptMutationAtom); - const [copyingId, setCopyingId] = useState(null); + const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom); + const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom); + const [copyingIds, setCopyingIds] = useState>(new Set()); const [expandedId, setExpandedId] = useState(null); const handleCopy = useCallback( async (id: number) => { - setCopyingId(id); + setCopyingIds((prev) => new Set(prev).add(id)); try { await copyPrompt(id); } catch { // toast handled by mutation atom } finally { - setCopyingId(null); + setCopyingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); } }, [copyPrompt] @@ -38,6 +42,16 @@ export function CommunityPromptsContent() { ); } + if (isError) { + return ( +
+ +

Failed to load community prompts

+

Please try refreshing the page.

+
+ ); + } + return (

@@ -91,20 +105,20 @@ export function CommunityPromptsContent() { )}

- + ))} 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 919af3e79..ee760caa9 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,7 +1,7 @@ "use client"; import { useAtomValue } from "jotai"; -import { Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; +import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; import { useCallback, useState } from "react"; import { toast } from "sonner"; import { @@ -10,6 +10,16 @@ import { updatePromptMutationAtom, } from "@/atoms/prompts/prompts-mutation.atoms"; import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -27,7 +37,7 @@ interface PromptFormData { const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false }; export function PromptsContent() { - const { data: prompts, isLoading } = useAtomValue(promptsAtom); + const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom); const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom); const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom); const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom); @@ -37,6 +47,7 @@ export function PromptsContent() { const [formData, setFormData] = useState(EMPTY_FORM); const [isSaving, setIsSaving] = useState(false); const [expandedId, setExpandedId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); const handleSave = useCallback(async () => { if (!formData.name.trim() || !formData.prompt.trim()) { @@ -46,7 +57,7 @@ export function PromptsContent() { setIsSaving(true); try { - if (editingId) { + if (editingId !== null) { await updatePrompt({ id: editingId, ...formData }); } else { await createPrompt(formData); @@ -72,16 +83,16 @@ export function PromptsContent() { setShowForm(true); }, []); - const handleDelete = useCallback( - async (id: number) => { - try { - await deletePrompt(id); - } catch { - // toast handled by mutation atom - } - }, - [deletePrompt] - ); + const handleConfirmDelete = useCallback(async () => { + if (deleteTarget === null) return; + try { + await deletePrompt(deleteTarget); + } catch { + // toast handled by mutation atom + } finally { + setDeleteTarget(null); + } + }, [deleteTarget, deletePrompt]); const handleTogglePublic = useCallback( async (prompt: PromptRead) => { @@ -110,6 +121,16 @@ export function PromptsContent() { ); } + if (isError) { + return ( +
+ +

Failed to load prompts

+

Please try refreshing the page.

+
+ ); + } + return (
@@ -137,7 +158,7 @@ export function PromptsContent() { {showForm && (

- {editingId ? "Edit prompt" : "New prompt"} + {editingId !== null ? "Edit prompt" : "New prompt"}

@@ -200,7 +221,7 @@ export function PromptsContent() { Cancel
@@ -279,7 +300,7 @@ export function PromptsContent() { variant="ghost" size="icon" className="size-7 text-destructive hover:text-destructive" - onClick={() => handleDelete(prompt.id)} + onClick={() => setDeleteTarget(prompt.id)} > @@ -288,6 +309,21 @@ export function PromptsContent() { ))}
)} + + !open && setDeleteTarget(null)}> + + + Delete prompt + + This action cannot be undone. The prompt will be permanently removed. + + + + Cancel + Delete + + +
); } diff --git a/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts index b1f102329..6996185fe 100644 --- a/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts +++ b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts @@ -31,7 +31,6 @@ export const updatePromptMutationAtom = atomWithMutation(() => ({ 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"); diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index ec84363dd..da0cb700b 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -98,35 +98,37 @@ export const PromptPicker = forwardRef(funct [filtered.length, highlightedIndex, handleSelect] ); - if (filtered.length === 0) return null; - return (
- {filtered.map((action, index) => ( - - ))} + {filtered.length === 0 ? ( +

No matching prompts

+ ) : ( + filtered.map((action, index) => ( + + )) + )}