From f14f09cbe635feae194b234d2b74632d7ebf3080 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 30 Mar 2026 19:33:16 +0200 Subject: [PATCH 01/23] add is_public to prompts model, schema, and migration --- .../versions/112_add_is_public_to_prompts.py | 24 +++++++++++++++++++ surfsense_backend/app/db.py | 1 + surfsense_backend/app/schemas/prompts.py | 7 ++++++ 3 files changed, 32 insertions(+) create mode 100644 surfsense_backend/alembic/versions/112_add_is_public_to_prompts.py 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 new file mode 100644 index 000000000..23a71ad9f --- /dev/null +++ b/surfsense_backend/alembic/versions/112_add_is_public_to_prompts.py @@ -0,0 +1,24 @@ +"""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/app/db.py b/surfsense_backend/app/db.py index ef3f6d4c2..17d2df996 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1799,6 +1799,7 @@ class Prompt(BaseModel, TimestampMixin): prompt = Column(Text, nullable=False) mode = Column(SQLAlchemyEnum(PromptMode), nullable=False) icon = Column(String(50), nullable=True) + is_public = Column(Boolean, nullable=False, default=False) user = relationship("User") search_space = relationship("SearchSpace") diff --git a/surfsense_backend/app/schemas/prompts.py b/surfsense_backend/app/schemas/prompts.py index c2fd753e6..a92751b12 100644 --- a/surfsense_backend/app/schemas/prompts.py +++ b/surfsense_backend/app/schemas/prompts.py @@ -9,6 +9,7 @@ class PromptCreate(BaseModel): 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): @@ -16,6 +17,7 @@ class PromptUpdate(BaseModel): 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): @@ -25,7 +27,12 @@ class PromptRead(BaseModel): mode: str icon: str | None search_space_id: int | None + is_public: bool created_at: datetime class Config: from_attributes = True + + +class PublicPromptRead(PromptRead): + author_name: str | None = None From 24fd19a3948069b3d068b9628bb6f23a46eaac0a Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 30 Mar 2026 19:36:54 +0200 Subject: [PATCH 02/23] add public prompts and copy endpoints --- .../app/routes/prompts_routes.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/surfsense_backend/app/routes/prompts_routes.py b/surfsense_backend/app/routes/prompts_routes.py index ebfe67130..b2cecd1a3 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.schemas.prompts import ( PromptCreate, PromptRead, PromptUpdate, + PublicPromptRead, ) from app.users import current_active_user @@ -92,3 +94,54 @@ 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)) + .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, + icon=source.icon, + is_public=False, + ) + session.add(copy) + await session.commit() + await session.refresh(copy) + return copy From 16884963a4bb08df173c3417d48d83342611986c Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 30 Mar 2026 19:38:00 +0200 Subject: [PATCH 03/23] add is_public to frontend types and API service --- surfsense_web/contracts/types/prompts.types.ts | 11 +++++++++++ surfsense_web/lib/apis/prompts-api.service.ts | 11 ++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/surfsense_web/contracts/types/prompts.types.ts b/surfsense_web/contracts/types/prompts.types.ts index a5c895bc9..4d1bf3fb9 100644 --- a/surfsense_web/contracts/types/prompts.types.ts +++ b/surfsense_web/contracts/types/prompts.types.ts @@ -9,19 +9,29 @@ export const promptRead = z.object({ mode: z.enum(["transform", "explore"]), icon: z.string().nullable(), search_space_id: z.number().nullable(), + is_public: z.boolean(), 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; @@ -31,6 +41,7 @@ export const promptUpdateRequest = z.object({ 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 5c445c02a..38c2ffb4e 100644 --- a/surfsense_web/lib/apis/prompts-api.service.ts +++ b/surfsense_web/lib/apis/prompts-api.service.ts @@ -4,8 +4,9 @@ import { promptCreateRequest, promptDeleteResponse, promptRead, - promptUpdateRequest, 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(); From 1238efaf990df50f85797742f377349cae206d69 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 30 Mar 2026 19:41:14 +0200 Subject: [PATCH 04/23] add community prompts tab and public toggle in prompt form --- .../components/CommunityPromptsContent.tsx | 104 ++++++++++++++++++ .../components/PromptsContent.tsx | 56 +++++++--- .../settings/user-settings-dialog.tsx | 9 +- 3 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx 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..4ae47dbfc --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { Copy, Globe, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +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 [copyingId, setCopyingId] = 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 promptsApiService.copy(id); + toast.success("Prompt added to your collection"); + } catch { + toast.error("Failed to copy prompt"); + } finally { + setCopyingId(null); + } + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+

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

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

No community prompts yet

+

+ Share your own prompts from the My Prompts tab +

+
+ )} + + {prompts.length > 0 && ( +
+ {prompts.map((prompt) => ( +
+
+ +
+
+
+ {prompt.name} + + {prompt.mode} + +
+

{prompt.prompt}

+ {prompt.author_name && ( +

+ by {prompt.author_name} +

+ )} +
+ +
+ ))} +
+ )} +
+ ); +} 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 38ccafa94..c91c19f2c 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,22 +1,24 @@ "use client"; -import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; +import { Globe, PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; -import type { PromptRead } from "@/contracts/types/prompts.types"; 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([]); @@ -66,6 +68,7 @@ export function PromptsContent() { name: prompt.name, prompt: prompt.prompt, mode: prompt.mode as "transform" | "explore", + is_public: prompt.is_public, }); setEditingId(prompt.id); setShowForm(true); @@ -99,7 +102,9 @@ export function PromptsContent() {

- Create prompt templates triggered with / in the chat composer. + Create prompt templates triggered with{" "} + / in the + chat composer.

{!showForm && (
@@ -153,7 +162,9 @@ export function PromptsContent() {
-
- - -
+
+ setFormData((p) => ({ ...p, is_public: checked }))} + /> + +
+ +
+ + +
)} @@ -198,6 +220,12 @@ export function PromptsContent() { {prompt.mode} + {prompt.is_public && ( + + + Public + + )}

{prompt.prompt}

diff --git a/surfsense_web/components/settings/user-settings-dialog.tsx b/surfsense_web/components/settings/user-settings-dialog.tsx index 88fc729de..3a66c54de 100644 --- a/surfsense_web/components/settings/user-settings-dialog.tsx +++ b/surfsense_web/components/settings/user-settings-dialog.tsx @@ -1,9 +1,10 @@ "use client"; import { useAtom } from "jotai"; -import { KeyRound, Sparkles, User } from "lucide-react"; +import { Globe, KeyRound, Sparkles, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent"; +import { CommunityPromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/CommunityPromptsContent"; import { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent"; import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; @@ -25,6 +26,11 @@ export function UserSettingsDialog() { label: "My Prompts", icon: , }, + { + value: "community-prompts", + label: "Community Prompts", + icon: , + }, ]; return ( @@ -40,6 +46,7 @@ export function UserSettingsDialog() { {state.initialTab === "profile" && } {state.initialTab === "api-key" && } {state.initialTab === "prompts" && } + {state.initialTab === "community-prompts" && } ); From 3de8ac90d85f2a347467f559b135384f35c0e2e5 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Mon, 30 Mar 2026 20:19:43 +0200 Subject: [PATCH 05/23] add inline share toggle on prompt cards --- .../user-settings/components/PromptsContent.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 c91c19f2c..55cabe49e 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 @@ -230,6 +230,22 @@ export function PromptsContent() {

{prompt.prompt}

+
-

{prompt.prompt}

- {prompt.author_name && ( -

- by {prompt.author_name} -

+

+ {prompt.prompt} +

+ {prompt.prompt.length > 100 && ( + )} + )}
- onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc)}> + + onOpenInTab ? onOpenInTab(doc) : handleViewDocument(doc) + } + > Open diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 4e93ace51..29bbc0c5c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -1581,4 +1581,4 @@ export default function NewChatPage() {
); -} \ No newline at end of file +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx index b6f008887..d9ca9efb3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx @@ -308,7 +308,8 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) { {invitesLoading ? ( ) : ( - canInvite && activeInvites.length > 0 && ( + canInvite && + activeInvites.length > 0 && ( ) )} diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index abd70e3f4..a3d84128c 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -7,7 +7,14 @@ import { useAuiState, } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { CheckIcon, ClipboardPaste, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; +import { + CheckIcon, + ClipboardPaste, + CopyIcon, + DownloadIcon, + MessageSquare, + RefreshCwIcon, +} from "lucide-react"; import type { FC } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { commentsEnabledAtom, targetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; @@ -40,10 +47,6 @@ import { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI, } from "@/components/tool-ui/google-drive"; -import { - CreateOneDriveFileToolUI, - DeleteOneDriveFileToolUI, -} from "@/components/tool-ui/onedrive"; import { CreateJiraIssueToolUI, DeleteJiraIssueToolUI, @@ -59,6 +62,7 @@ import { DeleteNotionPageToolUI, UpdateNotionPageToolUI, } from "@/components/tool-ui/notion"; +import { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "@/components/tool-ui/onedrive"; import { SandboxExecuteToolUI } from "@/components/tool-ui/sandbox-execute"; import { RecallMemoryToolUI, SaveMemoryToolUI } from "@/components/tool-ui/user-memory"; import { GenerateVideoPresentationToolUI } from "@/components/tool-ui/video-presentation"; diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index f1cf5ee4d..c8175fcb7 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -340,11 +340,11 @@ export const ConnectorIndicator = forwardRef { const cfg = connectorConfig || editingConnector.config; - const isDriveOrOneDrive = - editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || - editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || - editingConnector.connector_type === "ONEDRIVE_CONNECTOR"; - const hasDriveItems = isDriveOrOneDrive + const isDriveOrOneDrive = + editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "ONEDRIVE_CONNECTOR"; + const hasDriveItems = isDriveOrOneDrive ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 || ((cfg?.selected_files as unknown[]) ?? []).length > 0 : true; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx index 250a353cd..dc8ec3ded 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx @@ -212,8 +212,7 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh {isAuthExpired && (

- Your OneDrive authentication has expired. Please re-authenticate using the button - below. + Your OneDrive authentication has expired. Please re-authenticate using the button below.

)} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx index ba43ce823..605de93b7 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/index.tsx @@ -19,9 +19,9 @@ import { LinkupApiConfig } from "./components/linkup-api-config"; import { LumaConfig } from "./components/luma-config"; import { MCPConfig } from "./components/mcp-config"; import { ObsidianConfig } from "./components/obsidian-config"; +import { OneDriveConfig } from "./components/onedrive-config"; import { SlackConfig } from "./components/slack-config"; import { TavilyApiConfig } from "./components/tavily-api-config"; -import { OneDriveConfig } from "./components/onedrive-config"; import { TeamsConfig } from "./components/teams-config"; import { WebcrawlerConfig } from "./components/webcrawler-config"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 0ee34d7c2..e5ce803c1 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -779,11 +779,11 @@ export const useConnectorDialog = () => { }); } - // Handle Google Drive / OneDrive folder selection (regular and Composio) - if ( - (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" || - indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || - indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") && + // Handle Google Drive / OneDrive folder selection (regular and Composio) + if ( + (indexingConfig.connectorType === "GOOGLE_DRIVE_CONNECTOR" || + indexingConfig.connectorType === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || + indexingConfig.connectorType === "ONEDRIVE_CONNECTOR") && indexingConnectorConfig ) { const selectedFolders = indexingConnectorConfig.selected_folders as diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index b8a0febbe..af7a8397c 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -544,7 +544,12 @@ export const InlineMentionEditor = forwardRef {children} diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index e1e45c521..69eef939e 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -60,11 +60,11 @@ 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 { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; import { DocumentMentionPicker, type DocumentMentionPickerRef, } from "@/components/new-chat/document-mention-picker"; +import { PromptPicker, type PromptPickerRef } from "@/components/new-chat/prompt-picker"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer"; @@ -490,7 +490,9 @@ const Composer: FC = () => { } const finalPrompt = action.prompt.includes("{selection}") ? action.prompt.replace("{selection}", () => userText) - : userText ? `${action.prompt}\n\n${userText}` : action.prompt; + : userText + ? `${action.prompt}\n\n${userText}` + : action.prompt; aui.composer().setText(finalPrompt); aui.composer().send(); editorRef.current?.clear(); @@ -582,9 +584,7 @@ const Composer: FC = () => { if (!showDocumentPopover && !showPromptPicker) { if (clipboardInitialText) { const userText = editorRef.current?.getText() ?? ""; - const combined = userText - ? `${userText}\n\n${clipboardInitialText}` - : clipboardInitialText; + const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText; aui.composer().setText(combined); setClipboardInitialText(undefined); } @@ -640,7 +640,7 @@ const Composer: FC = () => { return ( { currentUserId={currentUser?.id ?? null} members={members ?? []} /> -
+
{clipboardInitialText && ( { position: "fixed", ...(clipboardInitialText && composerBoxRef.current ? { top: `${composerBoxRef.current.getBoundingClientRect().bottom + 8}px` } - : { bottom: editorContainerRef.current - ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` - : "200px" } - ), + : { + bottom: editorContainerRef.current + ? `${window.innerHeight - editorContainerRef.current.getBoundingClientRect().top + 8}px` + : "200px", + }), left: editorContainerRef.current ? `${editorContainerRef.current.getBoundingClientRect().left}px` : "50%", diff --git a/surfsense_web/components/documents/FolderTreeView.tsx b/surfsense_web/components/documents/FolderTreeView.tsx index a8397e2b5..f63d5da5c 100644 --- a/surfsense_web/components/documents/FolderTreeView.tsx +++ b/surfsense_web/components/documents/FolderTreeView.tsx @@ -240,7 +240,9 @@ export function FolderTreeView({ return (

No documents found

-

Use the upload button or connect a source above

+

+ Use the upload button or connect a source above +

); } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index da28c17e0..2b31b997e 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -7,21 +7,21 @@ import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { MarkdownViewer } from "@/components/markdown-viewer"; import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { expandedFolderIdsAtom } from "@/atoms/documents/folder.atoms"; -import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { agentCreatedDocumentsAtom } from "@/atoms/documents/ui.atoms"; +import { rightPanelCollapsedAtom } from "@/atoms/layout/right-panel.atom"; import { openDocumentTabAtom } from "@/atoms/tabs/tabs.atom"; import { CreateFolderDialog } from "@/components/documents/CreateFolderDialog"; 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, @@ -49,8 +49,8 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useIsMobile } from "@/hooks/use-mobile"; -import { foldersApiService } from "@/lib/apis/folders-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { foldersApiService } from "@/lib/apis/folders-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { queries } from "@/zero/queries/index"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; diff --git a/surfsense_web/components/new-chat/prompt-picker.tsx b/surfsense_web/components/new-chat/prompt-picker.tsx index dee3eae32..7f0dab8a4 100644 --- a/surfsense_web/components/new-chat/prompt-picker.tsx +++ b/surfsense_web/components/new-chat/prompt-picker.tsx @@ -1,5 +1,6 @@ "use client"; +import { useSetAtom } from "jotai"; import { BookOpen, Check, @@ -8,11 +9,10 @@ import { List, Minimize2, PenLine, + Plus, Search, Zap, - Plus, } from "lucide-react"; -import { useSetAtom } from "jotai"; import { forwardRef, useCallback, @@ -53,113 +53,192 @@ const ICONS: Record = { 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" }, +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 [highlightedIndex, setHighlightedIndex] = useState(0); - const [customPrompts, setCustomPrompts] = useState([]); - const scrollContainerRef = useRef(null); - const shouldScrollRef = useRef(false); - const itemRefs = useRef>(new Map()); +export const PromptPicker = forwardRef(function PromptPicker( + { onSelect, onDone, externalSearch = "", containerStyle }, + ref +) { + const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); + 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(() => {}); - }, []); + 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 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 filtered = useMemo(() => { + if (!externalSearch) return allActions; + return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); + }, [allActions, externalSearch]); - // Reset highlight when results change - const prevSearchRef = useRef(externalSearch); - if (prevSearchRef.current !== externalSearch) { - prevSearchRef.current = externalSearch; - if (highlightedIndex !== 0) { - setHighlightedIndex(0); - } + // Reset highlight when results change + const prevSearchRef = useRef(externalSearch); + if (prevSearchRef.current !== externalSearch) { + prevSearchRef.current = externalSearch; + if (highlightedIndex !== 0) { + setHighlightedIndex(0); } + } - const handleSelect = useCallback( - (index: number) => { - const action = filtered[index]; - if (!action) return; - onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); - }, - [filtered, onSelect] - ); + const handleSelect = useCallback( + (index: number) => { + const action = filtered[index]; + if (!action) return; + onSelect({ name: action.name, prompt: action.prompt, mode: action.mode }); + }, + [filtered, onSelect] + ); - // Auto-scroll highlighted item into view - useEffect(() => { - if (!shouldScrollRef.current) return; - shouldScrollRef.current = false; + // Auto-scroll highlighted item into view + useEffect(() => { + if (!shouldScrollRef.current) return; + shouldScrollRef.current = false; - const rafId = requestAnimationFrame(() => { - const item = itemRefs.current.get(highlightedIndex); - const container = scrollContainerRef.current; - if (item && container) { - const itemRect = item.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { - item.scrollIntoView({ block: "nearest" }); - } + const rafId = requestAnimationFrame(() => { + const item = itemRefs.current.get(highlightedIndex); + const container = scrollContainerRef.current; + if (item && container) { + const itemRect = item.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + if (itemRect.top < containerRect.top || itemRect.bottom > containerRect.bottom) { + item.scrollIntoView({ block: "nearest" }); } - }); + } + }); - return () => cancelAnimationFrame(rafId); - }, [highlightedIndex]); + return () => cancelAnimationFrame(rafId); + }, [highlightedIndex]); - useImperativeHandle( - ref, - () => ({ - selectHighlighted: () => handleSelect(highlightedIndex), - moveUp: () => { - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); - }, - moveDown: () => { - shouldScrollRef.current = true; - setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); - }, - }), - [filtered.length, highlightedIndex, handleSelect] - ); + useImperativeHandle( + ref, + () => ({ + selectHighlighted: () => handleSelect(highlightedIndex), + moveUp: () => { + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); + }, + moveDown: () => { + shouldScrollRef.current = true; + setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); + }, + }), + [filtered.length, highlightedIndex, handleSelect] + ); - if (filtered.length === 0) return null; + if (filtered.length === 0) return null; - const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length); - const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length); + const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length); + const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length); - return ( -
-
- {defaultFiltered.map((action, index) => ( + return ( +
+
+ {defaultFiltered.map((action, index) => ( + + ))} + + {customFiltered.length > 0 &&
} + + {customFiltered.map((action, i) => { + const index = defaultFiltered.length + i; + return ( - ))} + ); + })} - {customFiltered.length > 0 && ( -
- )} - - {customFiltered.map((action, i) => { - const index = defaultFiltered.length + i; - return ( - - ); - })} - -
- -
+
+
- ); - } -); +
+ ); +}); diff --git a/surfsense_web/components/tool-ui/index.ts b/surfsense_web/components/tool-ui/index.ts index 2e4ea82ef..517a8a290 100644 --- a/surfsense_web/components/tool-ui/index.ts +++ b/surfsense_web/components/tool-ui/index.ts @@ -17,7 +17,6 @@ export { export { GeneratePodcastToolUI } from "./generate-podcast"; export { GenerateReportToolUI } from "./generate-report"; export { CreateGoogleDriveFileToolUI, DeleteGoogleDriveFileToolUI } from "./google-drive"; -export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive"; export { Image, ImageErrorBoundary, @@ -33,6 +32,7 @@ export { UpdateLinearIssueToolUI, } from "./linear"; export { CreateNotionPageToolUI, DeleteNotionPageToolUI, UpdateNotionPageToolUI } from "./notion"; +export { CreateOneDriveFileToolUI, DeleteOneDriveFileToolUI } from "./onedrive"; export { Plan, PlanErrorBoundary, diff --git a/surfsense_web/components/tool-ui/onedrive/create-file.tsx b/surfsense_web/components/tool-ui/onedrive/create-file.tsx index c75be7f7f..d66f04d24 100644 --- a/surfsense_web/components/tool-ui/onedrive/create-file.tsx +++ b/surfsense_web/components/tool-ui/onedrive/create-file.tsx @@ -270,9 +270,7 @@ function ApprovalCard({ )}
-

- File Type -

+

File Type

-
- setFormData((p) => ({ ...p, is_public: checked }))} - /> - -
+
+ setFormData((p) => ({ ...p, is_public: checked }))} + /> + +
-
- - -
+
+ + +
)} {prompts.length === 0 && !showForm && (
-

No custom prompts yet

+

No prompts yet

Create prompts to quickly transform or explore text with /

@@ -228,7 +228,9 @@ export function PromptsContent() { )}
-

+

{prompt.prompt}

{prompt.prompt.length > 100 && ( @@ -247,7 +249,9 @@ export function PromptsContent() { title={prompt.is_public ? "Make private" : "Share with community"} onClick={async () => { try { - const updated = await promptsApiService.update(prompt.id, { is_public: !prompt.is_public }); + 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 { @@ -256,7 +260,11 @@ export function PromptsContent() { }} className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" > - {prompt.is_public ? : } + {prompt.is_public ? ( + + ) : ( + + )} ))} - {customFiltered.length > 0 &&
} - - {customFiltered.map((action, i) => { - const index = defaultFiltered.length + i; - return ( - - ); - })} -
)}
- +
))}
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) => ( + + )) + )}
- +