From 459bd468466d158bf71735b2700786d391fab3ed Mon Sep 17 00:00:00 2001 From: pablodanswer Date: Thu, 1 Aug 2024 08:40:35 -0700 Subject: [PATCH] Add Prompt library (#1990) --- .../e1392f05e840_added_input_prompts.py | 58 ++++ backend/danswer/auth/users.py | 1 + backend/danswer/chat/input_prompts.yaml | 24 ++ backend/danswer/chat/load_yamls.py | 25 ++ backend/danswer/configs/chat_configs.py | 1 + backend/danswer/db/input_prompt.py | 202 ++++++++++++++ backend/danswer/db/models.py | 27 ++ backend/danswer/main.py | 6 + .../server/features/input_prompt/__init__.py | 0 .../server/features/input_prompt/api.py | 134 +++++++++ .../server/features/input_prompt/models.py | 47 ++++ .../danswer/server/features/persona/api.py | 2 +- web/src/app/admin/prompt-library/hooks.ts | 46 ++++ .../app/admin/prompt-library/interfaces.ts | 31 +++ .../prompt-library/modals/AddPromptModal.tsx | 92 +++++++ .../prompt-library/modals/EditPromptModal.tsx | 138 ++++++++++ web/src/app/admin/prompt-library/page.tsx | 32 +++ .../admin/prompt-library/promptLibrary.tsx | 260 ++++++++++++++++++ .../admin/prompt-library/promptSection.tsx | 146 ++++++++++ web/src/app/admin/settings/page.tsx | 2 +- web/src/app/assistants/SidebarWrapper.tsx | 6 +- web/src/app/assistants/gallery/page.tsx | 2 + .../assistants/mine/WrappedInputPrompts.tsx | 64 +++++ web/src/app/assistants/mine/page.tsx | 2 + web/src/app/assistants/new/page.tsx | 1 + web/src/app/chat/ChatPage.tsx | 2 + web/src/app/chat/input/ChatInputBar.tsx | 179 +++++++++--- web/src/app/chat/modal/ModalWrapper.tsx | 10 + web/src/app/chat/page.tsx | 2 + .../chat/sessionSidebar/HistorySidebar.tsx | 12 + web/src/app/prompts/page.tsx | 40 +++ web/src/components/admin/ClientLayout.tsx | 11 + web/src/components/admin/Layout.tsx | 16 ++ web/src/components/context/ChatContext.tsx | 2 + web/src/components/icons/icons.tsx | 24 ++ .../search/filtering/FilterDropdown.tsx | 3 +- web/src/lib/chat/fetchChatData.ts | 17 +- 37 files changed, 1619 insertions(+), 48 deletions(-) create mode 100644 backend/alembic/versions/e1392f05e840_added_input_prompts.py create mode 100644 backend/danswer/chat/input_prompts.yaml create mode 100644 backend/danswer/db/input_prompt.py create mode 100644 backend/danswer/server/features/input_prompt/__init__.py create mode 100644 backend/danswer/server/features/input_prompt/api.py create mode 100644 backend/danswer/server/features/input_prompt/models.py create mode 100644 web/src/app/admin/prompt-library/hooks.ts create mode 100644 web/src/app/admin/prompt-library/interfaces.ts create mode 100644 web/src/app/admin/prompt-library/modals/AddPromptModal.tsx create mode 100644 web/src/app/admin/prompt-library/modals/EditPromptModal.tsx create mode 100644 web/src/app/admin/prompt-library/page.tsx create mode 100644 web/src/app/admin/prompt-library/promptLibrary.tsx create mode 100644 web/src/app/admin/prompt-library/promptSection.tsx create mode 100644 web/src/app/assistants/mine/WrappedInputPrompts.tsx create mode 100644 web/src/app/prompts/page.tsx diff --git a/backend/alembic/versions/e1392f05e840_added_input_prompts.py b/backend/alembic/versions/e1392f05e840_added_input_prompts.py new file mode 100644 index 000000000..dd358220f --- /dev/null +++ b/backend/alembic/versions/e1392f05e840_added_input_prompts.py @@ -0,0 +1,58 @@ +"""Added input prompts + +Revision ID: e1392f05e840 +Revises: 08a1eda20fe1 +Create Date: 2024-07-13 19:09:22.556224 + +""" + +import fastapi_users_db_sqlalchemy + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "e1392f05e840" +down_revision = "08a1eda20fe1" +branch_labels: None = None +depends_on: None = None + + +def upgrade() -> None: + op.create_table( + "inputprompt", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("prompt", sa.String(), nullable=False), + sa.Column("content", sa.String(), nullable=False), + sa.Column("active", sa.Boolean(), nullable=False), + sa.Column("is_public", sa.Boolean(), nullable=False), + sa.Column( + "user_id", + fastapi_users_db_sqlalchemy.generics.GUID(), + nullable=True, + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["user.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "inputprompt__user", + sa.Column("input_prompt_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.ForeignKeyConstraint( + ["input_prompt_id"], + ["inputprompt.id"], + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["inputprompt.id"], + ), + sa.PrimaryKeyConstraint("input_prompt_id", "user_id"), + ) + + +def downgrade() -> None: + op.drop_table("inputprompt__user") + op.drop_table("inputprompt") diff --git a/backend/danswer/auth/users.py b/backend/danswer/auth/users.py index b6f00424d..8c9570c46 100644 --- a/backend/danswer/auth/users.py +++ b/backend/danswer/auth/users.py @@ -371,4 +371,5 @@ async def current_admin_user(user: User | None = Depends(current_user)) -> User status_code=status.HTTP_403_FORBIDDEN, detail="Access denied. User is not an admin.", ) + return user diff --git a/backend/danswer/chat/input_prompts.yaml b/backend/danswer/chat/input_prompts.yaml new file mode 100644 index 000000000..cc7dbe78e --- /dev/null +++ b/backend/danswer/chat/input_prompts.yaml @@ -0,0 +1,24 @@ +input_prompts: + - id: -5 + prompt: "Elaborate" + content: "Elaborate on the above, give me a more in depth explanation." + active: true + is_public: true + + - id: -4 + prompt: "Reword" + content: "Help me rewrite the following politely and concisely for professional communication:\n" + active: true + is_public: true + + - id: -3 + prompt: "Email" + content: "Write a professional email for me including a subject line, signature, etc. Template the parts that need editing with [ ]. The email should cover the following points:\n" + active: true + is_public: true + + - id: -2 + prompt: "Debug" + content: "Provide step-by-step troubleshooting instructions for the following issue:\n" + active: true + is_public: true diff --git a/backend/danswer/chat/load_yamls.py b/backend/danswer/chat/load_yamls.py index 279ef2b62..ed0c0a2d7 100644 --- a/backend/danswer/chat/load_yamls.py +++ b/backend/danswer/chat/load_yamls.py @@ -1,11 +1,13 @@ import yaml from sqlalchemy.orm import Session +from danswer.configs.chat_configs import INPUT_PROMPT_YAML from danswer.configs.chat_configs import MAX_CHUNKS_FED_TO_CHAT from danswer.configs.chat_configs import PERSONAS_YAML from danswer.configs.chat_configs import PROMPTS_YAML from danswer.db.document_set import get_or_create_document_set_by_name from danswer.db.engine import get_sqlalchemy_engine +from danswer.db.input_prompt import insert_input_prompt_if_not_exists from danswer.db.models import DocumentSet as DocumentSetDBModel from danswer.db.models import Prompt as PromptDBModel from danswer.db.persona import get_prompt_by_name @@ -101,9 +103,32 @@ def load_personas_from_yaml( ) +def load_input_prompts_from_yaml(input_prompts_yaml: str = INPUT_PROMPT_YAML) -> None: + with open(input_prompts_yaml, "r") as file: + data = yaml.safe_load(file) + + all_input_prompts = data.get("input_prompts", []) + with Session(get_sqlalchemy_engine()) as db_session: + for input_prompt in all_input_prompts: + # If these prompts are deleted (which is a hard delete in the DB), on server startup + # they will be recreated, but the user can always just deactivate them, just a light inconvenience + insert_input_prompt_if_not_exists( + user=None, + input_prompt_id=input_prompt.get("id"), + prompt=input_prompt["prompt"], + content=input_prompt["content"], + is_public=input_prompt["is_public"], + active=input_prompt.get("active", True), + db_session=db_session, + commit=True, + ) + + def load_chat_yamls( prompt_yaml: str = PROMPTS_YAML, personas_yaml: str = PERSONAS_YAML, + input_prompts_yaml: str = INPUT_PROMPT_YAML, ) -> None: load_prompts_from_yaml(prompt_yaml) load_personas_from_yaml(personas_yaml) + load_input_prompts_from_yaml(input_prompts_yaml) diff --git a/backend/danswer/configs/chat_configs.py b/backend/danswer/configs/chat_configs.py index 0d8aadb6f..3ba8e7a42 100644 --- a/backend/danswer/configs/chat_configs.py +++ b/backend/danswer/configs/chat_configs.py @@ -3,6 +3,7 @@ import os PROMPTS_YAML = "./danswer/chat/prompts.yaml" PERSONAS_YAML = "./danswer/chat/personas.yaml" +INPUT_PROMPT_YAML = "./danswer/chat/input_prompts.yaml" NUM_RETURNED_HITS = 50 # Used for LLM filtering and reranking diff --git a/backend/danswer/db/input_prompt.py b/backend/danswer/db/input_prompt.py new file mode 100644 index 000000000..efa54d986 --- /dev/null +++ b/backend/danswer/db/input_prompt.py @@ -0,0 +1,202 @@ +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.orm import Session + +from danswer.db.models import InputPrompt +from danswer.db.models import User +from danswer.server.features.input_prompt.models import InputPromptSnapshot +from danswer.server.manage.models import UserInfo +from danswer.utils.logger import setup_logger + + +logger = setup_logger() + + +def insert_input_prompt_if_not_exists( + user: User | None, + input_prompt_id: int | None, + prompt: str, + content: str, + active: bool, + is_public: bool, + db_session: Session, + commit: bool = True, +) -> InputPrompt: + if input_prompt_id is not None: + input_prompt = ( + db_session.query(InputPrompt).filter_by(id=input_prompt_id).first() + ) + else: + query = db_session.query(InputPrompt).filter(InputPrompt.prompt == prompt) + if user: + query = query.filter(InputPrompt.user_id == user.id) + else: + query = query.filter(InputPrompt.user_id.is_(None)) + input_prompt = query.first() + + if input_prompt is None: + input_prompt = InputPrompt( + id=input_prompt_id, + prompt=prompt, + content=content, + active=active, + is_public=is_public or user is None, + user_id=user.id if user else None, + ) + db_session.add(input_prompt) + + if commit: + db_session.commit() + + return input_prompt + + +def insert_input_prompt( + prompt: str, + content: str, + is_public: bool, + user: User | None, + db_session: Session, +) -> InputPrompt: + input_prompt = InputPrompt( + prompt=prompt, + content=content, + active=True, + is_public=is_public or user is None, + user_id=user.id if user is not None else None, + ) + db_session.add(input_prompt) + db_session.commit() + + return input_prompt + + +def update_input_prompt( + user: User | None, + input_prompt_id: int, + prompt: str, + content: str, + active: bool, + db_session: Session, +) -> InputPrompt: + input_prompt = db_session.scalar( + select(InputPrompt).where(InputPrompt.id == input_prompt_id) + ) + if input_prompt is None: + raise ValueError(f"No input prompt with id {input_prompt_id}") + + if not validate_user_prompt_authorization(user, input_prompt): + raise HTTPException(status_code=401, detail="You don't own this prompt") + + input_prompt.prompt = prompt + input_prompt.content = content + input_prompt.active = active + + db_session.commit() + return input_prompt + + +def validate_user_prompt_authorization( + user: User | None, input_prompt: InputPrompt +) -> bool: + prompt = InputPromptSnapshot.from_model(input_prompt=input_prompt) + + if prompt.user_id is not None: + if user is None: + return False + + user_details = UserInfo.from_model(user) + if str(user_details.id) != str(prompt.user_id): + return False + return True + + +def remove_public_input_prompt(input_prompt_id: int, db_session: Session) -> None: + input_prompt = db_session.scalar( + select(InputPrompt).where(InputPrompt.id == input_prompt_id) + ) + + if input_prompt is None: + raise ValueError(f"No input prompt with id {input_prompt_id}") + + if not input_prompt.is_public: + raise HTTPException(status_code=400, detail="This prompt is not public") + + db_session.delete(input_prompt) + db_session.commit() + + +def remove_input_prompt( + user: User | None, input_prompt_id: int, db_session: Session +) -> None: + input_prompt = db_session.scalar( + select(InputPrompt).where(InputPrompt.id == input_prompt_id) + ) + if input_prompt is None: + raise ValueError(f"No input prompt with id {input_prompt_id}") + + if input_prompt.is_public: + raise HTTPException( + status_code=400, detail="Cannot delete public prompts with this method" + ) + + if not validate_user_prompt_authorization(user, input_prompt): + raise HTTPException(status_code=401, detail="You do not own this prompt") + + db_session.delete(input_prompt) + db_session.commit() + + +def fetch_input_prompt_by_id( + id: int, user_id: UUID | None, db_session: Session +) -> InputPrompt: + query = select(InputPrompt).where(InputPrompt.id == id) + + if user_id: + query = query.where( + (InputPrompt.user_id == user_id) | (InputPrompt.user_id is None) + ) + else: + # If no user_id is provided, only fetch prompts without a user_id (aka public) + query = query.where(InputPrompt.user_id == None) # noqa + + result = db_session.scalar(query) + + if result is None: + raise HTTPException(422, "No input prompt found") + + return result + + +def fetch_public_input_prompts( + db_session: Session, +) -> list[InputPrompt]: + query = select(InputPrompt).where(InputPrompt.is_public) + return list(db_session.scalars(query).all()) + + +def fetch_input_prompts_by_user( + db_session: Session, + user_id: UUID | None, + active: bool | None = None, + include_public: bool = False, +) -> list[InputPrompt]: + query = select(InputPrompt) + + if user_id is not None: + if include_public: + query = query.where( + (InputPrompt.user_id == user_id) | InputPrompt.is_public + ) + else: + query = query.where(InputPrompt.user_id == user_id) + + elif include_public: + query = query.where(InputPrompt.is_public) + + if active is not None: + query = query.where(InputPrompt.active == active) + + return list(db_session.scalars(query).all()) diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 9f372811c..397d26ac7 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -137,12 +137,39 @@ class User(SQLAlchemyBaseUserTableUUID, Base): ) prompts: Mapped[list["Prompt"]] = relationship("Prompt", back_populates="user") + input_prompts: Mapped[list["InputPrompt"]] = relationship( + "InputPrompt", back_populates="user" + ) + # Personas owned by this user personas: Mapped[list["Persona"]] = relationship("Persona", back_populates="user") # Custom tools created by this user custom_tools: Mapped[list["Tool"]] = relationship("Tool", back_populates="user") +class InputPrompt(Base): + __tablename__ = "inputprompt" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + prompt: Mapped[str] = mapped_column(String) + content: Mapped[str] = mapped_column(String) + active: Mapped[bool] = mapped_column(Boolean) + user: Mapped[User | None] = relationship("User", back_populates="input_prompts") + is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + user_id: Mapped[UUID] = mapped_column(ForeignKey("user.id")) + + +class InputPrompt__User(Base): + __tablename__ = "inputprompt__user" + + input_prompt_id: Mapped[int] = mapped_column( + ForeignKey("inputprompt.id"), primary_key=True + ) + user_id: Mapped[UUID] = mapped_column( + ForeignKey("inputprompt.id"), primary_key=True + ) + + class AccessToken(SQLAlchemyBaseAccessTokenTableUUID, Base): pass diff --git a/backend/danswer/main.py b/backend/danswer/main.py index 1d4c7635a..5cfe1c1c1 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -62,6 +62,10 @@ from danswer.server.documents.credential import router as credential_router from danswer.server.documents.document import router as document_router from danswer.server.features.document_set.api import router as document_set_router from danswer.server.features.folder.api import router as folder_router +from danswer.server.features.input_prompt.api import ( + admin_router as admin_input_prompt_router, +) +from danswer.server.features.input_prompt.api import basic_router as input_prompt_router from danswer.server.features.persona.api import admin_router as admin_persona_router from danswer.server.features.persona.api import basic_router as persona_router from danswer.server.features.prompt.api import basic_router as prompt_router @@ -286,6 +290,8 @@ def get_application() -> FastAPI: include_router_with_global_prefix_prepended(application, standard_answer_router) include_router_with_global_prefix_prepended(application, persona_router) include_router_with_global_prefix_prepended(application, admin_persona_router) + include_router_with_global_prefix_prepended(application, input_prompt_router) + include_router_with_global_prefix_prepended(application, admin_input_prompt_router) include_router_with_global_prefix_prepended(application, prompt_router) include_router_with_global_prefix_prepended(application, tool_router) include_router_with_global_prefix_prepended(application, admin_tool_router) diff --git a/backend/danswer/server/features/input_prompt/__init__.py b/backend/danswer/server/features/input_prompt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/danswer/server/features/input_prompt/api.py b/backend/danswer/server/features/input_prompt/api.py new file mode 100644 index 000000000..58eecd009 --- /dev/null +++ b/backend/danswer/server/features/input_prompt/api.py @@ -0,0 +1,134 @@ +from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from danswer.auth.users import current_admin_user +from danswer.auth.users import current_user +from danswer.db.engine import get_session +from danswer.db.input_prompt import fetch_input_prompt_by_id +from danswer.db.input_prompt import fetch_input_prompts_by_user +from danswer.db.input_prompt import fetch_public_input_prompts +from danswer.db.input_prompt import insert_input_prompt +from danswer.db.input_prompt import remove_input_prompt +from danswer.db.input_prompt import remove_public_input_prompt +from danswer.db.input_prompt import update_input_prompt +from danswer.db.models import User +from danswer.server.features.input_prompt.models import CreateInputPromptRequest +from danswer.server.features.input_prompt.models import InputPromptSnapshot +from danswer.server.features.input_prompt.models import UpdateInputPromptRequest +from danswer.utils.logger import setup_logger + +logger = setup_logger() + +basic_router = APIRouter(prefix="/input_prompt") +admin_router = APIRouter(prefix="/admin/input_prompt") + + +@basic_router.get("") +def list_input_prompts( + user: User | None = Depends(current_user), + include_public: bool = False, + db_session: Session = Depends(get_session), +) -> list[InputPromptSnapshot]: + user_prompts = fetch_input_prompts_by_user( + user_id=user.id if user is not None else None, + db_session=db_session, + include_public=include_public, + ) + return [InputPromptSnapshot.from_model(prompt) for prompt in user_prompts] + + +@basic_router.get("/{input_prompt_id}") +def get_input_prompt( + input_prompt_id: int, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> InputPromptSnapshot: + input_prompt = fetch_input_prompt_by_id( + id=input_prompt_id, + user_id=user.id if user is not None else None, + db_session=db_session, + ) + return InputPromptSnapshot.from_model(input_prompt=input_prompt) + + +@basic_router.post("") +def create_input_prompt( + create_input_prompt_request: CreateInputPromptRequest, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> InputPromptSnapshot: + input_prompt = insert_input_prompt( + prompt=create_input_prompt_request.prompt, + content=create_input_prompt_request.content, + is_public=create_input_prompt_request.is_public, + user=user, + db_session=db_session, + ) + return InputPromptSnapshot.from_model(input_prompt) + + +@basic_router.patch("/{input_prompt_id}") +def patch_input_prompt( + input_prompt_id: int, + update_input_prompt_request: UpdateInputPromptRequest, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> InputPromptSnapshot: + try: + updated_input_prompt = update_input_prompt( + user=user, + input_prompt_id=input_prompt_id, + prompt=update_input_prompt_request.prompt, + content=update_input_prompt_request.content, + active=update_input_prompt_request.active, + db_session=db_session, + ) + except ValueError as e: + error_msg = "Error occurred while updated input prompt" + logger.warn(f"{error_msg}. Stack trace: {e}") + raise HTTPException(status_code=404, detail=error_msg) + + return InputPromptSnapshot.from_model(updated_input_prompt) + + +@basic_router.delete("/{input_prompt_id}") +def delete_input_prompt( + input_prompt_id: int, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> None: + try: + remove_input_prompt(user, input_prompt_id, db_session) + + except ValueError as e: + error_msg = "Error occurred while deleting input prompt" + logger.warn(f"{error_msg}. Stack trace: {e}") + raise HTTPException(status_code=404, detail=error_msg) + + +@admin_router.delete("/{input_prompt_id}") +def delete_public_input_prompt( + input_prompt_id: int, + _: User | None = Depends(current_admin_user), + db_session: Session = Depends(get_session), +) -> None: + try: + remove_public_input_prompt(input_prompt_id, db_session) + + except ValueError as e: + error_msg = "Error occurred while deleting input prompt" + logger.warn(f"{error_msg}. Stack trace: {e}") + raise HTTPException(status_code=404, detail=error_msg) + + +@admin_router.get("") +def list_public_input_prompts( + _: User | None = Depends(current_admin_user), + db_session: Session = Depends(get_session), +) -> list[InputPromptSnapshot]: + user_prompts = fetch_public_input_prompts( + db_session=db_session, + ) + return [InputPromptSnapshot.from_model(prompt) for prompt in user_prompts] diff --git a/backend/danswer/server/features/input_prompt/models.py b/backend/danswer/server/features/input_prompt/models.py new file mode 100644 index 000000000..21ce2ba4e --- /dev/null +++ b/backend/danswer/server/features/input_prompt/models.py @@ -0,0 +1,47 @@ +from uuid import UUID + +from pydantic import BaseModel + +from danswer.db.models import InputPrompt +from danswer.utils.logger import setup_logger + +logger = setup_logger() + + +class CreateInputPromptRequest(BaseModel): + prompt: str + content: str + is_public: bool + + +class UpdateInputPromptRequest(BaseModel): + prompt: str + content: str + active: bool + + +class InputPromptResponse(BaseModel): + id: int + prompt: str + content: str + active: bool + + +class InputPromptSnapshot(BaseModel): + id: int + prompt: str + content: str + active: bool + user_id: UUID | None + is_public: bool + + @classmethod + def from_model(cls, input_prompt: InputPrompt) -> "InputPromptSnapshot": + return InputPromptSnapshot( + id=input_prompt.id, + prompt=input_prompt.prompt, + content=input_prompt.content, + active=input_prompt.active, + user_id=input_prompt.user_id, + is_public=input_prompt.is_public, + ) diff --git a/backend/danswer/server/features/persona/api.py b/backend/danswer/server/features/persona/api.py index 87e9fbdab..cf2c0e261 100644 --- a/backend/danswer/server/features/persona/api.py +++ b/backend/danswer/server/features/persona/api.py @@ -96,7 +96,7 @@ def undelete_persona( ) -# used for assistnat profile pictures +# used for assistat profile pictures @admin_router.post("/upload-image") def upload_file( file: UploadFile, diff --git a/web/src/app/admin/prompt-library/hooks.ts b/web/src/app/admin/prompt-library/hooks.ts new file mode 100644 index 000000000..ccab6b340 --- /dev/null +++ b/web/src/app/admin/prompt-library/hooks.ts @@ -0,0 +1,46 @@ +import useSWR from "swr"; +import { InputPrompt } from "./interfaces"; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +export const useAdminInputPrompts = () => { + const { data, error, mutate } = useSWR( + `/api/admin/input_prompt`, + fetcher + ); + + return { + data, + error, + isLoading: !error && !data, + refreshInputPrompts: mutate, + }; +}; + +export const useInputPrompts = (includePublic: boolean = false) => { + const { data, error, mutate } = useSWR( + `/api/input_prompt${includePublic ? "?include_public=true" : ""}`, + fetcher + ); + + return { + data, + error, + isLoading: !error && !data, + refreshInputPrompts: mutate, + }; +}; + +export const useInputPrompt = (id: number) => { + const { data, error, mutate } = useSWR( + `/api/input_prompt/${id}`, + fetcher + ); + + return { + data, + error, + isLoading: !error && !data, + refreshInputPrompt: mutate, + }; +}; diff --git a/web/src/app/admin/prompt-library/interfaces.ts b/web/src/app/admin/prompt-library/interfaces.ts new file mode 100644 index 000000000..9143a0ea8 --- /dev/null +++ b/web/src/app/admin/prompt-library/interfaces.ts @@ -0,0 +1,31 @@ +export interface InputPrompt { + id: number; + prompt: string; + content: string; + active: boolean; + is_public: string; +} + +export interface EditPromptModalProps { + onClose: () => void; + + promptId: number; + editInputPrompt: ( + promptId: number, + values: CreateInputPromptRequest + ) => Promise; +} +export interface CreateInputPromptRequest { + prompt: string; + content: string; +} + +export interface AddPromptModalProps { + onClose: () => void; + onSubmit: (promptData: CreateInputPromptRequest) => void; +} +export interface PromptData { + id: number; + prompt: string; + content: string; +} diff --git a/web/src/app/admin/prompt-library/modals/AddPromptModal.tsx b/web/src/app/admin/prompt-library/modals/AddPromptModal.tsx new file mode 100644 index 000000000..bc75c17c0 --- /dev/null +++ b/web/src/app/admin/prompt-library/modals/AddPromptModal.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Formik, Form, Field, ErrorMessage } from "formik"; +import * as Yup from "yup"; +import { ModalWrapper } from "@/app/chat/modal/ModalWrapper"; +import { Button, Textarea, TextInput } from "@tremor/react"; + +import { BookstackIcon } from "@/components/icons/icons"; +import { AddPromptModalProps } from "../interfaces"; +import { TextFormField } from "@/components/admin/connectors/Field"; + +const AddPromptSchema = Yup.object().shape({ + title: Yup.string().required("Title is required"), + prompt: Yup.string().required("Prompt is required"), +}); + +const AddPromptModal = ({ onClose, onSubmit }: AddPromptModalProps) => { + const defaultPrompts = [ + { + title: "Email help", + prompt: "Write a professional email addressing the following points:", + }, + { + title: "Code explanation", + prompt: "Explain the following code snippet in simple terms:", + }, + { + title: "Product description", + prompt: "Write a compelling product description for the following item:", + }, + { + title: "Troubleshooting steps", + prompt: + "Provide step-by-step troubleshooting instructions for the following issue:", + }, + ]; + + return ( + + { + onSubmit({ + prompt: values.title, + content: values.prompt, + }); + setSubmitting(false); + onClose(); + }} + > + {({ isSubmitting, setFieldValue }) => ( +
+

+ + Add prompt +

+ +
+ + + + +
+ +
+
+
+ )} +
+
+ ); +}; + +export default AddPromptModal; diff --git a/web/src/app/admin/prompt-library/modals/EditPromptModal.tsx b/web/src/app/admin/prompt-library/modals/EditPromptModal.tsx new file mode 100644 index 000000000..f71dfc487 --- /dev/null +++ b/web/src/app/admin/prompt-library/modals/EditPromptModal.tsx @@ -0,0 +1,138 @@ +import React from "react"; +import { Formik, Form, Field, ErrorMessage } from "formik"; +import * as Yup from "yup"; +import { ModalWrapper } from "@/app/chat/modal/ModalWrapper"; +import { Button, Textarea, TextInput } from "@tremor/react"; +import { useInputPrompt } from "../hooks"; +import { EditPromptModalProps } from "../interfaces"; + +const EditPromptSchema = Yup.object().shape({ + prompt: Yup.string().required("Title is required"), + content: Yup.string().required("Content is required"), + active: Yup.boolean(), +}); + +const EditPromptModal = ({ + onClose, + promptId, + editInputPrompt, +}: EditPromptModalProps) => { + const { + data: promptData, + error, + refreshInputPrompt, + } = useInputPrompt(promptId); + + if (error) + return ( + +

Failed to load prompt data

+
+ ); + + if (!promptData) + return ( + +

Loading...

+
+ ); + + return ( + + { + editInputPrompt(promptId, values); + refreshInputPrompt(); + }} + > + {({ isSubmitting, values }) => ( +
+

+ + + + Edit prompt +

+ +
+
+ + + +
+ +
+ + + +
+ +
+ +
+
+ +
+ +
+
+ )} +
+
+ ); +}; + +export default EditPromptModal; diff --git a/web/src/app/admin/prompt-library/page.tsx b/web/src/app/admin/prompt-library/page.tsx new file mode 100644 index 000000000..d7c72ff5f --- /dev/null +++ b/web/src/app/admin/prompt-library/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { AdminPageTitle } from "@/components/admin/Title"; +import { ClosedBookIcon } from "@/components/icons/icons"; +import { useAdminInputPrompts } from "./hooks"; +import { PromptSection } from "./promptSection"; + +const Page = () => { + const { + data: promptLibrary, + error: promptLibraryError, + isLoading: promptLibraryIsLoading, + refreshInputPrompts: refreshPrompts, + } = useAdminInputPrompts(); + + return ( +
+ } + title="Prompt Library" + /> + +
+ ); +}; +export default Page; diff --git a/web/src/app/admin/prompt-library/promptLibrary.tsx b/web/src/app/admin/prompt-library/promptLibrary.tsx new file mode 100644 index 000000000..0b47bf40a --- /dev/null +++ b/web/src/app/admin/prompt-library/promptLibrary.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { EditIcon, TrashIcon } from "@/components/icons/icons"; +import { PopupSpec } from "@/components/admin/connectors/Popup"; +import { MagnifyingGlass } from "@phosphor-icons/react"; +import { useState } from "react"; +import { + Table, + TableHead, + TableRow, + TableHeaderCell, + TableBody, + TableCell, +} from "@tremor/react"; +import { FilterDropdown } from "@/components/search/filtering/FilterDropdown"; +import { FiTag } from "react-icons/fi"; +import { PageSelector } from "@/components/PageSelector"; +import { InputPrompt } from "./interfaces"; +import { Modal } from "@/components/Modal"; + +const CategoryBubble = ({ + name, + onDelete, +}: { + name: string; + onDelete?: () => void; +}) => ( + + {name} + {onDelete && ( + + )} + +); + +const NUM_RESULTS_PER_PAGE = 10; + +export const PromptLibraryTable = ({ + promptLibrary, + refresh, + setPopup, + handleEdit, + isPublic, +}: { + promptLibrary: InputPrompt[]; + refresh: () => void; + setPopup: (popup: PopupSpec | null) => void; + handleEdit: (promptId: number) => void; + isPublic: boolean; +}) => { + const [query, setQuery] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const [selectedStatus, setSelectedStatus] = useState([]); + + const columns = [ + { name: "Prompt", key: "prompt" }, + { name: "Content", key: "content" }, + { name: "Status", key: "status" }, + { name: "", key: "edit" }, + { name: "", key: "delete" }, + ]; + + const filteredPromptLibrary = promptLibrary.filter((item) => { + const cleanedQuery = query.toLowerCase(); + const searchMatch = + item.prompt.toLowerCase().includes(cleanedQuery) || + item.content.toLowerCase().includes(cleanedQuery); + const statusMatch = + selectedStatus.length === 0 || + (selectedStatus.includes("Active") && item.active) || + (selectedStatus.includes("Inactive") && !item.active); + + return searchMatch && statusMatch; + }); + + const totalPages = Math.ceil( + filteredPromptLibrary.length / NUM_RESULTS_PER_PAGE + ); + const startIndex = (currentPage - 1) * NUM_RESULTS_PER_PAGE; + const endIndex = startIndex + NUM_RESULTS_PER_PAGE; + const paginatedPromptLibrary = filteredPromptLibrary.slice( + startIndex, + endIndex + ); + + const handlePageChange = (page: number) => { + setCurrentPage(page); + }; + + const handleDelete = async (id: number) => { + const response = await fetch( + `/api${isPublic ? "/admin" : ""}/input_prompt/${id}`, + { + method: "DELETE", + } + ); + if (!response.ok) { + setPopup({ message: "Failed to delete input prompt", type: "error" }); + } + refresh(); + }; + + const handleStatusSelect = (status: string) => { + setSelectedStatus((prev) => { + if (prev.includes(status)) { + return prev.filter((s) => s !== status); + } + return [...prev, status]; + }); + }; + + const [confirmDeletionId, setConfirmDeletionId] = useState( + null + ); + + return ( +
+ {confirmDeletionId != null && ( + setConfirmDeletionId(null)} + className="max-w-sm" + > + <> +

+ Are you sure you want to delete this prompt? You will not be able + to recover this prompt +

+
+ + +
+ +
+ )} + +
+ + { + setQuery(event.target.value); + setCurrentPage(1); + }} + /> +
+
+ handleStatusSelect(option.key)} + icon={} + defaultDisplay="All Statuses" + /> +
+ {selectedStatus.map((status) => ( + handleStatusSelect(status)} + /> + ))} +
+
+
+ + + + {columns.map((column) => ( + + {column.name} + + ))} + + + + {paginatedPromptLibrary.length > 0 ? ( + paginatedPromptLibrary + .filter((prompt) => !(!isPublic && prompt.is_public)) + .map((item) => ( + + {item.prompt} + {item.content} + {item.active ? "Active" : "Inactive"} + + + + + + + + )) + ) : ( + + No matching prompts found... + + )} + +
+ {paginatedPromptLibrary.length > 0 && ( +
+ +
+ )} +
+
+ ); +}; diff --git a/web/src/app/admin/prompt-library/promptSection.tsx b/web/src/app/admin/prompt-library/promptSection.tsx new file mode 100644 index 000000000..f719ad500 --- /dev/null +++ b/web/src/app/admin/prompt-library/promptSection.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { usePopup } from "@/components/admin/connectors/Popup"; +import { ThreeDotsLoader } from "@/components/Loading"; +import { ErrorCallout } from "@/components/ErrorCallout"; +import { Button, Divider, Text } from "@tremor/react"; +import { useState } from "react"; +import AddPromptModal from "./modals/AddPromptModal"; +import EditPromptModal from "./modals/EditPromptModal"; +import { PromptLibraryTable } from "./promptLibrary"; +import { CreateInputPromptRequest, InputPrompt } from "./interfaces"; + +export const PromptSection = ({ + promptLibrary, + isLoading, + error, + refreshPrompts, + centering = false, + isPublic, +}: { + promptLibrary: InputPrompt[]; + isLoading: boolean; + error: any; + refreshPrompts: () => void; + centering?: boolean; + isPublic: boolean; +}) => { + const { popup, setPopup } = usePopup(); + const [newPrompt, setNewPrompt] = useState(false); + const [newPromptId, setNewPromptId] = useState(null); + + const createInputPrompt = async ( + promptData: CreateInputPromptRequest + ): Promise => { + const response = await fetch("/api/input_prompt", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ ...promptData, is_public: isPublic }), + }); + + if (!response.ok) { + setPopup({ message: "Failed to create input prompt", type: "error" }); + } + + refreshPrompts(); + return response.json(); + }; + + const editInputPrompt = async ( + promptId: number, + values: CreateInputPromptRequest + ) => { + try { + const response = await fetch(`/api/input_prompt/${promptId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(values), + }); + + if (!response.ok) { + setPopup({ message: "Failed to update prompt!", type: "error" }); + } + + setNewPromptId(null); + refreshPrompts(); + } catch (err) { + setPopup({ message: `Failed to update prompt: ${err}`, type: "error" }); + } + }; + + if (isLoading) { + return ; + } + + if (error || !promptLibrary) { + return ( + + ); + } + + const handleEdit = (promptId: number) => { + setNewPromptId(promptId); + }; + + return ( +
+ {popup} + + {newPrompt && ( + setNewPrompt(false)} + /> + )} + + {newPromptId && ( + setNewPromptId(null)} + /> + )} +
+ + Create prompts that can be accessed with the `/` shortcut in + Danswer Chat.{" "} + {isPublic + ? "Prompts created here will be accessible to all users." + : "Prompts created here will be available only to you."} + +
+ +
+ + + + + +
+ +
+
+ ); +}; diff --git a/web/src/app/admin/settings/page.tsx b/web/src/app/admin/settings/page.tsx index 1fe2ca883..9cb2f630e 100644 --- a/web/src/app/admin/settings/page.tsx +++ b/web/src/app/admin/settings/page.tsx @@ -1,5 +1,5 @@ import { AdminPageTitle } from "@/components/admin/Title"; -import { FiSettings } from "react-icons/fi"; + import { SettingsForm } from "./SettingsForm"; import { Text } from "@tremor/react"; import { SettingsIcon } from "@/components/icons/icons"; diff --git a/web/src/app/assistants/SidebarWrapper.tsx b/web/src/app/assistants/SidebarWrapper.tsx index 7f12d86ef..126a0a826 100644 --- a/web/src/app/assistants/SidebarWrapper.tsx +++ b/web/src/app/assistants/SidebarWrapper.tsx @@ -27,6 +27,7 @@ interface SidebarWrapperProps { }; contentProps: T; page: pageType; + size?: "sm" | "lg"; } export default function SidebarWrapper({ @@ -38,6 +39,7 @@ export default function SidebarWrapper({ headerProps, contentProps, content, + size = "sm", }: SidebarWrapperProps) { const [toggledSidebar, setToggledSidebar] = useState(initiallyToggled); @@ -140,7 +142,9 @@ export default function SidebarWrapper({ ${toggledSidebar ? "w-[250px]" : "w-[0px]"}`} /> -
+
{content(contentProps)}
diff --git a/web/src/app/assistants/gallery/page.tsx b/web/src/app/assistants/gallery/page.tsx index ee5d9f8d3..d5215f179 100644 --- a/web/src/app/assistants/gallery/page.tsx +++ b/web/src/app/assistants/gallery/page.tsx @@ -36,6 +36,7 @@ export default async function GalleryPage({ openedFolders, shouldShowWelcomeModal, toggleSidebar, + userInputPrompts, } = data; return ( @@ -55,6 +56,7 @@ export default async function GalleryPage({ llmProviders, folders, openedFolders, + userInputPrompts, }} > ( +
+ Prompt Gallery + + +
+ )} + /> + ); +} diff --git a/web/src/app/assistants/mine/page.tsx b/web/src/app/assistants/mine/page.tsx index afd580b95..e167a10a8 100644 --- a/web/src/app/assistants/mine/page.tsx +++ b/web/src/app/assistants/mine/page.tsx @@ -38,6 +38,7 @@ export default async function GalleryPage({ openedFolders, shouldShowWelcomeModal, toggleSidebar, + userInputPrompts, } = data; return ( @@ -57,6 +58,7 @@ export default async function GalleryPage({ llmProviders, folders, openedFolders, + userInputPrompts, }} >
+

New Assistant

diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 7338c72e0..62610197e 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -102,6 +102,7 @@ export function ChatPage({ llmProviders, folders, openedFolders, + userInputPrompts, } = useChatContext(); // chat session @@ -1599,6 +1600,7 @@ export function ChatPage({ )} setDocumentSelection(true)} selectedDocuments={selectedDocuments} // assistant stuff diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index 56c15cf5c..5abd8c7fb 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -2,6 +2,7 @@ import React, { useContext, useEffect, useRef, useState } from "react"; import { FiPlusCircle, FiPlus, FiInfo, FiX } from "react-icons/fi"; import { ChatInputOption } from "./ChatInputOption"; import { Persona } from "@/app/admin/assistants/interfaces"; +import { InputPrompt } from "@/app/admin/prompt-library/interfaces"; import { FilterManager, getDisplayNameForModel, @@ -55,12 +56,14 @@ export function ChatInputBar({ textAreaRef, alternativeAssistant, chatSessionId, + inputPrompts, }: { showDocs: () => void; selectedDocuments: DanswerDocument[]; assistantOptions: Persona[]; setAlternativeAssistant: (alternativeAssistant: Persona | null) => void; setSelectedAssistant: (assistant: Persona) => void; + inputPrompts: InputPrompt[]; message: string; setMessage: (message: string) => void; onSubmit: () => void; @@ -76,7 +79,6 @@ export function ChatInputBar({ textAreaRef: React.RefObject; chatSessionId?: number; }) { - // handle re-sizing of the text area useEffect(() => { const textarea = textAreaRef.current; if (textarea) { @@ -111,9 +113,28 @@ export function ChatInputBar({ const suggestionsRef = useRef(null); const [showSuggestions, setShowSuggestions] = useState(false); + const [showPrompts, setShowPrompts] = useState(false); const interactionsRef = useRef(null); - // Click out of assistant suggestions + + const hideSuggestions = () => { + setShowSuggestions(false); + setTabbingIconIndex(0); + }; + + const hidePrompts = () => { + setTimeout(() => { + setShowPrompts(false); + }, 50); + + setTabbingIconIndex(0); + }; + + const updateInputPrompt = (prompt: InputPrompt) => { + hidePrompts(); + setMessage(`${prompt.content}`); + }; + useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( @@ -123,6 +144,7 @@ export function ChatInputBar({ !interactionsRef.current.contains(event.target as Node)) ) { hideSuggestions(); + hidePrompts(); } }; document.addEventListener("mousedown", handleClickOutside); @@ -131,12 +153,6 @@ export function ChatInputBar({ }; }, []); - const hideSuggestions = () => { - setShowSuggestions(false); - setAssistantIconIndex(0); - }; - - // Update selected persona const updatedTaggedAssistant = (assistant: Persona) => { setAlternativeAssistant( assistant.id == selectedAssistant.id ? null : assistant @@ -145,22 +161,37 @@ export function ChatInputBar({ setMessage(""); }; - // Complete user input handling + const handleAssistantInput = (text: string) => { + if (!text.startsWith("@")) { + hideSuggestions(); + } else { + const match = text.match(/(?:\s|^)@(\w*)$/); + if (match) { + setShowSuggestions(true); + } else { + hideSuggestions(); + } + } + }; + + const handlePromptInput = (text: string) => { + if (!text.startsWith("/")) { + hidePrompts(); + } else { + const promptMatch = text.match(/(?:\s|^)\/(\w*)$/); + if (promptMatch) { + setShowPrompts(true); + } else { + hidePrompts(); + } + } + }; + const handleInputChange = (event: React.ChangeEvent) => { const text = event.target.value; setMessage(text); - - if (!text.startsWith("@")) { - hideSuggestions(); - return; - } - - const match = text.match(/(?:\s|^)@(\w*)$/); - if (match) { - setShowSuggestions(true); - } else { - hideSuggestions(); - } + handleAssistantInput(text); + handlePromptInput(text); }; const assistantTagOptions = assistantOptions.filter((assistant) => @@ -172,38 +203,65 @@ export function ChatInputBar({ ) ); - const [assistantIconIndex, setAssistantIconIndex] = useState(0); + const filteredPrompts = inputPrompts.filter( + (prompt) => + prompt.active && + prompt.prompt.toLowerCase().startsWith( + message + .slice(message.lastIndexOf("/") + 1) + .split(/\s/)[0] + .toLowerCase() + ) + ); + + const [tabbingIconIndex, setTabbingIconIndex] = useState(0); const handleKeyDown = (e: React.KeyboardEvent) => { if ( - showSuggestions && - assistantTagOptions.length > 0 && + ((showSuggestions && assistantTagOptions.length > 0) || showPrompts) && (e.key === "Tab" || e.key == "Enter") ) { e.preventDefault(); - if (assistantIconIndex == assistantTagOptions.length) { - window.open("/assistants/new", "_blank"); - hideSuggestions(); - setMessage(""); + + if ( + (tabbingIconIndex == assistantTagOptions.length && showSuggestions) || + (tabbingIconIndex == filteredPrompts.length && showPrompts) + ) { + if (showPrompts) { + window.open("/prompts", "_self"); + } else { + window.open("/assistants/new", "_self"); + } } else { - const option = - assistantTagOptions[assistantIconIndex >= 0 ? assistantIconIndex : 0]; - updatedTaggedAssistant(option); + if (showPrompts) { + const uppity = + filteredPrompts[tabbingIconIndex >= 0 ? tabbingIconIndex : 0]; + updateInputPrompt(uppity); + } else { + const option = + assistantTagOptions[tabbingIconIndex >= 0 ? tabbingIconIndex : 0]; + + updatedTaggedAssistant(option); + } } } - if (!showSuggestions) { + if (!showPrompts && !showSuggestions) { return; } if (e.key === "ArrowDown") { e.preventDefault(); - setAssistantIconIndex((assistantIconIndex) => - Math.min(assistantIconIndex + 1, assistantTagOptions.length) + + setTabbingIconIndex((tabbingIconIndex) => + Math.min( + tabbingIconIndex + 1, + showPrompts ? filteredPrompts.length : assistantTagOptions.length + ) ); } else if (e.key === "ArrowUp") { e.preventDefault(); - setAssistantIconIndex((assistantIconIndex) => - Math.max(assistantIconIndex - 1, 0) + setTabbingIconIndex((tabbingIconIndex) => + Math.max(tabbingIconIndex - 1, 0) ); } }; @@ -231,7 +289,7 @@ export function ChatInputBar({ ))} + @@ -260,6 +318,42 @@ export function ChatInputBar({
)} + + {showPrompts && ( + + )} +
@@ -391,11 +485,13 @@ export function ChatInputBar({ style={{ scrollbarWidth: "thin" }} role="textarea" aria-multiline - placeholder={`Send a message ${!settings?.isMobile ? "or @ to tag an assistant..." : ""}`} + placeholder={`Send a message ${!settings?.isMobile ? "or try using @ or /" : ""}`} value={message} onKeyDown={(event) => { if ( event.key === "Enter" && + !showPrompts && + !showSuggestions && !event.shiftKey && message && !isStreaming @@ -453,7 +549,6 @@ export function ChatInputBar({ /> )} position="top" - // flexPriority="second" > + {onClose && ( +
+ +
+ )} + {children} diff --git a/web/src/app/chat/page.tsx b/web/src/app/chat/page.tsx index f518a0044..48c52dbf2 100644 --- a/web/src/app/chat/page.tsx +++ b/web/src/app/chat/page.tsx @@ -39,6 +39,7 @@ export default async function Page({ finalDocumentSidebarInitialWidth, shouldShowWelcomeModal, shouldDisplaySourcesIncompleteModal, + userInputPrompts, } = data; return ( @@ -59,6 +60,7 @@ export default async function Page({ llmProviders, folders, openedFolders, + userInputPrompts, }} > ( Manage Assistants

+ + +

+ Manage Prompts +

+ )}
diff --git a/web/src/app/prompts/page.tsx b/web/src/app/prompts/page.tsx new file mode 100644 index 000000000..8bf104d5b --- /dev/null +++ b/web/src/app/prompts/page.tsx @@ -0,0 +1,40 @@ +import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrapper"; +import { fetchChatData } from "@/lib/chat/fetchChatData"; +import { unstable_noStore as noStore } from "next/cache"; +import { redirect } from "next/navigation"; +import WrappedPrompts from "../assistants/mine/WrappedInputPrompts"; + +export default async function GalleryPage({ + searchParams, +}: { + searchParams: { [key: string]: string }; +}) { + noStore(); + + const data = await fetchChatData(searchParams); + + if ("redirect" in data) { + redirect(data.redirect); + } + + const { + user, + chatSessions, + assistants, + folders, + openedFolders, + shouldShowWelcomeModal, + toggleSidebar, + } = data; + + return ( + + ); +} diff --git a/web/src/components/admin/ClientLayout.tsx b/web/src/components/admin/ClientLayout.tsx index 622f10025..15f723b65 100644 --- a/web/src/components/admin/ClientLayout.tsx +++ b/web/src/components/admin/ClientLayout.tsx @@ -20,12 +20,14 @@ import { DocumentSetIconSkeleton, EmbeddingIconSkeleton, AssistantsIconSkeleton, + ClosedBookIcon, } from "@/components/icons/icons"; import { FiActivity, FiBarChart2 } from "react-icons/fi"; import { UserDropdown } from "../UserDropdown"; import { User } from "@/lib/types"; import { usePathname } from "next/navigation"; +import { PencilCircle } from "@phosphor-icons/react"; export function ClientLayout({ user, @@ -144,6 +146,15 @@ export function ClientLayout({ ), link: "/admin/standard-answer", }, + { + name: ( +
+ +
Prompt Library
+
+ ), + link: "/admin/prompt-library", + }, ], }, { diff --git a/web/src/components/admin/Layout.tsx b/web/src/components/admin/Layout.tsx index 2450d21a3..b2f08ade4 100644 --- a/web/src/components/admin/Layout.tsx +++ b/web/src/components/admin/Layout.tsx @@ -1,3 +1,19 @@ +import { Header } from "@/components/header/Header"; +import { AdminSidebar } from "@/components/admin/connectors/AdminSidebar"; +import { + NotebookIcon, + UsersIcon, + ThumbsUpIcon, + BookmarkIcon, + ZoomInIcon, + RobotIcon, + ConnectorIcon, + GroupsIcon, + DatabaseIcon, + KeyIcon, + ClipboardIcon, + BookstackIcon, +} from "@/components/icons/icons"; import { User } from "@/lib/types"; import { AuthTypeMetadata, diff --git a/web/src/components/context/ChatContext.tsx b/web/src/components/context/ChatContext.tsx index 2c114c5ce..74772cc29 100644 --- a/web/src/components/context/ChatContext.tsx +++ b/web/src/components/context/ChatContext.tsx @@ -12,6 +12,7 @@ import { ChatSession } from "@/app/chat/interfaces"; import { Persona } from "@/app/admin/assistants/interfaces"; import { LLMProviderDescriptor } from "@/app/admin/models/llm/interfaces"; import { Folder } from "@/app/chat/folders/interfaces"; +import { InputPrompt } from "@/app/admin/prompt-library/interfaces"; interface ChatContextProps { user: User | null; @@ -23,6 +24,7 @@ interface ChatContextProps { llmProviders: LLMProviderDescriptor[]; folders: Folder[]; openedFolders: Record; + userInputPrompts: InputPrompt[]; } const ChatContext = createContext(undefined); diff --git a/web/src/components/icons/icons.tsx b/web/src/components/icons/icons.tsx index e6f1b74a1..ec9e381c1 100644 --- a/web/src/components/icons/icons.tsx +++ b/web/src/components/icons/icons.tsx @@ -2366,3 +2366,27 @@ export const SwapIcon = ({ ); }; + +export const ClosedBookIcon = ({ + size = 16, + className = defaultTailwindCSS, +}: IconProps) => { + return ( + + + + ); +}; diff --git a/web/src/components/search/filtering/FilterDropdown.tsx b/web/src/components/search/filtering/FilterDropdown.tsx index bee6a3962..1c3028d60 100644 --- a/web/src/components/search/filtering/FilterDropdown.tsx +++ b/web/src/components/search/filtering/FilterDropdown.tsx @@ -82,11 +82,12 @@ export function FilterDropdown({ py-1.5 rounded-lg border + gap-x-2 border-border cursor-pointer hover:bg-hover-light`} > - {icon} +
{icon}
{selected.length === 0 ? ( defaultDisplay ) : ( diff --git a/web/src/lib/chat/fetchChatData.ts b/web/src/lib/chat/fetchChatData.ts index fb19037a9..f59850812 100644 --- a/web/src/lib/chat/fetchChatData.ts +++ b/web/src/lib/chat/fetchChatData.ts @@ -13,6 +13,7 @@ import { } from "@/lib/types"; import { ChatSession } from "@/app/chat/interfaces"; import { Persona } from "@/app/admin/assistants/interfaces"; +import { InputPrompt } from "@/app/admin/prompt-library/interfaces"; import { FullEmbeddingModelResponse } from "@/app/admin/models/embedding/components/types"; import { Settings } from "@/app/admin/settings/interfaces"; import { fetchLLMProvidersSS } from "@/lib/llm/fetchLLMs"; @@ -44,6 +45,7 @@ interface FetchChatDataResult { finalDocumentSidebarInitialWidth?: number; shouldShowWelcomeModal: boolean; shouldDisplaySourcesIncompleteModal: boolean; + userInputPrompts: InputPrompt[]; } export async function fetchChatData(searchParams: { @@ -59,6 +61,7 @@ export async function fetchChatData(searchParams: { fetchSS("/query/valid-tags"), fetchLLMProvidersSS(), fetchSS("/folder"), + fetchSS("/input_prompt?include_public=true"), ]; let results: ( @@ -90,8 +93,8 @@ export async function fetchChatData(searchParams: { const tagsResponse = results[6] as Response | null; const llmProviders = (results[7] || []) as LLMProviderDescriptor[]; - - const foldersResponse = results[8] as Response | null; // Handle folders result + const foldersResponse = results[8] as Response | null; + const userInputPromptsResponse = results[9] as Response | null; const authDisabled = authTypeMetadata?.authType === "disabled"; if (!authDisabled && !user) { @@ -135,6 +138,15 @@ export async function fetchChatData(searchParams: { ); } + let userInputPrompts: InputPrompt[] = []; + if (userInputPromptsResponse?.ok) { + userInputPrompts = await userInputPromptsResponse.json(); + } else { + console.log( + `Failed to fetch user input prompts - ${userInputPromptsResponse?.status}` + ); + } + let assistants = rawAssistantsList; if (assistantsFetchError) { console.log(`Failed to fetch assistants - ${assistantsFetchError}`); @@ -218,5 +230,6 @@ export async function fetchChatData(searchParams: { toggleSidebar, shouldShowWelcomeModal, shouldDisplaySourcesIncompleteModal, + userInputPrompts, }; }