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): class PromptMode(StrEnum):
TRANSFORM = "transform" transform = "transform"
EXPLORE = "explore" explore = "explore"
class Prompt(BaseModel, TimestampMixin): class Prompt(BaseModel, TimestampMixin):
__tablename__ = "prompts" __tablename__ = "prompts"
__table_args__ = (
UniqueConstraint(
"user_id",
"default_prompt_slug",
name="uq_prompt_user_default_slug",
),
)
user_id = Column( user_id = Column(
UUID(as_uuid=True), UUID(as_uuid=True),
@ -1797,10 +1804,15 @@ class Prompt(BaseModel, TimestampMixin):
nullable=True, nullable=True,
index=True, index=True,
) )
default_prompt_slug = Column(String(100), nullable=True, index=True)
name = Column(String(200), nullable=False) name = Column(String(200), nullable=False)
prompt = Column(Text, nullable=False) prompt = Column(Text, nullable=False)
mode = Column(SQLAlchemyEnum(PromptMode), nullable=False) mode = Column(
icon = Column(String(50), nullable=True) 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") user = relationship("User")
search_space = relationship("SearchSpace") 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 fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession 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 ( from app.schemas.prompts import (
PromptCreate, PromptCreate,
PromptRead, PromptRead,
PromptUpdate, PromptUpdate,
PublicPromptRead,
) )
from app.users import current_active_user from app.users import current_active_user
@ -33,13 +35,26 @@ async def create_prompt(
session: AsyncSession = Depends(get_async_session), session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user), 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( prompt = Prompt(
user_id=user.id, user_id=user.id,
search_space_id=body.search_space_id, search_space_id=body.search_space_id,
name=body.name, name=body.name,
prompt=body.prompt, prompt=body.prompt,
mode=body.mode, mode=body.mode,
icon=body.icon, is_public=body.is_public,
) )
session.add(prompt) session.add(prompt)
await session.commit() await session.commit()
@ -64,9 +79,16 @@ async def update_prompt(
if not prompt: if not prompt:
raise HTTPException(status_code=404, detail="Prompt not found") 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) setattr(prompt, field, value)
if has_content_change:
prompt.version = Prompt.version + 1
session.add(prompt) session.add(prompt)
await session.commit() await session.commit()
await session.refresh(prompt) await session.refresh(prompt)
@ -92,3 +114,53 @@ async def delete_prompt(
await session.delete(prompt) await session.delete(prompt)
await session.commit() await session.commit()
return {"success": True} 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) name: str = Field(..., min_length=1, max_length=200)
prompt: str = Field(..., min_length=1) prompt: str = Field(..., min_length=1)
mode: str = Field(..., pattern="^(transform|explore)$") mode: str = Field(..., pattern="^(transform|explore)$")
icon: str | None = Field(None, max_length=50)
search_space_id: int | None = None search_space_id: int | None = None
is_public: bool = False
class PromptUpdate(BaseModel): class PromptUpdate(BaseModel):
name: str | None = Field(None, min_length=1, max_length=200) name: str | None = Field(None, min_length=1, max_length=200)
prompt: str | None = Field(None, min_length=1) prompt: str | None = Field(None, min_length=1)
mode: str | None = Field(None, pattern="^(transform|explore)$") mode: str | None = Field(None, pattern="^(transform|explore)$")
icon: str | None = Field(None, max_length=50) is_public: bool | None = None
class PromptRead(BaseModel): class PromptRead(BaseModel):
@ -23,9 +23,14 @@ class PromptRead(BaseModel):
name: str name: str
prompt: str prompt: str
mode: str mode: str
icon: str | None
search_space_id: int | None search_space_id: int | None
is_public: bool
version: int
created_at: datetime created_at: datetime
class Config: class Config:
from_attributes = True 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.config import config
from app.db import ( from app.db import (
Prompt,
SearchSpace, SearchSpace,
SearchSpaceMembership, SearchSpaceMembership,
SearchSpaceRole, SearchSpaceRole,
@ -25,6 +26,7 @@ from app.db import (
get_default_roles_config, get_default_roles_config,
get_user_db, get_user_db,
) )
from app.prompts.system_defaults import SYSTEM_PROMPT_DEFAULTS
from app.utils.refresh_tokens import create_refresh_token from app.utils.refresh_tokens import create_refresh_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -188,6 +190,18 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
) )
session.add(owner_membership) 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() await session.commit()
logger.info( logger.info(
f"Created default search space (ID: {default_search_space.id}) for user {user.id}" f"Created default search space (ID: {default_search_space.id}) for user {user.id}"

View file

@ -1,6 +1,9 @@
"use client"; "use client";
import dynamic from "next/dynamic"; 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"; import { HeroSection } from "@/components/homepage/hero-section";
const FeaturesCards = dynamic( const FeaturesCards = dynamic(
@ -26,6 +29,14 @@ const CTAHomepage = dynamic(
); );
export default function HomePage() { export default function HomePage() {
const router = useRouter();
useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
}
}, [router]);
return ( 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"> <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 /> <HeroSection />

View file

@ -11,6 +11,7 @@ import { registerMutationAtom } from "@/atoms/auth/auth-mutation.atoms";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors"; import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
import { getBearerToken } from "@/lib/auth-utils";
import { AUTH_TYPE } from "@/lib/env-config"; import { AUTH_TYPE } from "@/lib/env-config";
import { AppError, ValidationError } from "@/lib/error"; import { AppError, ValidationError } from "@/lib/error";
import { import {
@ -38,6 +39,10 @@ export default function RegisterPage() {
// Check authentication type and redirect if not LOCAL // Check authentication type and redirect if not LOCAL
useEffect(() => { useEffect(() => {
if (getBearerToken()) {
router.replace("/dashboard");
return;
}
if (AUTH_TYPE !== "LOCAL") { if (AUTH_TYPE !== "LOCAL") {
router.push("/login"); 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"; "use client";
import { PenLine, Plus, Sparkles, Trash2 } from "lucide-react"; import { useAtomValue } from "jotai";
import { useCallback, useEffect, useState } from "react"; import { AlertTriangle, Globe, Lock, PenLine, Plus, Sparkles, Trash2 } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "sonner"; 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 { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import type { PromptRead } from "@/contracts/types/prompts.types"; import type { PromptRead } from "@/contracts/types/prompts.types";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
interface PromptFormData { interface PromptFormData {
name: string; name: string;
prompt: string; prompt: string;
mode: "transform" | "explore"; 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() { export function PromptsContent() {
const [prompts, setPrompts] = useState<PromptRead[]>([]); const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
const [isLoading, setIsLoading] = useState(true); const { mutateAsync: createPrompt } = useAtomValue(createPromptMutationAtom);
const { mutateAsync: updatePrompt } = useAtomValue(updatePromptMutationAtom);
const { mutateAsync: deletePrompt } = useAtomValue(deletePromptMutationAtom);
const [showForm, setShowForm] = useState(false); const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM); const [formData, setFormData] = useState<PromptFormData>(EMPTY_FORM);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
useEffect(() => { const [deleteTarget, setDeleteTarget] = useState<number | null>(null);
promptsApiService const [togglingPublicIds, setTogglingPublicIds] = useState<Set<number>>(new Set());
.list()
.then(setPrompts)
.catch(() => toast.error("Failed to load prompts"))
.finally(() => setIsLoading(false));
}, []);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!formData.name.trim() || !formData.prompt.trim()) { if (!formData.name.trim() || !formData.prompt.trim()) {
@ -42,44 +58,61 @@ export function PromptsContent() {
setIsSaving(true); setIsSaving(true);
try { try {
if (editingId) { if (editingId !== null) {
const updated = await promptsApiService.update(editingId, formData); await updatePrompt({ id: editingId, ...formData });
setPrompts((prev) => prev.map((p) => (p.id === editingId ? updated : p)));
toast.success("Prompt updated");
} else { } else {
const created = await promptsApiService.create(formData); await createPrompt(formData);
setPrompts((prev) => [created, ...prev]);
toast.success("Prompt created");
} }
setShowForm(false); setShowForm(false);
setFormData(EMPTY_FORM); setFormData(EMPTY_FORM);
setEditingId(null); setEditingId(null);
} catch { } catch {
toast.error("Failed to save prompt"); // toast handled by mutation atoms
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [formData, editingId]); }, [formData, editingId, createPrompt, updatePrompt]);
const handleEdit = useCallback((prompt: PromptRead) => { const handleEdit = useCallback((prompt: PromptRead) => {
setFormData({ setFormData({
name: prompt.name, name: prompt.name,
prompt: prompt.prompt, prompt: prompt.prompt,
mode: prompt.mode as "transform" | "explore", mode: prompt.mode as "transform" | "explore",
is_public: prompt.is_public,
}); });
setEditingId(prompt.id); setEditingId(prompt.id);
setShowForm(true); setShowForm(true);
}, []); }, []);
const handleDelete = useCallback(async (id: number) => { const handleConfirmDelete = useCallback(async () => {
if (deleteTarget === null) return;
try { try {
await promptsApiService.delete(id); await deletePrompt(deleteTarget);
setPrompts((prev) => prev.filter((p) => p.id !== id));
toast.success("Prompt deleted");
} catch { } 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(() => { const handleCancel = useCallback(() => {
setShowForm(false); setShowForm(false);
@ -87,6 +120,8 @@ export function PromptsContent() {
setEditingId(null); setEditingId(null);
}, []); }, []);
const list = prompts ?? [];
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center py-12"> <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 ( return (
<div className="space-y-6 min-w-0 overflow-hidden"> <div className="space-y-6 min-w-0 overflow-hidden">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -122,7 +167,7 @@ export function PromptsContent() {
{showForm && ( {showForm && (
<div className="rounded-lg border border-border/60 bg-card p-6 space-y-4"> <div className="rounded-lg border border-border/60 bg-card p-6 space-y-4">
<h3 className="text-sm font-semibold tracking-tight"> <h3 className="text-sm font-semibold tracking-tight">
{editingId ? "Edit prompt" : "New prompt"} {editingId !== null ? "Edit prompt" : "New prompt"}
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
@ -169,31 +214,42 @@ export function PromptsContent() {
</select> </select>
</div> </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"> <div className="flex items-center justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={handleCancel}> <Button variant="ghost" size="sm" onClick={handleCancel}>
Cancel Cancel
</Button> </Button>
<Button size="sm" onClick={handleSave} disabled={isSaving} className="relative"> <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" />} {isSaving && <Spinner className="size-3.5 absolute" />}
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{prompts.length === 0 && !showForm && ( {list.length === 0 && !showForm && (
<div className="rounded-lg border border-dashed border-border/60 p-8 text-center"> <div className="rounded-lg border border-dashed border-border/60 p-8 text-center">
<Sparkles className="mx-auto size-8 text-muted-foreground/40" /> <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"> <p className="text-xs text-muted-foreground/60">
Create prompts to quickly transform or explore text with / Create prompts to quickly transform or explore text with /
</p> </p>
</div> </div>
)} )}
{prompts.length > 0 && ( {list.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{prompts.map((prompt) => ( {list.map((prompt) => (
<div <div
key={prompt.id} key={prompt.id}
className="group flex items-start gap-3 rounded-lg border border-border/60 bg-card p-4" 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"> <span className="rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{prompt.mode} {prompt.mode}
</span> </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> </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>
<div className="hidden group-hover:flex items-center gap-1 shrink-0"> <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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -223,7 +313,7 @@ export function PromptsContent() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-7 text-destructive hover:text-destructive" className="size-7 text-destructive hover:text-destructive"
onClick={() => handleDelete(prompt.id)} onClick={() => setDeleteTarget(prompt.id)}
> >
<Trash2 className="size-3.5" /> <Trash2 className="size-3.5" />
</Button> </Button>
@ -232,6 +322,21 @@ export function PromptsContent() {
))} ))}
</div> </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> </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 type { FolderDisplay } from "@/components/documents/FolderNode";
import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog"; import { FolderPickerDialog } from "@/components/documents/FolderPickerDialog";
import { FolderTreeView } from "@/components/documents/FolderTreeView"; import { FolderTreeView } from "@/components/documents/FolderTreeView";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems"; import { EXPORT_FILE_EXTENSIONS } from "@/components/shared/ExportMenuItems";
import { import {
AlertDialog, AlertDialog,

View file

@ -1,18 +1,7 @@
"use client"; "use client";
import { useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { import { Plus, Zap } from "lucide-react";
BookOpen,
Check,
Globe,
Languages,
List,
Minimize2,
PenLine,
Plus,
Search,
Zap,
} from "lucide-react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@ -23,9 +12,9 @@ import {
useState, useState,
} from "react"; } from "react";
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import type { PromptRead } from "@/contracts/types/prompts.types"; import { Spinner } from "@/components/ui/spinner";
import { promptsApiService } from "@/lib/apis/prompts-api.service";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export interface PromptPickerRef { export interface PromptPickerRef {
@ -41,113 +30,23 @@ interface PromptPickerProps {
containerStyle?: React.CSSProperties; 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( export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
{ onSelect, onDone, externalSearch = "", containerStyle }, { onSelect, onDone, externalSearch = "", containerStyle },
ref ref
) { ) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom); const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
const { data: prompts, isLoading, isError } = useAtomValue(promptsAtom);
const [highlightedIndex, setHighlightedIndex] = useState(0); const [highlightedIndex, setHighlightedIndex] = useState(0);
const [customPrompts, setCustomPrompts] = useState<PromptRead[]>([]);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const shouldScrollRef = useRef(false); const shouldScrollRef = useRef(false);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map()); 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(() => { const filtered = useMemo(() => {
if (!externalSearch) return allActions; const list = prompts ?? [];
return allActions.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase())); if (!externalSearch) return list;
}, [allActions, externalSearch]); return list.filter((a) => a.name.toLowerCase().includes(externalSearch.toLowerCase()));
}, [prompts, externalSearch]);
// Reset highlight when results change
const prevSearchRef = useRef(externalSearch); const prevSearchRef = useRef(externalSearch);
if (prevSearchRef.current !== externalSearch) { if (prevSearchRef.current !== externalSearch) {
prevSearchRef.current = externalSearch; prevSearchRef.current = externalSearch;
@ -165,7 +64,6 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
[filtered, onSelect] [filtered, onSelect]
); );
// Auto-scroll highlighted item into view
useEffect(() => { useEffect(() => {
if (!shouldScrollRef.current) return; if (!shouldScrollRef.current) return;
shouldScrollRef.current = false; shouldScrollRef.current = false;
@ -190,10 +88,12 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
() => ({ () => ({
selectHighlighted: () => handleSelect(highlightedIndex), selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => { moveUp: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true; shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1)); setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
}, },
moveDown: () => { moveDown: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true; shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0)); setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
}, },
@ -201,46 +101,24 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
[filtered.length, highlightedIndex, handleSelect] [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 ( return (
<div <div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden" className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle} style={containerStyle}
> >
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1"> <div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
{defaultFiltered.map((action, index) => ( {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 <button
key={action.name} key={action.id}
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 (
<button
key={action.name}
ref={(el) => { ref={(el) => {
if (el) itemRefs.current.set(index, el); if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index); else itemRefs.current.delete(index);
@ -258,8 +136,8 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
</span> </span>
<span className="truncate">{action.name}</span> <span className="truncate">{action.name}</span>
</button> </button>
); ))
})} )}
<div className="my-1 h-px bg-border mx-2" /> <div className="my-1 h-px bg-border mx-2" />
<button <button

View file

@ -1,9 +1,10 @@
"use client"; "use client";
import { useAtom } from "jotai"; 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 { useTranslations } from "next-intl";
import { ApiKeyContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent"; 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 { ProfileContent } from "@/app/dashboard/[search_space_id]/user-settings/components/ProfileContent";
import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent"; import { PromptsContent } from "@/app/dashboard/[search_space_id]/user-settings/components/PromptsContent";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms"; import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
@ -25,6 +26,11 @@ export function UserSettingsDialog() {
label: "My Prompts", label: "My Prompts",
icon: <Sparkles className="h-4 w-4" />, icon: <Sparkles className="h-4 w-4" />,
}, },
{
value: "community-prompts",
label: "Community Prompts",
icon: <Globe className="h-4 w-4" />,
},
]; ];
return ( return (
@ -40,6 +46,7 @@ export function UserSettingsDialog() {
{state.initialTab === "profile" && <ProfileContent />} {state.initialTab === "profile" && <ProfileContent />}
{state.initialTab === "api-key" && <ApiKeyContent />} {state.initialTab === "api-key" && <ApiKeyContent />}
{state.initialTab === "prompts" && <PromptsContent />} {state.initialTab === "prompts" && <PromptsContent />}
{state.initialTab === "community-prompts" && <CommunityPromptsContent />}
</div> </div>
</SettingsDialog> </SettingsDialog>
); );

View file

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

View file

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

View file

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