Merge pull request #1064 from CREDO23/feature/prompt-library

[Feature] Add Prompt Library
This commit is contained in:
Rohan Verma 2026-03-31 14:32:46 -07:00 committed by GitHub
commit b650320b68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 731 additions and 197 deletions

View file

@ -0,0 +1,62 @@
"""add prompt library schema: is_public, default_prompt_slug, version, drop icon
Revision ID: 113
Revises: 112
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "113"
down_revision: str | None = "112"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" is_public BOOLEAN NOT NULL DEFAULT false"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_prompts_is_public"
" ON prompts (is_public) WHERE is_public = true"
)
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" default_prompt_slug VARCHAR(100)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS ix_prompts_default_prompt_slug"
" ON prompts (default_prompt_slug)"
)
conn = op.get_bind()
exists = conn.execute(
sa.text(
"SELECT 1 FROM pg_constraint WHERE conname = 'uq_prompt_user_default_slug'"
)
).scalar()
if not exists:
op.execute(
"ALTER TABLE prompts ADD CONSTRAINT uq_prompt_user_default_slug"
" UNIQUE (user_id, default_prompt_slug)"
)
op.execute(
"ALTER TABLE prompts ADD COLUMN IF NOT EXISTS"
" version INTEGER NOT NULL DEFAULT 1"
)
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS icon")
def downgrade() -> None:
op.execute("ALTER TABLE prompts ADD COLUMN IF NOT EXISTS icon VARCHAR(50)")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS version")
op.execute(
"ALTER TABLE prompts DROP CONSTRAINT IF EXISTS uq_prompt_user_default_slug"
)
op.execute("DROP INDEX IF EXISTS ix_prompts_default_prompt_slug")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS default_prompt_slug")
op.execute("DROP INDEX IF EXISTS ix_prompts_is_public")
op.execute("ALTER TABLE prompts DROP COLUMN IF EXISTS is_public")

View file

@ -0,0 +1,45 @@
"""seed default prompts for all existing users
Revision ID: 114
Revises: 113
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "114"
down_revision: str | None = "113"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(
sa.text(
"""
INSERT INTO prompts
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
SELECT u.id, d.slug, d.name, d.prompt, d.mode::prompt_mode, 1, false, now()
FROM "user" u
CROSS JOIN (VALUES
('fix-grammar', 'Fix grammar', 'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}', 'transform'),
('make-shorter', 'Make shorter', 'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}', 'transform'),
('translate', 'Translate', 'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}', 'transform'),
('rewrite', 'Rewrite', 'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}', 'transform'),
('summarize', 'Summarize', 'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}', 'transform'),
('explain', 'Explain', 'Explain the following text in simple terms:\n\n{selection}', 'explore'),
('ask-knowledge-base','Ask my knowledge base', 'Search my knowledge base for information related to:\n\n{selection}', 'explore'),
('look-up-web', 'Look up on the web', 'Search the web for information about:\n\n{selection}', 'explore')
) AS d(slug, name, prompt, mode)
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
"""
)
)
def downgrade() -> None:
op.execute("DELETE FROM prompts WHERE default_prompt_slug IS NOT NULL")

View file

@ -1778,12 +1778,19 @@ class SearchSpaceInvite(BaseModel, TimestampMixin):
class PromptMode(StrEnum):
TRANSFORM = "transform"
EXPLORE = "explore"
transform = "transform"
explore = "explore"
class Prompt(BaseModel, TimestampMixin):
__tablename__ = "prompts"
__table_args__ = (
UniqueConstraint(
"user_id",
"default_prompt_slug",
name="uq_prompt_user_default_slug",
),
)
user_id = Column(
UUID(as_uuid=True),
@ -1797,10 +1804,15 @@ class Prompt(BaseModel, TimestampMixin):
nullable=True,
index=True,
)
default_prompt_slug = Column(String(100), nullable=True, index=True)
name = Column(String(200), nullable=False)
prompt = Column(Text, nullable=False)
mode = Column(SQLAlchemyEnum(PromptMode), nullable=False)
icon = Column(String(50), nullable=True)
mode = Column(
SQLAlchemyEnum(PromptMode, name="prompt_mode", create_type=False),
nullable=False,
)
version = Column(Integer, nullable=False, default=1)
is_public = Column(Boolean, nullable=False, default=False)
user = relationship("User")
search_space = relationship("SearchSpace")

View file

@ -0,0 +1,74 @@
SYSTEM_PROMPT_DEFAULTS: list[dict] = [
{
"slug": "fix-grammar",
"version": 1,
"name": "Fix grammar",
"prompt": (
"Fix the grammar and spelling in the following text."
" Return only the corrected text, nothing else.\n\n{selection}"
),
"mode": "transform",
},
{
"slug": "make-shorter",
"version": 1,
"name": "Make shorter",
"prompt": (
"Make the following text more concise while preserving its meaning."
" Return only the shortened text, nothing else.\n\n{selection}"
),
"mode": "transform",
},
{
"slug": "translate",
"version": 1,
"name": "Translate",
"prompt": (
"Translate the following text to English."
" If it is already in English, translate it to French."
" Return only the translation, nothing else.\n\n{selection}"
),
"mode": "transform",
},
{
"slug": "rewrite",
"version": 1,
"name": "Rewrite",
"prompt": (
"Rewrite the following text to improve clarity and readability."
" Return only the rewritten text, nothing else.\n\n{selection}"
),
"mode": "transform",
},
{
"slug": "summarize",
"version": 1,
"name": "Summarize",
"prompt": (
"Summarize the following text concisely."
" Return only the summary, nothing else.\n\n{selection}"
),
"mode": "transform",
},
{
"slug": "explain",
"version": 1,
"name": "Explain",
"prompt": "Explain the following text in simple terms:\n\n{selection}",
"mode": "explore",
},
{
"slug": "ask-knowledge-base",
"version": 1,
"name": "Ask my knowledge base",
"prompt": "Search my knowledge base for information related to:\n\n{selection}",
"mode": "explore",
},
{
"slug": "look-up-web",
"version": 1,
"name": "Look up on the web",
"prompt": "Search the web for information about:\n\n{selection}",
"mode": "explore",
},
]

View file

@ -1,12 +1,14 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db import Prompt, User, get_async_session
from app.db import Prompt, SearchSpaceMembership, User, get_async_session
from app.schemas.prompts import (
PromptCreate,
PromptRead,
PromptUpdate,
PublicPromptRead,
)
from app.users import current_active_user
@ -33,13 +35,26 @@ async def create_prompt(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
if body.search_space_id is not None:
membership = await session.execute(
select(SearchSpaceMembership).where(
SearchSpaceMembership.user_id == user.id,
SearchSpaceMembership.search_space_id == body.search_space_id,
)
)
if not membership.scalar_one_or_none():
raise HTTPException(
status_code=403,
detail="You are not a member of this search space",
)
prompt = Prompt(
user_id=user.id,
search_space_id=body.search_space_id,
name=body.name,
prompt=body.prompt,
mode=body.mode,
icon=body.icon,
is_public=body.is_public,
)
session.add(prompt)
await session.commit()
@ -64,9 +79,16 @@ async def update_prompt(
if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found")
for field, value in body.model_dump(exclude_unset=True).items():
updates = body.model_dump(exclude_unset=True)
content_fields = {"name", "prompt", "mode"}
has_content_change = bool(updates.keys() & content_fields)
for field, value in updates.items():
setattr(prompt, field, value)
if has_content_change:
prompt.version = Prompt.version + 1
session.add(prompt)
await session.commit()
await session.refresh(prompt)
@ -92,3 +114,53 @@ async def delete_prompt(
await session.delete(prompt)
await session.commit()
return {"success": True}
@router.get("/prompts/public", response_model=list[PublicPromptRead])
async def list_public_prompts(
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
result = await session.execute(
select(Prompt)
.options(selectinload(Prompt.user))
.where(Prompt.is_public.is_(True), Prompt.user_id != user.id)
.order_by(Prompt.created_at.desc())
)
prompts = result.scalars().all()
return [
PublicPromptRead(
**PromptRead.model_validate(p).model_dump(),
author_name=p.user.email if p.user else None,
)
for p in prompts
]
@router.post("/prompts/{prompt_id}/copy", response_model=PromptRead)
async def copy_public_prompt(
prompt_id: int,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
result = await session.execute(
select(Prompt).where(
Prompt.id == prompt_id,
Prompt.is_public.is_(True),
)
)
source = result.scalar_one_or_none()
if not source:
raise HTTPException(status_code=404, detail="Prompt not found")
copy = Prompt(
user_id=user.id,
name=source.name,
prompt=source.prompt,
mode=source.mode,
is_public=False,
)
session.add(copy)
await session.commit()
await session.refresh(copy)
return copy

View file

@ -7,15 +7,15 @@ class PromptCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
prompt: str = Field(..., min_length=1)
mode: str = Field(..., pattern="^(transform|explore)$")
icon: str | None = Field(None, max_length=50)
search_space_id: int | None = None
is_public: bool = False
class PromptUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200)
prompt: str | None = Field(None, min_length=1)
mode: str | None = Field(None, pattern="^(transform|explore)$")
icon: str | None = Field(None, max_length=50)
is_public: bool | None = None
class PromptRead(BaseModel):
@ -23,9 +23,14 @@ class PromptRead(BaseModel):
name: str
prompt: str
mode: str
icon: str | None
search_space_id: int | None
is_public: bool
version: int
created_at: datetime
class Config:
from_attributes = True
class PublicPromptRead(PromptRead):
author_name: str | None = None

View file

@ -17,6 +17,7 @@ from sqlalchemy import update
from app.config import config
from app.db import (
Prompt,
SearchSpace,
SearchSpaceMembership,
SearchSpaceRole,
@ -25,6 +26,7 @@ from app.db import (
get_default_roles_config,
get_user_db,
)
from app.prompts.system_defaults import SYSTEM_PROMPT_DEFAULTS
from app.utils.refresh_tokens import create_refresh_token
logger = logging.getLogger(__name__)
@ -188,6 +190,18 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
)
session.add(owner_membership)
for default in SYSTEM_PROMPT_DEFAULTS:
session.add(
Prompt(
user_id=user.id,
default_prompt_slug=default["slug"],
name=default["name"],
prompt=default["prompt"],
mode=default["mode"],
version=default["version"],
)
)
await session.commit()
logger.info(
f"Created default search space (ID: {default_search_space.id}) for user {user.id}"

View file

@ -1,6 +1,9 @@
"use client";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { getBearerToken } from "@/lib/auth-utils";
import { HeroSection } from "@/components/homepage/hero-section";
const FeaturesCards = dynamic(
@ -26,6 +29,14 @@ const CTAHomepage = dynamic(
);
export default function HomePage() {
const router = useRouter();
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
}
}, [router]);
return (
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white">
<HeroSection />

View file

@ -11,6 +11,7 @@ import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { getBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error";
import {
@ -38,6 +39,10 @@ export default function RegisterPage() {
// Check authentication type and redirect if not LOCAL
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
return;
}
if (AUTH_TYPE !== "LOCAL") {
router.push("/login");
}

View file

@ -0,0 +1,128 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertTriangle, Copy, Globe, Sparkles } from "lucide-react";
import { useCallback, useState } from "react";
import { copyPromptMutationAtom } from "@/atoms/prompts/prompts-mutation.atoms";
import { publicPromptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
export function CommunityPromptsContent() {
const { data: prompts, isLoading, isError } = useAtomValue(publicPromptsAtom);
const { mutateAsync: copyPrompt } = useAtomValue(copyPromptMutationAtom);
const [copyingIds, setCopyingIds] = useState<Set<number>>(new Set());
const [expandedId, setExpandedId] = useState<number | null>(null);
const handleCopy = useCallback(
async (id: number) => {
setCopyingIds((prev) => new Set(prev).add(id));
try {
await copyPrompt(id);
} catch {
// toast handled by mutation atom
} finally {
setCopyingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}
},
[copyPrompt]
);
const list = prompts ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Spinner className="size-6" />
</div>
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load community prompts</p>
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
</div>
);
}
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<p className="text-sm text-muted-foreground">
Prompts shared by other users. Add any to your collection with one click.
</p>
{list.length === 0 && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Globe className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No community prompts yet</p>
<p className="text-xs text-muted-foreground/60">
Share your own prompts from the My Prompts tab
</p>
</div>
)}
{list.length > 0 && (
<div className="space-y-2">
{list.map((prompt) => (
<div
key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
>
<div className="mt-0.5 shrink-0 text-muted-foreground">
<Sparkles className="size-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{prompt.name}</span>
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{prompt.mode}
</span>
{prompt.author_name && (
<span className="text-[11px] text-muted-foreground/60">
by {prompt.author_name}
</span>
)}
</div>
<p
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
>
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<button
type="button"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
>
{expandedId === prompt.id ? "See less" : "See more"}
</button>
)}
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 gap-1.5"
disabled={copyingIds.has(prompt.id)}
onClick={() => handleCopy(prompt.id)}
>
{copyingIds.has(prompt.id) ? (
<Spinner className="size-3" />
) : (
<Copy className="size-3" />
)}
Add to mine
</Button>
</div>
))}
</div>
)}
</div>
);
}

View file

@ -1,38 +1,54 @@
"use client";
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useAtomValue } from "jotai";
import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
createPromptMutationAtom,
deletePromptMutationAtom,
updatePromptMutationAtom,
} from "@/atoms/prompts/prompts-mutation.atoms";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData {
name: string;
prompt: string;
mode: "transform" | "explore";
is_public: boolean;
}
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform" };
const EMPTY_FORM: PromptFormData = { name: "", prompt: "", mode: "transform", is_public: false };
export function PromptsContent() {
const [prompts, setPrompts] = useState<PromptRead[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => {
promptsApiService
.list()
.then(setPrompts)
.catch(() => toast.error("Failed to load prompts"))
.finally(() => setIsLoading(false));
}, []);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
const [togglingPublicIds, setTogglingPublicIds] = useState<Set<number>>(new Set());
const handleSave = useCallback(async () => {
if (!formData.name.trim() || !formData.prompt.trim()) {
@ -42,44 +58,61 @@ export function PromptsContent() {
setIsSaving(true);
try {
if (editingId) {
const updated = await promptsApiService.update(editingId, formData);
setPrompts((prev) => prev.map((p) => (p.id === editingId ? updated : p)));
toast.success("Prompt updated");
if (editingId !== null) {
await updatePrompt({ id: editingId, ...formData });
} else {
const created = await promptsApiService.create(formData);
setPrompts((prev) => [created, ...prev]);
toast.success("Prompt created");
await createPrompt(formData);
}
setShowForm(false);
setFormData(EMPTY_FORM);
setEditingId(null);
} catch {
toast.error("Failed to save prompt");
// toast handled by mutation atoms
} finally {
setIsSaving(false);
}
}, [formData, editingId]);
}, [formData, editingId, createPrompt, updatePrompt]);
const handleEdit = useCallback((prompt: PromptRead) => {
setFormData({
name: prompt.name,
prompt: prompt.prompt,
mode: prompt.mode as "transform" | "explore",
is_public: prompt.is_public,
});
setEditingId(prompt.id);
setShowForm(true);
}, []);
const handleDelete = useCallback(async (id: number) => {
const handleConfirmDelete = useCallback(async () => {
if (deleteTarget === null) return;
try {
await promptsApiService.delete(id);
setPrompts((prev) => prev.filter((p) => p.id !== id));
toast.success("Prompt deleted");
await deletePrompt(deleteTarget);
} catch {
toast.error("Failed to delete prompt");
// toast handled by mutation atom
} finally {
setDeleteTarget(null);
}
}, []);
}, [deleteTarget, deletePrompt]);
const handleTogglePublic = useCallback(
async (prompt: PromptRead) => {
if (togglingPublicIds.has(prompt.id)) return;
setTogglingPublicIds((prev) => new Set(prev).add(prompt.id));
try {
await updatePrompt({ id: prompt.id, is_public: !prompt.is_public });
} catch {
// toast handled by mutation atom
} finally {
setTogglingPublicIds((prev) => {
const next = new Set(prev);
next.delete(prompt.id);
return next;
});
}
},
[updatePrompt, togglingPublicIds]
);
const handleCancel = useCallback(() => {
setShowForm(false);
@ -87,6 +120,8 @@ export function PromptsContent() {
setEditingId(null);
}, []);
const list = prompts ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
@ -95,6 +130,16 @@ export function PromptsContent() {
);
}
if (isError) {
return (
<div className="rounded-lg border border-dashed border-destructive/40 p-8 text-center">
<AlertTriangle className="mx-auto size-8 text-destructive/60" />
<p className="mt-2 text-sm text-destructive">Failed to load prompts</p>
<p className="text-xs text-muted-foreground">Please try refreshing the page.</p>
</div>
);
}
return (
<div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between">
@ -122,7 +167,7 @@ export function PromptsContent() {
{showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
<h3 className="text-sm font-semibold tracking-tight">
{editingId ? "Edit prompt" : "New prompt"}
{editingId !== null ? "Edit prompt" : "New prompt"}
</h3>
<div className="space-y-2">
@ -169,31 +214,42 @@ export function PromptsContent() {
</select>
</div>
<div className="flex items-center gap-2">
<Switch
id="prompt-public"
checked={formData.is_public}
onCheckedChange={(checked) => setFormData((p) => ({ ...p, is_public: checked }))}
/>
<Label htmlFor="prompt-public" className="text-sm font-normal">
Share with community
</Label>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative">
<span className={isSaving ? "opacity-0" : ""}>{editingId ? "Update" : "Create"}</span>
<span className={isSaving ? "opacity-0" : ""}>{editingId !== null ? "Update" : "Create"}</span>
{isSaving && <Spinner className="size-3.5 absolute" />}
</Button>
</div>
</div>
)}
{prompts.length === 0 && !showForm && (
{list.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Sparkles className="mx-auto size-8 text-muted-foreground/40" />
<p className="mt-2 text-sm text-muted-foreground">No custom prompts yet</p>
<p className="mt-2 text-sm text-muted-foreground">No prompts yet</p>
<p className="text-xs text-muted-foreground/60">
Create prompts to quickly transform or explore text with /
</p>
</div>
)}
{prompts.length > 0 && (
{list.length > 0 && (
<div className="space-y-2">
{prompts.map((prompt) => (
{list.map((prompt) => (
<div
key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4"
@ -207,10 +263,44 @@ export function PromptsContent() {
<span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{prompt.mode}
</span>
{prompt.is_public && (
<span className="flex items-center gap-1 rounded-full border border-primary/20 bg-primary/5 px-2 py-0.5 text-[10px] text-primary">
<Globe className="size-2.5" />
Public
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{prompt.prompt}</p>
<p
className={`mt-1 text-xs text-muted-foreground ${expandedId === prompt.id ? "whitespace-pre-wrap" : "line-clamp-2"}`}
>
{prompt.prompt}
</p>
{prompt.prompt.length > 100 && (
<button
type="button"
onClick={() => setExpandedId(expandedId === prompt.id ? null : prompt.id)}
className="mt-1 text-[11px] text-primary hover:underline cursor-pointer"
>
{expandedId === prompt.id ? "See less" : "See more"}
</button>
)}
</div>
<div className="hidden group-hover:flex items-center gap-1 shrink-0">
<button
type="button"
title={prompt.is_public ? "Make private" : "Share with community"}
onClick={() => handleTogglePublic(prompt)}
disabled={togglingPublicIds.has(prompt.id)}
className="flex items-center justify-center size-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors disabled:opacity-50 disabled:pointer-events-none"
>
{togglingPublicIds.has(prompt.id) ? (
<Spinner className="size-3.5" />
) : prompt.is_public ? (
<Lock className="size-3.5" />
) : (
<Globe className="size-3.5" />
)}
</button>
<Button
variant="ghost"
size="icon"
@ -223,7 +313,7 @@ export function PromptsContent() {
variant="ghost"
size="icon"
className="size-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(prompt.id)}
onClick={() => setDeleteTarget(prompt.id)}
>
<Trash2 className="size-3.5" />
</Button>
@ -232,6 +322,21 @@ export function PromptsContent() {
))}
</div>
)}
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete prompt</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. The prompt will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -0,0 +1,70 @@
import { atomWithMutation } from "jotai-tanstack-query";
import { toast } from "sonner";
import type {
PromptCreateRequest,
PromptRead,
PromptUpdateRequest,
} from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { queryClient } from "@/lib/query-client/client";
export const createPromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "create"],
mutationFn: async (request: PromptCreateRequest) => {
return promptsApiService.create(request);
},
onSuccess: () => {
toast.success("Prompt created");
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create prompt");
},
}));
export const updatePromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "update"],
mutationFn: async ({ id, ...data }: PromptUpdateRequest & { id: number }) => {
return promptsApiService.update(id, data);
},
onSuccess: () => {
toast.success("Prompt updated");
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update prompt");
},
}));
export const deletePromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "delete"],
mutationFn: async (id: number) => {
return promptsApiService.delete(id);
},
onSuccess: (_: unknown, id: number) => {
toast.success("Prompt deleted");
queryClient.setQueryData(cacheKeys.prompts.all(), (old: PromptRead[] | undefined) => {
if (!old) return old;
return old.filter((p) => p.id !== id);
});
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.public() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete prompt");
},
}));
export const copyPromptMutationAtom = atomWithMutation(() => ({
mutationKey: ["prompts", "copy"],
mutationFn: async (promptId: number) => {
return promptsApiService.copy(promptId);
},
onSuccess: () => {
toast.success("Prompt added to your collection");
queryClient.invalidateQueries({ queryKey: cacheKeys.prompts.all() });
},
onError: (error: Error) => {
toast.error(error.message || "Failed to copy prompt");
},
}));

View file

@ -0,0 +1,23 @@
import { atomWithQuery } from "jotai-tanstack-query";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
export const promptsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.prompts.all(),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
return promptsApiService.list();
},
};
});
export const publicPromptsAtom = atomWithQuery(() => {
return {
queryKey: cacheKeys.prompts.public(),
staleTime: 2 * 60 * 1000,
queryFn: async () => {
return promptsApiService.listPublic();
},
};
});

View file

@ -21,6 +21,7 @@ import type { DocumentNodeDoc } from "@/components/documents/DocumentNode";
import type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import {
AlertDialog,

View file

@ -1,18 +1,7 @@
"use client";
import { useSetAtom } from "jotai";
import {
BookOpen,
Check,
Globe,
Languages,
List,
Minimize2,
PenLine,
Plus,
Search,
Zap,
} from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { Plus, Zap } from "lucide-react";
import {
forwardRef,
useCallback,
@ -23,9 +12,9 @@ import {
useState,
} from "react";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { Spinner } from "@/components/ui/spinner";
import { cn } from "@/lib/utils";
export interface PromptPickerRef {
@ -41,113 +30,23 @@ interface PromptPickerProps {
containerStyle?: React.CSSProperties;
}
const ICONS: Record<string, React.ReactNode> = {
check: <Check className="size-3.5" />,
minimize: <Minimize2 className="size-3.5" />,
languages: <Languages className="size-3.5" />,
"pen-line": <PenLine className="size-3.5" />,
"book-open": <BookOpen className="size-3.5" />,
list: <List className="size-3.5" />,
search: <Search className="size-3.5" />,
globe: <Globe className="size-3.5" />,
zap: <Zap className="size-3.5" />,
};
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<PromptPickerRef, PromptPickerProps>(function PromptPicker(
{ onSelect, onDone, externalSearch = "", containerStyle },
ref
) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
useEffect(() => {
promptsApiService
.list()
.then(setCustomPrompts)
.catch(() => {});
}, []);
const allActions = useMemo(() => {
const customs = customPrompts.map((a) => ({
name: a.name,
prompt: a.prompt,
mode: a.mode as "transform" | "explore",
icon: a.icon || "zap",
}));
return [...DEFAULT_ACTIONS, ...customs];
}, [customPrompts]);
const filtered = useMemo(() => {
if (!externalSearch) return allActions;
return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
}, [allActions, externalSearch]);
const list = prompts ?? [];
if (!externalSearch) return list;
return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
}, [prompts, externalSearch]);
// Reset highlight when results change
const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch;
@ -165,7 +64,6 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
[filtered, onSelect]
);
// Auto-scroll highlighted item into view
useEffect(() => {
if (!shouldScrollRef.current) return;
shouldScrollRef.current = false;
@ -190,10 +88,12 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
},
moveDown: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
},
@ -201,46 +101,24 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
[filtered.length, highlightedIndex, handleSelect]
);
if (filtered.length === 0) return null;
const defaultFiltered = filtered.filter((_, i) => i < DEFAULT_ACTIONS.length);
const customFiltered = filtered.filter((_, i) => i >= DEFAULT_ACTIONS.length);
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{defaultFiltered.map((action, index) => (
<button
key={action.name}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">
{ICONS[action.icon] ?? <Zap className="size-3.5" />}
</span>
<span className="truncate">{action.name}</span>
</button>
))}
{customFiltered.length > 0 && <div className="my-1 h-px bg-border mx-2" />}
{customFiltered.map((action, i) => {
const index = defaultFiltered.length + i;
return (
{isLoading ? (
<div className="flex items-center justify-center py-3">
<Spinner className="size-4" />
</div>
) : isError ? (
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
) : filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
) : (
filtered.map((action, index) => (
<button
key={action.name}
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
@ -258,8 +136,8 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
</span>
<span className="truncate">{action.name}</span>
</button>
);
})}
))
)}
<div className="my-1 h-px bg-border mx-2" />
<button

View file

@ -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: <Sparkles className="h-4 w-4" />,
},
{
value: "community-prompts",
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
];
return (
@ -40,6 +46,7 @@ export function UserSettingsDialog() {
{state.initialTab === "profile" && <ProfileContent />}
{state.initialTab === "api-key" && <ApiKeyContent />}
{state.initialTab === "prompts" && <PromptsContent />}
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
</div>
</SettingsDialog>
);

View file

@ -7,21 +7,30 @@ export const promptRead = z.object({
name: z.string(),
prompt: z.string(),
mode: z.enum(["transform", "explore"]),
icon: z.string().nullable(),
search_space_id: z.number().nullable(),
is_public: z.boolean(),
version: z.number(),
created_at: z.string(),
});
export type PromptRead = z.infer<typeof promptRead>;
export const publicPromptRead = promptRead.extend({
author_name: z.string().nullable(),
});
export type PublicPromptRead = z.infer<typeof publicPromptRead>;
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<typeof promptCreateRequest>;
@ -30,7 +39,7 @@ export const promptUpdateRequest = z.object({
name: z.string().min(1).max(200).optional(),
prompt: z.string().min(1).optional(),
mode: z.enum(["transform", "explore"]).optional(),
icon: z.string().max(50).nullable().optional(),
is_public: z.boolean().optional(),
});
export type PromptUpdateRequest = z.infer<typeof promptUpdateRequest>;

View file

@ -6,6 +6,7 @@ import {
promptRead,
promptsListResponse,
promptUpdateRequest,
publicPromptsListResponse,
} from "@/contracts/types/prompts.types";
import { ValidationError } from "@/lib/error";
import { baseApiService } from "./base-api.service";
@ -49,6 +50,14 @@ class PromptsApiService {
delete = async (promptId: number) => {
return baseApiService.delete(`/api/v1/prompts/${promptId}`, promptDeleteResponse);
};
listPublic = async () => {
return baseApiService.get("/api/v1/prompts/public", publicPromptsListResponse);
};
copy = async (promptId: number) => {
return baseApiService.post(`/api/v1/prompts/${promptId}/copy`, promptRead, {});
};
}
export const promptsApiService = new PromptsApiService();

View file

@ -91,6 +91,10 @@ export const cacheKeys = {
bySearchSpace: (searchSpaceId: number) =>
["public-chat-snapshots", "search-space", searchSpaceId] as const,
},
prompts: {
all: () => ["prompts"] as const,
public: () => ["prompts", "public"] as const,
},
notifications: {
search: (searchSpaceId: number | null, search: string, tab: string) =>
["notifications", "search", searchSpaceId, search, tab] as const,