diff --git a/surfsense_backend/alembic/versions/111_add_prompts_table.py b/surfsense_backend/alembic/versions/111_add_prompts_table.py new file mode 100644 index 000000000..7d4d69fd2 --- /dev/null +++ b/surfsense_backend/alembic/versions/111_add_prompts_table.py @@ -0,0 +1,50 @@ +"""add prompts table + +Revision ID: 111 +Revises: 110 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "111" +down_revision: str | None = "110" +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/app/db.py b/surfsense_backend/app/db.py index a8510ebab..ef3f6d4c2 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1775,6 +1775,35 @@ class SearchSpaceInvite(BaseModel, TimestampMixin): ) +class PromptMode(StrEnum): + TRANSFORM = "transform" + EXPLORE = "explore" + + +class Prompt(BaseModel, TimestampMixin): + __tablename__ = "prompts" + + user_id = Column( + UUID(as_uuid=True), + ForeignKey("user.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + search_space_id = Column( + Integer, + ForeignKey("searchspaces.id", ondelete="CASCADE"), + 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) + + user = relationship("User") + search_space = relationship("SearchSpace") + + if config.AUTH_TYPE == "GOOGLE": class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): diff --git a/surfsense_backend/app/routes/__init__.py b/surfsense_backend/app/routes/__init__.py index af26e3680..a2b7a154a 100644 --- a/surfsense_backend/app/routes/__init__.py +++ b/surfsense_backend/app/routes/__init__.py @@ -35,6 +35,7 @@ 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 .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 @@ -89,3 +90,4 @@ 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(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/schemas/prompts.py b/surfsense_backend/app/schemas/prompts.py new file mode 100644 index 000000000..c2fd753e6 --- /dev/null +++ b/surfsense_backend/app/schemas/prompts.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel, Field + + +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 + + +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 PromptRead(BaseModel): + id: int + name: str + prompt: str + mode: str + icon: str | None + search_space_id: int | None + created_at: datetime + + class Config: + from_attributes = True diff --git a/surfsense_desktop/src/ipc/channels.ts b/surfsense_desktop/src/ipc/channels.ts index 18002b520..25ec1bc0e 100644 --- a/surfsense_desktop/src/ipc/channels.ts +++ b/surfsense_desktop/src/ipc/channels.ts @@ -3,4 +3,7 @@ export const IPC_CHANNELS = { GET_APP_VERSION: 'get-app-version', DEEP_LINK: 'deep-link', QUICK_ASK_TEXT: 'quick-ask-text', + SET_QUICK_ASK_MODE: 'set-quick-ask-mode', + GET_QUICK_ASK_MODE: 'get-quick-ask-mode', + REPLACE_TEXT: 'replace-text', } as const; diff --git a/surfsense_desktop/src/modules/platform.ts b/surfsense_desktop/src/modules/platform.ts new file mode 100644 index 000000000..37e126799 --- /dev/null +++ b/surfsense_desktop/src/modules/platform.ts @@ -0,0 +1,55 @@ +import { execSync } from 'child_process'; +import { systemPreferences } from 'electron'; + +export function getFrontmostApp(): string { + try { + if (process.platform === 'darwin') { + return execSync( + 'osascript -e \'tell application "System Events" to get name of first application process whose frontmost is true\'' + ).toString().trim(); + } + if (process.platform === 'win32') { + return execSync( + 'powershell -command "Add-Type \'using System; using System.Runtime.InteropServices; public class W { [DllImport(\\\"user32.dll\\\")] public static extern IntPtr GetForegroundWindow(); }\'; (Get-Process | Where-Object { $_.MainWindowHandle -eq [W]::GetForegroundWindow() }).ProcessName"' + ).toString().trim(); + } + } catch { + return ''; + } + return ''; +} + +export function getSelectedText(): string { + try { + if (process.platform === 'darwin') { + return execSync( + 'osascript -e \'tell application "System Events" to get value of attribute "AXSelectedText" of focused UI element of first application process whose frontmost is true\'' + ).toString().trim(); + } + // Windows: no reliable accessibility API for selected text across apps + } catch { + return ''; + } + return ''; +} + +export function simulateCopy(): void { + if (process.platform === 'darwin') { + execSync('osascript -e \'tell application "System Events" to keystroke "c" using command down\''); + } else if (process.platform === 'win32') { + execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^c\')"'); + } +} + +export function simulatePaste(): void { + if (process.platform === 'darwin') { + execSync('osascript -e \'tell application "System Events" to keystroke "v" using command down\''); + } else if (process.platform === 'win32') { + execSync('powershell -command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(\'^v\')"'); + } +} + +export function checkAccessibilityPermission(): boolean { + if (process.platform !== 'darwin') return true; + return systemPreferences.isTrustedAccessibilityClient(true); +} diff --git a/surfsense_desktop/src/modules/quick-ask.ts b/surfsense_desktop/src/modules/quick-ask.ts index 9009099a3..52bfc6054 100644 --- a/surfsense_desktop/src/modules/quick-ask.ts +++ b/surfsense_desktop/src/modules/quick-ask.ts @@ -1,16 +1,22 @@ import { BrowserWindow, clipboard, globalShortcut, ipcMain, screen, shell } from 'electron'; import path from 'path'; import { IPC_CHANNELS } from '../ipc/channels'; +import { checkAccessibilityPermission, getFrontmostApp, simulatePaste } from './platform'; import { getServerPort } from './server'; const SHORTCUT = 'CommandOrControl+Option+S'; let quickAskWindow: BrowserWindow | null = null; let pendingText = ''; +let pendingMode = ''; +let sourceApp = ''; +let savedClipboard = ''; -function hideQuickAsk(): void { +function destroyQuickAsk(): void { if (quickAskWindow && !quickAskWindow.isDestroyed()) { - quickAskWindow.hide(); + quickAskWindow.close(); } + quickAskWindow = null; + pendingMode = ''; } function clampToScreen(x: number, y: number, w: number, h: number): { x: number; y: number } { @@ -23,16 +29,11 @@ function clampToScreen(x: number, y: number, w: number, h: number): { x: number; } function createQuickAskWindow(x: number, y: number): BrowserWindow { - if (quickAskWindow && !quickAskWindow.isDestroyed()) { - quickAskWindow.setPosition(x, y); - quickAskWindow.show(); - quickAskWindow.focus(); - return quickAskWindow; - } + destroyQuickAsk(); quickAskWindow = new BrowserWindow({ width: 450, - height: 550, + height: 750, x, y, ...(process.platform === 'darwin' @@ -58,7 +59,7 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { }); quickAskWindow.webContents.on('before-input-event', (_event, input) => { - if (input.key === 'Escape') hideQuickAsk(); + if (input.key === 'Escape') destroyQuickAsk(); }); quickAskWindow.webContents.setWindowOpenHandler(({ url }) => { @@ -78,17 +79,20 @@ function createQuickAskWindow(x: number, y: number): BrowserWindow { export function registerQuickAsk(): void { const ok = globalShortcut.register(SHORTCUT, () => { - if (quickAskWindow && !quickAskWindow.isDestroyed() && quickAskWindow.isVisible()) { - hideQuickAsk(); + if (quickAskWindow && !quickAskWindow.isDestroyed()) { + destroyQuickAsk(); return; } - const text = clipboard.readText().trim(); + sourceApp = getFrontmostApp(); + savedClipboard = clipboard.readText(); + + const text = savedClipboard.trim(); if (!text) return; pendingText = text; const cursor = screen.getCursorScreenPoint(); - const pos = clampToScreen(cursor.x, cursor.y, 450, 550); + const pos = clampToScreen(cursor.x, cursor.y, 450, 750); createQuickAskWindow(pos.x, pos.y); }); @@ -101,6 +105,35 @@ export function registerQuickAsk(): void { pendingText = ''; return text; }); + + ipcMain.handle(IPC_CHANNELS.SET_QUICK_ASK_MODE, (_event, mode: string) => { + pendingMode = mode; + }); + + ipcMain.handle(IPC_CHANNELS.GET_QUICK_ASK_MODE, (event) => { + if (quickAskWindow && !quickAskWindow.isDestroyed() && event.sender.id === quickAskWindow.webContents.id) { + return pendingMode; + } + return ''; + }); + + ipcMain.handle(IPC_CHANNELS.REPLACE_TEXT, async (_event, text: string) => { + if (!sourceApp) return; + + if (!checkAccessibilityPermission()) return; + + clipboard.writeText(text); + destroyQuickAsk(); + + try { + await new Promise((r) => setTimeout(r, 50)); + simulatePaste(); + await new Promise((r) => setTimeout(r, 100)); + clipboard.writeText(savedClipboard); + } catch { + clipboard.writeText(savedClipboard); + } + }); } export function unregisterQuickAsk(): void { diff --git a/surfsense_desktop/src/preload.ts b/surfsense_desktop/src/preload.ts index 9c857de1b..264ec25b3 100644 --- a/surfsense_desktop/src/preload.ts +++ b/surfsense_desktop/src/preload.ts @@ -18,4 +18,7 @@ contextBridge.exposeInMainWorld('electronAPI', { }; }, getQuickAskText: () => ipcRenderer.invoke(IPC_CHANNELS.QUICK_ASK_TEXT), + setQuickAskMode: (mode: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_QUICK_ASK_MODE, mode), + getQuickAskMode: () => ipcRenderer.invoke(IPC_CHANNELS.GET_QUICK_ASK_MODE), + replaceText: (text: string) => ipcRenderer.invoke(IPC_CHANNELS.REPLACE_TEXT, text), }); 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 29bbc0c5c..4e93ace51 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]/user-settings/components/PromptsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx new file mode 100644 index 000000000..38ccafa94 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/PromptsContent.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { 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 { promptsApiService } from "@/lib/apis/prompts-api.service"; + +interface PromptFormData { + name: string; + prompt: string; + mode: "transform" | "explore"; +} + +const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform" }; + +export function PromptsContent() { + const [prompts, setPrompts] = useState([]); + const [isLoading, setIsLoading] = useState(true); + 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 handleSave = useCallback(async () => { + if (!formData.name.trim() || !formData.prompt.trim()) { + toast.error("Name and prompt are required"); + return; + } + + 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"); + } else { + const created = await promptsApiService.create(formData); + setPrompts((prev) => [created, ...prev]); + toast.success("Prompt created"); + } + setShowForm(false); + setFormData(EMPTY_FORM); + setEditingId(null); + } catch { + toast.error("Failed to save prompt"); + } finally { + setIsSaving(false); + } + }, [formData, editingId]); + + const handleEdit = useCallback((prompt: PromptRead) => { + setFormData({ + name: prompt.name, + prompt: prompt.prompt, + mode: prompt.mode as "transform" | "explore", + }); + setEditingId(prompt.id); + setShowForm(true); + }, []); + + const handleDelete = useCallback(async (id: number) => { + try { + await promptsApiService.delete(id); + setPrompts((prev) => prev.filter((p) => p.id !== id)); + toast.success("Prompt deleted"); + } catch { + toast.error("Failed to delete prompt"); + } + }, []); + + const handleCancel = useCallback(() => { + setShowForm(false); + setFormData(EMPTY_FORM); + setEditingId(null); + }, []); + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( +
+
+

+ Create prompt templates triggered with / in the chat composer. +

+ {!showForm && ( + + )} +
+ + {showForm && ( +
+

+ {editingId ? "Edit prompt" : "New prompt"} +

+ +
+ + setFormData((p) => ({ ...p, name: e.target.value }))} + placeholder="e.g. Fix grammar" + /> +
+ +
+ +