diff --git a/surfsense_backend/alembic/versions/109_add_prompts_table.py b/surfsense_backend/alembic/versions/109_add_prompts_table.py new file mode 100644 index 000000000..e044839b0 --- /dev/null +++ b/surfsense_backend/alembic/versions/109_add_prompts_table.py @@ -0,0 +1,50 @@ +"""add prompts table + +Revision ID: 109 +Revises: 108 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "109" +down_revision: str | None = "108" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + + result = conn.execute( + sa.text("SELECT 1 FROM pg_type WHERE typname = 'prompt_mode'") + ) + if not result.fetchone(): + op.execute("CREATE TYPE prompt_mode AS ENUM ('transform', 'explore')") + + result = conn.execute( + sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'prompts'") + ) + if not result.fetchone(): + op.execute(""" + CREATE TABLE prompts ( + id SERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + search_space_id INTEGER REFERENCES searchspaces(id) ON DELETE CASCADE, + name VARCHAR(200) NOT NULL, + prompt TEXT NOT NULL, + mode prompt_mode NOT NULL, + icon VARCHAR(50), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now() + ) + """) + op.execute("CREATE INDEX ix_prompts_user_id ON prompts (user_id)") + op.execute("CREATE INDEX ix_prompts_search_space_id ON prompts (search_space_id)") + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS prompts") + op.execute("DROP TYPE IF EXISTS prompt_mode") diff --git a/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py b/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py deleted file mode 100644 index 2b8db7cd4..000000000 --- a/surfsense_backend/alembic/versions/109_add_quick_ask_actions_table.py +++ /dev/null @@ -1,62 +0,0 @@ -"""add quick_ask_actions table - -Revision ID: 109 -Revises: 108 -""" - -from collections.abc import Sequence - -import sqlalchemy as sa - -from alembic import op - -revision: str = "109" -down_revision: str | None = "108" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - op.execute(""" - DO $$ BEGIN - CREATE TYPE quick_ask_action_mode AS ENUM ('transform', 'explore'); - EXCEPTION - WHEN duplicate_object THEN null; - END $$; - """) - - conn = op.get_bind() - result = conn.execute( - sa.text("SELECT 1 FROM information_schema.tables WHERE table_name = 'quick_ask_actions'") - ) - if not result.fetchone(): - op.create_table( - "quick_ask_actions", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.dialects.postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("search_space_id", sa.Integer(), nullable=True), - sa.Column("name", sa.String(200), nullable=False), - sa.Column("prompt", sa.Text(), nullable=False), - sa.Column( - "mode", - sa.Enum("transform", "explore", name="quick_ask_action_mode", create_type=False), - nullable=False, - ), - sa.Column("icon", sa.String(50), nullable=True), - sa.Column( - "created_at", - sa.TIMESTAMP(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["user_id"], ["user.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["search_space_id"], ["searchspaces.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_quick_ask_actions_user_id", "quick_ask_actions", ["user_id"]) - op.create_index("ix_quick_ask_actions_search_space_id", "quick_ask_actions", ["search_space_id"]) - - -def downgrade() -> None: - op.drop_table("quick_ask_actions") - op.execute("DROP TYPE IF EXISTS quick_ask_action_mode") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index eaa445223..42282d0d5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1722,13 +1722,13 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): ) -class QuickAskActionMode(StrEnum): +class PromptMode(StrEnum): TRANSFORM = "transform" EXPLORE = "explore" -class QuickAskAction(BaseModel, TimestampMixin): - __tablename__ = "quick_ask_actions" +class Prompt(BaseModel, TimestampMixin): + __tablename__ = "prompts" user_id = Column( UUID(as_uuid=True), @@ -1744,7 +1744,7 @@ class QuickAskAction(BaseModel, TimestampMixin): ) name = Column(String(200), nullable=False) prompt = Column(Text, nullable=False) - mode = Column(SQLAlchemyEnum(QuickAskActionMode), nullable=False) + mode = Column(SQLAlchemyEnum(PromptMode), nullable=False) icon = Column(String(50), nullable=True) user = relationship("User") diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index 171ee5792..1ddc958aa 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -34,7 +34,7 @@ from .notifications_routes import router as notifications_router from .notion_add_connector_route import router as notion_add_connector_router from .podcasts_routes import router as podcasts_router from .public_chat_routes import router as public_chat_router -from .quick_ask_actions_routes import router as quick_ask_actions_router +from .prompts_routes import router as prompts_router from .rbac_routes import router as rbac_router from .reports_routes import router as reports_router from .sandbox_routes import router as sandbox_router @@ -86,4 +86,4 @@ router.include_router(composio_router) # Composio OAuth and toolkit management router.include_router(public_chat_router) # Public chat sharing and cloning router.include_router(incentive_tasks_router) # Incentive tasks for earning free pages router.include_router(youtube_router) # YouTube playlist resolution -router.include_router(quick_ask_actions_router) +router.include_router(prompts_router) diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py new file mode 100644 index 000000000..ebfe67130 --- /dev/null +++ b/surfsense_backend/app/routes/prompts_routes.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import Prompt, User, get_async_session +from app.schemas.prompts import ( + PromptCreate, + PromptRead, + PromptUpdate, +) +from app.users import current_active_user + +router = APIRouter(tags=["Prompts"]) + + +@router.get("/prompts", response_model=list[PromptRead]) +async def list_prompts( + search_space_id: int | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + query = select(Prompt).where(Prompt.user_id == user.id) + if search_space_id is not None: + query = query.where(Prompt.search_space_id == search_space_id) + query = query.order_by(Prompt.created_at.desc()) + result = await session.execute(query) + return result.scalars().all() + + +@router.post("/prompts", response_model=PromptRead) +async def create_prompt( + body: PromptCreate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + 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, + ) + session.add(prompt) + await session.commit() + await session.refresh(prompt) + return prompt + + +@router.put("/prompts/{prompt_id}", response_model=PromptRead) +async def update_prompt( + prompt_id: int, + body: PromptUpdate, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + result = await session.execute( + select(Prompt).where( + Prompt.id == prompt_id, + Prompt.user_id == user.id, + ) + ) + prompt = result.scalar_one_or_none() + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + + for field, value in body.model_dump(exclude_unset=True).items(): + setattr(prompt, field, value) + + session.add(prompt) + await session.commit() + await session.refresh(prompt) + return prompt + + +@router.delete("/prompts/{prompt_id}") +async def delete_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.user_id == user.id, + ) + ) + prompt = result.scalar_one_or_none() + if not prompt: + raise HTTPException(status_code=404, detail="Prompt not found") + + await session.delete(prompt) + await session.commit() + return {"success": True} diff --git a/surfsense_backend/app/routes/quick_ask_actions_routes.py b/surfsense_backend/app/routes/quick_ask_actions_routes.py deleted file mode 100644 index 6b9868a07..000000000 --- a/surfsense_backend/app/routes/quick_ask_actions_routes.py +++ /dev/null @@ -1,94 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.db import QuickAskAction, User, get_async_session -from app.schemas.quick_ask_actions import ( - QuickAskActionCreate, - QuickAskActionRead, - QuickAskActionUpdate, -) -from app.users import current_active_user - -router = APIRouter(tags=["Quick Ask Actions"]) - - -@router.get("/quick-ask-actions", response_model=list[QuickAskActionRead]) -async def list_actions( - search_space_id: int | None = None, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - query = select(QuickAskAction).where(QuickAskAction.user_id == user.id) - if search_space_id is not None: - query = query.where(QuickAskAction.search_space_id == search_space_id) - query = query.order_by(QuickAskAction.created_at.desc()) - result = await session.execute(query) - return result.scalars().all() - - -@router.post("/quick-ask-actions", response_model=QuickAskActionRead) -async def create_action( - body: QuickAskActionCreate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - action = QuickAskAction( - user_id=user.id, - search_space_id=body.search_space_id, - name=body.name, - prompt=body.prompt, - mode=body.mode, - icon=body.icon, - ) - session.add(action) - await session.commit() - await session.refresh(action) - return action - - -@router.put("/quick-ask-actions/{action_id}", response_model=QuickAskActionRead) -async def update_action( - action_id: int, - body: QuickAskActionUpdate, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - result = await session.execute( - select(QuickAskAction).where( - QuickAskAction.id == action_id, - QuickAskAction.user_id == user.id, - ) - ) - action = result.scalar_one_or_none() - if not action: - raise HTTPException(status_code=404, detail="Action not found") - - for field, value in body.model_dump(exclude_unset=True).items(): - setattr(action, field, value) - - session.add(action) - await session.commit() - await session.refresh(action) - return action - - -@router.delete("/quick-ask-actions/{action_id}") -async def delete_action( - action_id: int, - session: AsyncSession = Depends(get_async_session), - user: User = Depends(current_active_user), -): - result = await session.execute( - select(QuickAskAction).where( - QuickAskAction.id == action_id, - QuickAskAction.user_id == user.id, - ) - ) - action = result.scalar_one_or_none() - if not action: - raise HTTPException(status_code=404, detail="Action not found") - - await session.delete(action) - await session.commit() - return {"success": True} diff --git a/surfsense_backend/app/schemas/quick_ask_actions.py b/surfsense_backend/app/schemas/prompts.py similarity index 86% rename from surfsense_backend/app/schemas/quick_ask_actions.py rename to surfsense_backend/app/schemas/prompts.py index 90fa716b9..c2fd753e6 100644 --- a/surfsense_backend/app/schemas/quick_ask_actions.py +++ b/surfsense_backend/app/schemas/prompts.py @@ -3,7 +3,7 @@ from datetime import datetime from pydantic import BaseModel, Field -class QuickAskActionCreate(BaseModel): +class PromptCreate(BaseModel): name: str = Field(..., min_length=1, max_length=200) prompt: str = Field(..., min_length=1) mode: str = Field(..., pattern="^(transform|explore)$") @@ -11,14 +11,14 @@ class QuickAskActionCreate(BaseModel): search_space_id: int | None = None -class QuickAskActionUpdate(BaseModel): +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) -class QuickAskActionRead(BaseModel): +class PromptRead(BaseModel): id: int name: str prompt: str diff --git a/surfsense_web/app/dashboard/quick-ask/actions.ts b/surfsense_web/app/dashboard/quick-ask/actions.ts deleted file mode 100644 index 984aef2b6..000000000 --- a/surfsense_web/app/dashboard/quick-ask/actions.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { QuickAskAction } from "@/contracts/types/quick-ask-actions.types"; - -export const DEFAULT_ACTIONS: QuickAskAction[] = [ - { - id: "fix-grammar", - 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", - group: "transform", - }, - { - id: "make-shorter", - 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", - group: "transform", - }, - { - id: "translate", - 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", - group: "transform", - }, - { - id: "rewrite", - 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", - group: "transform", - }, - { - id: "summarize", - name: "Summarize", - prompt: "Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}", - mode: "transform", - icon: "list", - group: "transform", - }, - { - id: "explain", - name: "Explain", - prompt: "Explain the following text in simple terms:\n\n{selection}", - mode: "explore", - icon: "book-open", - group: "explore", - }, - { - id: "ask-knowledge-base", - name: "Ask my knowledge base", - prompt: "Search my knowledge base for information related to:\n\n{selection}", - mode: "explore", - icon: "search", - group: "explore", - }, - { - id: "look-up-web", - name: "Look up on the web", - prompt: "Search the web for information about:\n\n{selection}", - mode: "explore", - icon: "globe", - group: "explore", - }, -]; diff --git a/surfsense_web/app/dashboard/quick-ask/page.tsx b/surfsense_web/app/dashboard/quick-ask/page.tsx deleted file mode 100644 index dca398254..000000000 --- a/surfsense_web/app/dashboard/quick-ask/page.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { - BookOpen, - Check, - Globe, - Languages, - List, - MessageSquare, - Minimize2, - PenLine, - Search, -} from "lucide-react"; -import { useEffect, useMemo, useState } from "react"; -import { DEFAULT_ACTIONS } from "./actions"; - -const ICONS: Record = { - check: , - minimize: , - languages: , - "pen-line": , - "book-open": , - list: , - search: , - globe: , -}; - -export default function QuickAskPage() { - const [clipboardText, setClipboardText] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); - - useEffect(() => { - window.electronAPI?.getQuickAskText().then((text) => { - if (text) setClipboardText(text); - }); - }, []); - - const navigateToChat = async (prompt: string, mode: string) => { - await window.electronAPI?.setQuickAskMode(mode); - sessionStorage.setItem("quickAskAutoSubmit", "true"); - const encoded = encodeURIComponent(prompt); - window.location.href = `/dashboard?quickAskPrompt=${encoded}`; - }; - - const navigateWithInitialText = async () => { - if (!clipboardText) return; - await window.electronAPI?.setQuickAskMode("explore"); - sessionStorage.setItem("quickAskAutoSubmit", "false"); - sessionStorage.setItem("quickAskInitialText", clipboardText); - window.location.href = `/dashboard?quickAskPrompt=${encodeURIComponent(clipboardText)}`; - }; - - const handleAction = (actionId: string) => { - const action = DEFAULT_ACTIONS.find((a) => a.id === actionId); - if (!action || !clipboardText) return; - const prompt = action.prompt.replace("{selection}", clipboardText); - navigateToChat(prompt, action.mode); - }; - - const transformActions = DEFAULT_ACTIONS.filter((a) => a.group === "transform"); - const exploreActions = DEFAULT_ACTIONS.filter((a) => a.group === "explore"); - - const filteredTransform = useMemo( - () => transformActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())), - [searchQuery] - ); - const filteredExplore = useMemo( - () => exploreActions.filter((a) => a.name.toLowerCase().includes(searchQuery.toLowerCase())), - [searchQuery] - ); - - if (!clipboardText) { - return ( -
-
Loading...
-
- ); - } - - return ( -
-
-
- - setSearchQuery(e.target.value)} - className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> -
-
- -
- {filteredTransform.length > 0 && ( - <> -
Transform
-
- {filteredTransform.map((action) => ( - - ))} -
- - )} - - {filteredExplore.length > 0 && ( - <> -
Explore
-
- {filteredExplore.map((action) => ( - - ))} -
- - )} - -
My Actions
-
- Custom actions coming soon -
-
- -
- -
-
- ); -} diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index d6edc640d..6ff05a252 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -57,7 +57,7 @@ import { import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; import { UserMessage } from "@/components/assistant-ui/user-message"; import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/components/layout/ui/sidebar/SidebarSlideOutPanel"; -import { ActionPicker, type ActionPickerRef } from "@/components/new-chat/action-picker"; +import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; import { DocumentMentionPicker, type DocumentMentionPickerRef, @@ -299,13 +299,13 @@ const Composer: FC = () => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSidebarDocs = useSetAtom(sidebarSelectedDocumentsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); - const [showActionPicker, setShowActionPicker] = useState(false); + const [showPromptPicker, setShowPromptPicker] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); const [actionQuery, setActionQuery] = useState(""); const editorRef = useRef(null); const editorContainerRef = useRef(null); const documentPickerRef = useRef(null); - const actionPickerRef = useRef(null); + const promptPickerRef = useRef(null); const { search_space_id, chat_id } = useParams(); const aui = useAui(); const hasAutoFocusedRef = useRef(false); @@ -427,24 +427,24 @@ const Composer: FC = () => { // Open action picker when / is triggered const handleActionTrigger = useCallback((query: string) => { - setShowActionPicker(true); + setShowPromptPicker(true); setActionQuery(query); }, []); // Close action picker and reset query const handleActionClose = useCallback(() => { - if (showActionPicker) { - setShowActionPicker(false); + if (showPromptPicker) { + setShowPromptPicker(false); setActionQuery(""); } - }, [showActionPicker]); + }, [showPromptPicker]); // Pending action prompt stored when user picks an action const pendingActionRef = useRef<{ name: string; prompt: string; mode: "transform" | "explore" } | null>(null); const handleActionSelect = useCallback( (action: { name: string; prompt: string; mode: "transform" | "explore" }) => { - setShowActionPicker(false); + setShowPromptPicker(false); setActionQuery(""); pendingActionRef.current = action; editorRef.current?.insertActionChip(action.name); @@ -459,25 +459,25 @@ const Composer: FC = () => { // Keyboard navigation for document/action picker (arrow keys, Enter, Escape) const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - if (showActionPicker) { + if (showPromptPicker) { if (e.key === "ArrowDown") { e.preventDefault(); - actionPickerRef.current?.moveDown(); + promptPickerRef.current?.moveDown(); return; } if (e.key === "ArrowUp") { e.preventDefault(); - actionPickerRef.current?.moveUp(); + promptPickerRef.current?.moveUp(); return; } if (e.key === "Enter") { e.preventDefault(); - actionPickerRef.current?.selectHighlighted(); + promptPickerRef.current?.selectHighlighted(); return; } if (e.key === "Escape") { e.preventDefault(); - setShowActionPicker(false); + setShowPromptPicker(false); setActionQuery(""); return; } @@ -506,7 +506,7 @@ const Composer: FC = () => { } } }, - [showDocumentPopover, showActionPicker] + [showDocumentPopover, showPromptPicker] ); // Submit message (blocked during streaming, document picker open, or AI responding to another user) @@ -514,7 +514,7 @@ const Composer: FC = () => { if (isThreadRunning || isBlockedByOtherUser) { return; } - if (!showDocumentPopover && !showActionPicker) { + if (!showDocumentPopover && !showPromptPicker) { if (pendingActionRef.current) { const userText = editorRef.current?.getText() ?? ""; const finalPrompt = pendingActionRef.current.prompt.replace("{selection}", userText); @@ -528,7 +528,7 @@ const Composer: FC = () => { } }, [ showDocumentPopover, - showActionPicker, + showPromptPicker, isThreadRunning, isBlockedByOtherUser, aui, @@ -621,14 +621,14 @@ const Composer: FC = () => { />, document.body )} - {showActionPicker && + {showPromptPicker && typeof document !== "undefined" && createPortal( - { - setShowActionPicker(false); + setShowPromptPicker(false); setActionQuery(""); }} externalSearch={actionQuery} diff --git a/surfsense_web/components/new-chat/action-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx similarity index 90% rename from surfsense_web/components/new-chat/action-picker.tsx rename to surfsense_web/components/new-chat/prompt-picker.tsx index 4bfac23f4..28176524d 100644 --- a/surfsense_web/components/new-chat/action-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -21,17 +21,17 @@ import { useState, } from "react"; -import type { QuickAskActionRead } from "@/contracts/types/quick-ask-actions.types"; -import { quickAskActionsApiService } from "@/lib/apis/quick-ask-actions-api.service"; +import type { PromptRead } from "@/contracts/types/prompts.types"; +import { promptsApiService } from "@/lib/apis/prompts-api.service"; import { cn } from "@/lib/utils"; -export interface ActionPickerRef { +export interface PromptPickerRef { selectHighlighted: () => void; moveUp: () => void; moveDown: () => void; } -interface ActionPickerProps { +interface PromptPickerProps { onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void; onDone: () => void; externalSearch?: string; @@ -61,27 +61,27 @@ const DEFAULT_ACTIONS: { name: string; prompt: string; mode: "transform" | "expl { name: "Look up on the web", prompt: "Search the web for information about:\n\n{selection}", mode: "explore", icon: "globe" }, ]; -export const ActionPicker = forwardRef( - function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { +export const PromptPicker = forwardRef( + function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) { const [highlightedIndex, setHighlightedIndex] = useState(0); - const [customActions, setCustomActions] = useState([]); + const [customPrompts, setCustomPrompts] = useState([]); const scrollContainerRef = useRef(null); const shouldScrollRef = useRef(false); const itemRefs = useRef>(new Map()); useEffect(() => { - quickAskActionsApiService.list().then(setCustomActions).catch(() => {}); + promptsApiService.list().then(setCustomPrompts).catch(() => {}); }, []); const allActions = useMemo(() => { - const customs = customActions.map((a) => ({ + 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]; - }, [customActions]); + }, [customPrompts]); const filtered = useMemo(() => { if (!externalSearch) return allActions; diff --git a/surfsense_web/contracts/types/prompts.types.ts b/surfsense_web/contracts/types/prompts.types.ts new file mode 100644 index 000000000..a5c895bc9 --- /dev/null +++ b/surfsense_web/contracts/types/prompts.types.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +export type PromptMode = "transform" | "explore"; + +export const promptRead = z.object({ + id: z.number(), + name: z.string(), + prompt: z.string(), + mode: z.enum(["transform", "explore"]), + icon: z.string().nullable(), + search_space_id: z.number().nullable(), + created_at: z.string(), +}); + +export type PromptRead = z.infer; + +export const promptsListResponse = z.array(promptRead); + +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(), +}); + +export type PromptCreateRequest = z.infer; + +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(), +}); + +export type PromptUpdateRequest = z.infer; + +export const promptDeleteResponse = z.object({ + success: z.boolean(), +}); diff --git a/surfsense_web/contracts/types/quick-ask-actions.types.ts b/surfsense_web/contracts/types/quick-ask-actions.types.ts deleted file mode 100644 index eaee09501..000000000 --- a/surfsense_web/contracts/types/quick-ask-actions.types.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { z } from "zod"; - -export type QuickAskActionMode = "transform" | "explore"; - -export const quickAskActionRead = z.object({ - id: z.number(), - name: z.string(), - prompt: z.string(), - mode: z.enum(["transform", "explore"]), - icon: z.string().nullable(), - search_space_id: z.number().nullable(), - created_at: z.string(), -}); - -export type QuickAskActionRead = z.infer; - -export const quickAskActionsListResponse = z.array(quickAskActionRead); - -export const quickAskActionCreateRequest = 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(), -}); - -export type QuickAskActionCreateRequest = z.infer; - -export const quickAskActionUpdateRequest = 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(), -}); - -export type QuickAskActionUpdateRequest = z.infer; - -export const quickAskActionDeleteResponse = z.object({ - success: z.boolean(), -}); - -export interface QuickAskAction { - id: string; - name: string; - prompt: string; - mode: QuickAskActionMode; - icon: string; - group: "transform" | "explore" | "knowledge" | "custom"; -} diff --git a/surfsense_web/lib/apis/prompts-api.service.ts b/surfsense_web/lib/apis/prompts-api.service.ts new file mode 100644 index 000000000..5c445c02a --- /dev/null +++ b/surfsense_web/lib/apis/prompts-api.service.ts @@ -0,0 +1,54 @@ +import { + type PromptCreateRequest, + type PromptUpdateRequest, + promptCreateRequest, + promptDeleteResponse, + promptRead, + promptUpdateRequest, + promptsListResponse, +} from "@/contracts/types/prompts.types"; +import { ValidationError } from "@/lib/error"; +import { baseApiService } from "./base-api.service"; + +class PromptsApiService { + list = async (searchSpaceId?: number) => { + const params = new URLSearchParams(); + if (searchSpaceId !== undefined) { + params.set("search_space_id", String(searchSpaceId)); + } + const queryString = params.toString(); + const url = queryString ? `/api/v1/prompts?${queryString}` : "/api/v1/prompts"; + + return baseApiService.get(url, promptsListResponse); + }; + + create = async (request: PromptCreateRequest) => { + const parsed = promptCreateRequest.safeParse(request); + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.post("/api/v1/prompts", promptRead, { + body: parsed.data, + }); + }; + + update = async (promptId: number, request: PromptUpdateRequest) => { + const parsed = promptUpdateRequest.safeParse(request); + if (!parsed.success) { + const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); + throw new ValidationError(`Invalid request: ${errorMessage}`); + } + + return baseApiService.put(`/api/v1/prompts/${promptId}`, promptRead, { + body: parsed.data, + }); + }; + + delete = async (promptId: number) => { + return baseApiService.delete(`/api/v1/prompts/${promptId}`, promptDeleteResponse); + }; +} + +export const promptsApiService = new PromptsApiService(); diff --git a/surfsense_web/lib/apis/quick-ask-actions-api.service.ts b/surfsense_web/lib/apis/quick-ask-actions-api.service.ts deleted file mode 100644 index ae1c3a360..000000000 --- a/surfsense_web/lib/apis/quick-ask-actions-api.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - type QuickAskActionCreateRequest, - type QuickAskActionUpdateRequest, - quickAskActionCreateRequest, - quickAskActionDeleteResponse, - quickAskActionRead, - quickAskActionUpdateRequest, - quickAskActionsListResponse, -} from "@/contracts/types/quick-ask-actions.types"; -import { ValidationError } from "@/lib/error"; -import { baseApiService } from "./base-api.service"; - -class QuickAskActionsApiService { - list = async (searchSpaceId?: number) => { - const params = new URLSearchParams(); - if (searchSpaceId !== undefined) { - params.set("search_space_id", String(searchSpaceId)); - } - const queryString = params.toString(); - const url = queryString - ? `/api/v1/quick-ask-actions?${queryString}` - : "/api/v1/quick-ask-actions"; - - return baseApiService.get(url, quickAskActionsListResponse); - }; - - create = async (request: QuickAskActionCreateRequest) => { - const parsed = quickAskActionCreateRequest.safeParse(request); - if (!parsed.success) { - const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - return baseApiService.post("/api/v1/quick-ask-actions", quickAskActionRead, { - body: parsed.data, - }); - }; - - update = async (actionId: number, request: QuickAskActionUpdateRequest) => { - const parsed = quickAskActionUpdateRequest.safeParse(request); - if (!parsed.success) { - const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", "); - throw new ValidationError(`Invalid request: ${errorMessage}`); - } - - return baseApiService.put(`/api/v1/quick-ask-actions/${actionId}`, quickAskActionRead, { - body: parsed.data, - }); - }; - - delete = async (actionId: number) => { - return baseApiService.delete( - `/api/v1/quick-ask-actions/${actionId}`, - quickAskActionDeleteResponse - ); - }; -} - -export const quickAskActionsApiService = new QuickAskActionsApiService();