mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
rename quick-ask-actions to prompts across backend and frontend
This commit is contained in:
parent
11374248d8
commit
a6ccb7a875
15 changed files with 277 additions and 523 deletions
50
surfsense_backend/alembic/versions/109_add_prompts_table.py
Normal file
50
surfsense_backend/alembic/versions/109_add_prompts_table.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
94
surfsense_backend/app/routes/prompts_routes.py
Normal file
94
surfsense_backend/app/routes/prompts_routes.py
Normal file
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
|
|
@ -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<string, React.ReactNode> = {
|
||||
check: <Check className="size-4" />,
|
||||
minimize: <Minimize2 className="size-4" />,
|
||||
languages: <Languages className="size-4" />,
|
||||
"pen-line": <PenLine className="size-4" />,
|
||||
"book-open": <BookOpen className="size-4" />,
|
||||
list: <List className="size-4" />,
|
||||
search: <Search className="size-4" />,
|
||||
globe: <Globe className="size-4" />,
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="text-sm text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<div className="border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2 rounded-md border bg-muted/50 px-3 py-1.5">
|
||||
<Search className="size-3.5 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search actions..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-3 py-2">
|
||||
{filteredTransform.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Transform</div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
{filteredTransform.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(action.id)}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20 cursor-pointer"
|
||||
>
|
||||
<span className="text-muted-foreground">{ICONS[action.icon]}</span>
|
||||
{action.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredExplore.length > 0 && (
|
||||
<>
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">Explore</div>
|
||||
<div className="mb-3 grid grid-cols-2 gap-1.5">
|
||||
{filteredExplore.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
type="button"
|
||||
onClick={() => handleAction(action.id)}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2.5 text-sm transition-colors hover:bg-accent hover:border-accent-foreground/20 cursor-pointer"
|
||||
>
|
||||
<span className="text-muted-foreground">{ICONS[action.icon]}</span>
|
||||
{action.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mb-2 text-xs font-medium text-muted-foreground">My Actions</div>
|
||||
<div className="mb-3 rounded-md border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
Custom actions coming soon
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={navigateWithInitialText}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md bg-primary px-3 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 cursor-pointer"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Ask SurfSense...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<InlineMentionEditorRef>(null);
|
||||
const editorContainerRef = useRef<HTMLDivElement>(null);
|
||||
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
|
||||
const actionPickerRef = useRef<ActionPickerRef>(null);
|
||||
const promptPickerRef = useRef<PromptPickerRef>(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(
|
||||
<ActionPicker
|
||||
ref={actionPickerRef}
|
||||
<PromptPicker
|
||||
ref={promptPickerRef}
|
||||
onSelect={handleActionSelect}
|
||||
onDone={() => {
|
||||
setShowActionPicker(false);
|
||||
setShowPromptPicker(false);
|
||||
setActionQuery("");
|
||||
}}
|
||||
externalSearch={actionQuery}
|
||||
|
|
|
|||
|
|
@ -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<ActionPickerRef, ActionPickerProps>(
|
||||
function ActionPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
|
||||
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(
|
||||
function PromptPicker({ onSelect, onDone, externalSearch = "", containerStyle }, ref) {
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
const [customActions, setCustomActions] = useState<QuickAskActionRead[]>([]);
|
||||
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const shouldScrollRef = useRef(false);
|
||||
const itemRefs = useRef<Map<number, HTMLButtonElement>>(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;
|
||||
40
surfsense_web/contracts/types/prompts.types.ts
Normal file
40
surfsense_web/contracts/types/prompts.types.ts
Normal file
|
|
@ -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<typeof promptRead>;
|
||||
|
||||
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<typeof promptCreateRequest>;
|
||||
|
||||
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<typeof promptUpdateRequest>;
|
||||
|
||||
export const promptDeleteResponse = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
|
@ -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<typeof quickAskActionRead>;
|
||||
|
||||
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<typeof quickAskActionCreateRequest>;
|
||||
|
||||
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<typeof quickAskActionUpdateRequest>;
|
||||
|
||||
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";
|
||||
}
|
||||
54
surfsense_web/lib/apis/prompts-api.service.ts
Normal file
54
surfsense_web/lib/apis/prompts-api.service.ts
Normal file
|
|
@ -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();
|
||||
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue