mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-06 20:15:17 +02:00
Merge pull request #1064 from CREDO23/feature/prompt-library
[Feature] Add Prompt Library
This commit is contained in:
commit
b650320b68
19 changed files with 731 additions and 197 deletions
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
74
surfsense_backend/app/prompts/system_defaults.py
Normal file
74
surfsense_backend/app/prompts/system_defaults.py
Normal 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",
|
||||
},
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
70
surfsense_web/atoms/prompts/prompts-mutation.atoms.ts
Normal file
70
surfsense_web/atoms/prompts/prompts-mutation.atoms.ts
Normal 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");
|
||||
},
|
||||
}));
|
||||
23
surfsense_web/atoms/prompts/prompts-query.atoms.ts
Normal file
23
surfsense_web/atoms/prompts/prompts-query.atoms.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue