diff --git a/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py b/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py new file mode 100644 index 000000000..191ffcbda --- /dev/null +++ b/surfsense_backend/alembic/versions/113_add_prompt_library_schema.py @@ -0,0 +1,62 @@ +"""add prompt library schema: is_public, default_prompt_slug, version, drop icon + +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: + 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)" + ) + conn = op.get_bind() + exists = conn.execute( + sa.text( + "SELECT 1 FROM pg_constraint WHERE conname = 'uq_prompt_user_default_slug'" + ) + ).scalar() + if not exists: + 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/114_seed_default_prompts.py b/surfsense_backend/alembic/versions/114_seed_default_prompts.py new file mode 100644 index 000000000..9cf0e4daf --- /dev/null +++ b/surfsense_backend/alembic/versions/114_seed_default_prompts.py @@ -0,0 +1,45 @@ +"""seed default prompts for all existing users + +Revision ID: 114 +Revises: 113 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "114" +down_revision: str | None = "113" +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/db.py b/surfsense_backend/app/db.py index 69b7cc36d..ffa12aac6 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1778,12 +1778,19 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): class PromptMode(StrEnum): - TRANSFORM = "transform" - EXPLORE = "explore" + transform = "transform" + explore = "explore" class Prompt(BaseModel, TimestampMixin): __tablename__ = "prompts" + __table_args__ = ( + UniqueConstraint( + "user_id", + "default_prompt_slug", + name="uq_prompt_user_default_slug", + ), + ) user_id = Column( UUID(as_uuid=True), @@ -1797,10 +1804,15 @@ class Prompt(BaseModel, TimestampMixin): nullable=True, index=True, ) + default_prompt_slug = Column(String(100), nullable=True, index=True) name = Column(String(200), nullable=False) prompt = Column(Text, nullable=False) - mode = Column(SQLAlchemyEnum(PromptMode), nullable=False) - icon = Column(String(50), nullable=True) + mode = Column( + SQLAlchemyEnum(PromptMode, name="prompt_mode", create_type=False), + nullable=False, + ) + version = Column(Integer, nullable=False, default=1) + is_public = Column(Boolean, nullable=False, default=False) user = relationship("User") search_space = relationship("SearchSpace") diff --git a/surfsense_backend/app/prompts/system_defaults.py b/surfsense_backend/app/prompts/system_defaults.py new file mode 100644 index 000000000..aaf9b64bd --- /dev/null +++ b/surfsense_backend/app/prompts/system_defaults.py @@ -0,0 +1,74 @@ +SYSTEM_PROMPT_DEFAULTS: list[dict] = [ + { + "slug": "fix-grammar", + "version": 1, + "name": "Fix grammar", + "prompt": ( + "Fix the grammar and spelling in the following text." + " Return only the corrected text, nothing else.\n\n{selection}" + ), + "mode": "transform", + }, + { + "slug": "make-shorter", + "version": 1, + "name": "Make shorter", + "prompt": ( + "Make the following text more concise while preserving its meaning." + " Return only the shortened text, nothing else.\n\n{selection}" + ), + "mode": "transform", + }, + { + "slug": "translate", + "version": 1, + "name": "Translate", + "prompt": ( + "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}" + ), + "mode": "transform", + }, + { + "slug": "rewrite", + "version": 1, + "name": "Rewrite", + "prompt": ( + "Rewrite the following text to improve clarity and readability." + " Return only the rewritten text, nothing else.\n\n{selection}" + ), + "mode": "transform", + }, + { + "slug": "summarize", + "version": 1, + "name": "Summarize", + "prompt": ( + "Summarize the following text concisely." + " Return only the summary, nothing else.\n\n{selection}" + ), + "mode": "transform", + }, + { + "slug": "explain", + "version": 1, + "name": "Explain", + "prompt": "Explain the following text in simple terms:\n\n{selection}", + "mode": "explore", + }, + { + "slug": "ask-knowledge-base", + "version": 1, + "name": "Ask my knowledge base", + "prompt": "Search my knowledge base for information related to:\n\n{selection}", + "mode": "explore", + }, + { + "slug": "look-up-web", + "version": 1, + "name": "Look up on the web", + "prompt": "Search the web for information about:\n\n{selection}", + "mode": "explore", + }, +] diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py index ebfe67130..8dd47537e 100644 --- a/surfsense_backend/app/routes/prompts_routes.py +++ b/surfsense_backend/app/routes/prompts_routes.py @@ -1,12 +1,14 @@ from fastapi import APIRouter, Depends, HTTPException 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, PromptUpdate, + PublicPromptRead, ) from app.users import current_active_user @@ -33,13 +35,26 @@ 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, name=body.name, prompt=body.prompt, mode=body.mode, - icon=body.icon, + is_public=body.is_public, ) session.add(prompt) await session.commit() @@ -64,9 +79,16 @@ 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) + if has_content_change: + prompt.version = Prompt.version + 1 + session.add(prompt) await session.commit() await session.refresh(prompt) @@ -92,3 +114,53 @@ async def delete_prompt( await session.delete(prompt) await session.commit() return {"success": True} + + +@router.get("/prompts/public", response_model=list[PublicPromptRead]) +async def list_public_prompts( + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(Prompt) + .options(selectinload(Prompt.user)) + .where(Prompt.is_public.is_(True), Prompt.user_id != user.id) + .order_by(Prompt.created_at.desc()) + ) + prompts = result.scalars().all() + return [ + PublicPromptRead( + **PromptRead.model_validate(p).model_dump(), + author_name=p.user.email if p.user else None, + ) + for p in prompts + ] + + +@router.post("/prompts/{prompt_id}/copy", response_model=PromptRead) +async def copy_public_prompt( + prompt_id: int, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(Prompt).where( + Prompt.id == prompt_id, + Prompt.is_public.is_(True), + ) + ) + source = result.scalar_one_or_none() + if not source: + raise HTTPException(status_code=404, detail="Prompt not found") + + copy = Prompt( + user_id=user.id, + name=source.name, + prompt=source.prompt, + mode=source.mode, + is_public=False, + ) + session.add(copy) + await session.commit() + await session.refresh(copy) + return copy diff --git a/surfsense_backend/app/schemas/prompts.py b/surfsense_backend/app/schemas/prompts.py index c2fd753e6..9f11520ff 100644 --- a/surfsense_backend/app/schemas/prompts.py +++ b/surfsense_backend/app/schemas/prompts.py @@ -7,15 +7,15 @@ class PromptCreate(BaseModel): name: str = Field(..., min_length=1, max_length=200) prompt: str = Field(..., min_length=1) mode: str = Field(..., pattern="^(transform|explore)$") - icon: str | None = Field(None, max_length=50) search_space_id: int | None = None + is_public: bool = False class PromptUpdate(BaseModel): name: str | None = Field(None, min_length=1, max_length=200) prompt: str | None = Field(None, min_length=1) mode: str | None = Field(None, pattern="^(transform|explore)$") - icon: str | None = Field(None, max_length=50) + is_public: bool | None = None class PromptRead(BaseModel): @@ -23,9 +23,14 @@ class PromptRead(BaseModel): name: str prompt: str mode: str - icon: str | None search_space_id: int | None + is_public: bool + version: int created_at: datetime class Config: from_attributes = True + + +class PublicPromptRead(PromptRead): + author_name: str | None = None diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index d24a6faf1..66e0cc8dd 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -17,6 +17,7 @@ from sqlalchemy import update from app.config import config from app.db import ( + Prompt, SearchSpace, SearchSpaceMembership, SearchSpaceRole, @@ -25,6 +26,7 @@ from app.db import ( get_default_roles_config, get_user_db, ) +from app.prompts.system_defaults import SYSTEM_PROMPT_DEFAULTS from app.utils.refresh_tokens import create_refresh_token logger = logging.getLogger(__name__) @@ -188,6 +190,18 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): ) session.add(owner_membership) + for default in SYSTEM_PROMPT_DEFAULTS: + session.add( + Prompt( + user_id=user.id, + default_prompt_slug=default["slug"], + name=default["name"], + prompt=default["prompt"], + mode=default["mode"], + version=default["version"], + ) + ) + await session.commit() logger.info( f"Created default search space (ID: {default_search_space.id}) for user {user.id}" diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index 2c1a70ac9..e6f1231a3 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -1,6 +1,9 @@ "use client"; import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { getBearerToken } from "@/lib/auth-utils"; import { HeroSection } from "@/components/homepage/hero-section"; const FeaturesCards = dynamic( @@ -26,6 +29,14 @@ const CTAHomepage = dynamic( ); export default function HomePage() { + const router = useRouter(); + + useEffect(() => { + if (getBearerToken()) { + router.replace("/dashboard"); + } + }, [router]); + return (
diff --git a/surfsense_web/app/(home)/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx index 96fab2c6a..b9200c68f 100644 --- a/surfsense_web/app/(home)/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -11,6 +11,7 @@ import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms"; import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; +import { getBearerToken } from "@/lib/auth-utils"; import { AUTH_TYPE } from "@/lib/env-config"; import { AppError, ValidationError } from "@/lib/error"; import { @@ -38,6 +39,10 @@ export default function RegisterPage() { // Check authentication type and redirect if not LOCAL useEffect(() => { + if (getBearerToken()) { + router.replace("/dashboard"); + return; + } if (AUTH_TYPE !== "LOCAL") { router.push("/login"); } 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 new file mode 100644 index 000000000..c3159c8af --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useAtomValue } from "jotai"; +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"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; + +export function CommunityPromptsContent() { + 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) => { + setCopyingIds((prev) => new Set(prev).add(id)); + try { + await copyPrompt(id); + } catch { + // toast handled by mutation atom + } finally { + setCopyingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }, + [copyPrompt] + ); + + const list = prompts ?? []; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ +

Failed to load community prompts

+

Please try refreshing the page.

+
+ ); + } + + return ( +
+

+ Prompts shared by other users. Add any to your collection with one click. +

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

No community prompts yet

+

+ Share your own prompts from the My Prompts tab +

+
+ )} + + {list.length > 0 && ( +
+ {list.map((prompt) => ( +
+
+ +
+
+
+ {prompt.name} + + {prompt.mode} + + {prompt.author_name && ( + + by {prompt.author_name} + + )} +
+

+ {prompt.prompt} +

+ {prompt.prompt.length > 100 && ( + + )} +
+ +
+ ))} +
+ )} +
+ ); +} 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 c2d2c01de..e4ba1426d 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,38 +1,54 @@ "use client"; -import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useAtomValue } from "jotai"; +import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-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 { + 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"; 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; prompt: string; mode: "transform" | "explore"; + is_public: boolean; } -const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform" }; +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, isError } = 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); - - useEffect(() => { - promptsApiService - .list() - .then(setPrompts) - .catch(() => toast.error("Failed to load prompts")) - .finally(() => setIsLoading(false)); - }, []); + const [expandedId, setExpandedId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [togglingPublicIds, setTogglingPublicIds] = useState>(new Set()); const handleSave = useCallback(async () => { if (!formData.name.trim() || !formData.prompt.trim()) { @@ -42,44 +58,61 @@ 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"); + if (editingId !== null) { + 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({ name: prompt.name, prompt: prompt.prompt, mode: prompt.mode as "transform" | "explore", + is_public: prompt.is_public, }); setEditingId(prompt.id); setShowForm(true); }, []); - const handleDelete = useCallback(async (id: number) => { + const handleConfirmDelete = useCallback(async () => { + if (deleteTarget === null) return; try { - await promptsApiService.delete(id); - setPrompts((prev) => prev.filter((p) => p.id !== id)); - toast.success("Prompt deleted"); + await deletePrompt(deleteTarget); } catch { - toast.error("Failed to delete prompt"); + // toast handled by mutation atom + } finally { + setDeleteTarget(null); } - }, []); + }, [deleteTarget, deletePrompt]); + + const handleTogglePublic = useCallback( + async (prompt: PromptRead) => { + if (togglingPublicIds.has(prompt.id)) return; + setTogglingPublicIds((prev) => new Set(prev).add(prompt.id)); + try { + await updatePrompt({ id: prompt.id, is_public: !prompt.is_public }); + } catch { + // toast handled by mutation atom + } finally { + setTogglingPublicIds((prev) => { + const next = new Set(prev); + next.delete(prompt.id); + return next; + }); + } + }, + [updatePrompt, togglingPublicIds] + ); const handleCancel = useCallback(() => { setShowForm(false); @@ -87,6 +120,8 @@ export function PromptsContent() { setEditingId(null); }, []); + const list = prompts ?? []; + if (isLoading) { return (
@@ -95,6 +130,16 @@ export function PromptsContent() { ); } + if (isError) { + return ( +
+ +

Failed to load prompts

+

Please try refreshing the page.

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

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

@@ -169,31 +214,42 @@ export function PromptsContent() {
+
+ setFormData((p) => ({ ...p, is_public: checked }))} + /> + +
+
)} - {prompts.length === 0 && !showForm && ( + {list.length === 0 && !showForm && (
-

No custom prompts yet

+

No prompts yet

Create prompts to quickly transform or explore text with /

)} - {prompts.length > 0 && ( + {list.length > 0 && (
- {prompts.map((prompt) => ( + {list.map((prompt) => (
{prompt.mode} + {prompt.is_public && ( + + + Public + + )}
-

{prompt.prompt}

+

+ {prompt.prompt} +

+ {prompt.prompt.length > 100 && ( + + )}
+ @@ -232,6 +322,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 new file mode 100644 index 000000000..6996185fe --- /dev/null +++ b/surfsense_web/atoms/prompts/prompts-mutation.atoms.ts @@ -0,0 +1,70 @@ +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() }); + }, + 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/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index d880524bd..476bda231 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -21,6 +21,7 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode"; import type { FolderDisplay } from "@/components/documents/FolderNode"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderTreeView } from "@/components/documents/FolderTreeView"; +import { MarkdownViewer } from "@/components/markdown-viewer"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { AlertDialog, diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index 7f0dab8a4..9fc435111 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -1,18 +1,7 @@ "use client"; -import { useSetAtom } from "jotai"; -import { - BookOpen, - Check, - Globe, - Languages, - List, - Minimize2, - PenLine, - Plus, - Search, - Zap, -} from "lucide-react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { Plus, Zap } from "lucide-react"; import { forwardRef, useCallback, @@ -23,9 +12,9 @@ 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 { Spinner } from "@/components/ui/spinner"; import { cn } from "@/lib/utils"; export interface PromptPickerRef { @@ -41,113 +30,23 @@ interface PromptPickerProps { containerStyle?: React.CSSProperties; } -const ICONS: Record = { - check: , - minimize: , - languages: , - "pen-line": , - "book-open": , - list: , - search: , - globe: , - zap: , -}; - -const DEFAULT_ACTIONS: { - name: string; - prompt: string; - mode: "transform" | "explore"; - icon: string; -}[] = [ - { - name: "Fix grammar", - prompt: - "Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}", - mode: "transform", - icon: "check", - }, - { - name: "Make shorter", - prompt: - "Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}", - mode: "transform", - icon: "minimize", - }, - { - name: "Translate", - prompt: - "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}", - mode: "transform", - icon: "languages", - }, - { - name: "Rewrite", - prompt: - "Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}", - mode: "transform", - icon: "pen-line", - }, - { - name: "Summarize", - prompt: - "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", - mode: "transform", - icon: "list", - }, - { - name: "Explain", - prompt: "Explain the following text in simple terms:\n\n{selection}", - mode: "explore", - icon: "book-open", - }, - { - name: "Ask my knowledge base", - prompt: "Search my knowledge base for information related to:\n\n{selection}", - mode: "explore", - icon: "search", - }, - { - name: "Look up on the web", - prompt: "Search the web for information about:\n\n{selection}", - mode: "explore", - icon: "globe", - }, -]; - export const PromptPicker = forwardRef(function PromptPicker( { onSelect, onDone, externalSearch = "", containerStyle }, ref ) { const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); + const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom); const [highlightedIndex, setHighlightedIndex] = useState(0); - const [customPrompts, setCustomPrompts] = useState([]); const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(false); const itemRefs = useRef>(new Map()); - useEffect(() => { - promptsApiService - .list() - .then(setCustomPrompts) - .catch(() => {}); - }, []); - - const allActions = useMemo(() => { - const customs = customPrompts.map((a) => ({ - name: a.name, - prompt: a.prompt, - mode: a.mode as "transform" | "explore", - icon: a.icon || "zap", - })); - return [...DEFAULT_ACTIONS, ...customs]; - }, [customPrompts]); - const filtered = useMemo(() => { - if (!externalSearch) return allActions; - return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); - }, [allActions, externalSearch]); + const list = prompts ?? []; + if (!externalSearch) return list; + return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); + }, [prompts, externalSearch]); - // Reset highlight when results change const prevSearchRef = useRef(externalSearch); if (prevSearchRef.current !== externalSearch) { prevSearchRef.current = externalSearch; @@ -165,7 +64,6 @@ export const PromptPicker = forwardRef(funct [filtered, onSelect] ); - // Auto-scroll highlighted item into view useEffect(() => { if (!shouldScrollRef.current) return; shouldScrollRef.current = false; @@ -190,10 +88,12 @@ export const PromptPicker = forwardRef(funct () => ({ selectHighlighted: () => handleSelect(highlightedIndex), moveUp: () => { + if (filtered.length === 0) return; shouldScrollRef.current = true; setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); }, moveDown: () => { + if (filtered.length === 0) return; shouldScrollRef.current = true; setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); }, @@ -201,46 +101,24 @@ export const PromptPicker = forwardRef(funct [filtered.length, highlightedIndex, handleSelect] ); - if (filtered.length === 0) return null; - - const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length); - const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length); - return (
- {defaultFiltered.map((action, index) => ( - - ))} - - {customFiltered.length > 0 &&
} - - {customFiltered.map((action, i) => { - const index = defaultFiltered.length + i; - return ( + {isLoading ? ( +
+ +
+ ) : isError ? ( +

Failed to load prompts

+ ) : filtered.length === 0 ? ( +

No matching prompts

+ ) : ( + filtered.map((action, index) => ( - ); - })} + )) + )}
); diff --git a/surfsense_web/contracts/types/prompts.types.ts b/surfsense_web/contracts/types/prompts.types.ts index a5c895bc9..74b26b1d7 100644 --- a/surfsense_web/contracts/types/prompts.types.ts +++ b/surfsense_web/contracts/types/prompts.types.ts @@ -7,21 +7,30 @@ export const promptRead = z.object({ name: z.string(), prompt: z.string(), mode: z.enum(["transform", "explore"]), - icon: z.string().nullable(), search_space_id: z.number().nullable(), + is_public: z.boolean(), + version: z.number(), created_at: z.string(), }); export type PromptRead = z.infer; +export const publicPromptRead = promptRead.extend({ + author_name: z.string().nullable(), +}); + +export type PublicPromptRead = z.infer; + export const promptsListResponse = z.array(promptRead); +export const publicPromptsListResponse = z.array(publicPromptRead); + export const promptCreateRequest = z.object({ name: z.string().min(1).max(200), prompt: z.string().min(1), mode: z.enum(["transform", "explore"]), - icon: z.string().max(50).nullable().optional(), search_space_id: z.number().nullable().optional(), + is_public: z.boolean().optional(), }); export type PromptCreateRequest = z.infer; @@ -30,7 +39,7 @@ export const promptUpdateRequest = z.object({ name: z.string().min(1).max(200).optional(), prompt: z.string().min(1).optional(), mode: z.enum(["transform", "explore"]).optional(), - icon: z.string().max(50).nullable().optional(), + is_public: z.boolean().optional(), }); export type PromptUpdateRequest = z.infer; diff --git a/surfsense_web/lib/apis/prompts-api.service.ts b/surfsense_web/lib/apis/prompts-api.service.ts index 307a9f597..38c2ffb4e 100644 --- a/surfsense_web/lib/apis/prompts-api.service.ts +++ b/surfsense_web/lib/apis/prompts-api.service.ts @@ -6,6 +6,7 @@ import { promptRead, promptsListResponse, promptUpdateRequest, + publicPromptsListResponse, } from "@/contracts/types/prompts.types"; import { ValidationError } from "@/lib/error"; import { baseApiService } from "./base-api.service"; @@ -49,6 +50,14 @@ class PromptsApiService { delete = async (promptId: number) => { return baseApiService.delete(`/api/v1/prompts/${promptId}`, promptDeleteResponse); }; + + listPublic = async () => { + return baseApiService.get("/api/v1/prompts/public", publicPromptsListResponse); + }; + + copy = async (promptId: number) => { + return baseApiService.post(`/api/v1/prompts/${promptId}/copy`, promptRead, {}); + }; } export const promptsApiService = new PromptsApiService(); 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,