Personal assistants

This commit is contained in:
Weves 2024-04-20 16:20:26 -07:00 committed by Chris Weaver
parent f616b7e6e5
commit b407edbe49
63 changed files with 2129 additions and 1595 deletions

View File

@ -24,7 +24,7 @@ def load_prompts_from_yaml(prompts_yaml: str = PROMPTS_YAML) -> None:
with Session(get_sqlalchemy_engine()) as db_session:
for prompt in all_prompts:
upsert_prompt(
user_id=None,
user=None,
prompt_id=prompt.get("id"),
name=prompt["name"],
description=prompt["description"].strip(),
@ -34,7 +34,6 @@ def load_prompts_from_yaml(prompts_yaml: str = PROMPTS_YAML) -> None:
datetime_aware=prompt.get("datetime_aware", True),
default_prompt=True,
personas=None,
shared=True,
db_session=db_session,
commit=True,
)
@ -67,9 +66,7 @@ def load_personas_from_yaml(
prompts: list[PromptDBModel | None] | None = None
else:
prompts = [
get_prompt_by_name(
prompt_name, user_id=None, shared=True, db_session=db_session
)
get_prompt_by_name(prompt_name, user=None, db_session=db_session)
for prompt_name in prompt_set_names
]
if any([prompt is None for prompt in prompts]):
@ -80,7 +77,7 @@ def load_personas_from_yaml(
p_id = persona.get("id")
upsert_persona(
user_id=None,
user=None,
# Negative to not conflict with existing personas
persona_id=(-1 * p_id) if p_id is not None else None,
name=persona["name"],
@ -96,7 +93,6 @@ def load_personas_from_yaml(
prompts=cast(list[PromptDBModel] | None, prompts),
document_sets=doc_sets,
default_persona=True,
shared=True,
is_public=True,
db_session=db_session,
)

View File

@ -12,6 +12,7 @@ from sqlalchemy import update
from sqlalchemy.exc import MultipleResultsFound
from sqlalchemy.orm import Session
from danswer.auth.schemas import UserRole
from danswer.configs.chat_configs import HARD_DELETE_CHATS
from danswer.configs.constants import MessageType
from danswer.db.constants import SLACK_BOT_PERSONA_PREFIX
@ -27,6 +28,7 @@ from danswer.db.models import Prompt
from danswer.db.models import SearchDoc
from danswer.db.models import SearchDoc as DBSearchDoc
from danswer.db.models import StarterMessage
from danswer.db.models import User
from danswer.db.models import User__UserGroup
from danswer.llm.override_models import LLMOverride
from danswer.llm.override_models import PromptOverride
@ -313,13 +315,16 @@ def set_as_latest_chat_message(
def get_prompt_by_id(
prompt_id: int,
user_id: UUID | None,
user: User | None,
db_session: Session,
include_deleted: bool = False,
) -> Prompt:
stmt = select(Prompt).where(
Prompt.id == prompt_id, or_(Prompt.user_id == user_id, Prompt.user_id.is_(None))
)
stmt = select(Prompt).where(Prompt.id == prompt_id)
# if user is not specified OR they are an admin, they should
# have access to all prompts, so this where clause is not needed
if user and user.role != UserRole.ADMIN:
stmt = stmt.where(or_(Prompt.user_id == user.id, Prompt.user_id.is_(None)))
if not include_deleted:
stmt = stmt.where(Prompt.deleted.is_(False))
@ -351,14 +356,16 @@ def get_default_prompt() -> Prompt:
def get_persona_by_id(
persona_id: int,
# if user_id is `None` assume the user is an admin or auth is disabled
user_id: UUID | None,
# if user is `None` assume the user is an admin or auth is disabled
user: User | None,
db_session: Session,
include_deleted: bool = False,
) -> Persona:
stmt = select(Persona).where(Persona.id == persona_id)
if user_id is not None:
stmt = stmt.where(or_(Persona.user_id == user_id, Persona.user_id.is_(None)))
# if user is an admin, they should have access to all Personas
if user is not None and user.role != UserRole.ADMIN:
stmt = stmt.where(or_(Persona.user_id == user.id, Persona.user_id.is_(None)))
if not include_deleted:
stmt = stmt.where(Persona.deleted.is_(False))
@ -397,33 +404,33 @@ def get_personas_by_ids(
def get_prompt_by_name(
prompt_name: str, user_id: UUID | None, shared: bool, db_session: Session
prompt_name: str, user: User | None, db_session: Session
) -> Prompt | None:
"""Cannot do shared and user owned simultaneously as there may be two of those"""
stmt = select(Prompt).where(Prompt.name == prompt_name)
if shared:
stmt = stmt.where(Prompt.user_id.is_(None))
else:
stmt = stmt.where(Prompt.user_id == user_id)
# if user is not specified OR they are an admin, they should
# have access to all prompts, so this where clause is not needed
if user and user.role != UserRole.ADMIN:
stmt = stmt.where(Prompt.user_id == user.id)
result = db_session.execute(stmt).scalar_one_or_none()
return result
def get_persona_by_name(
persona_name: str, user_id: UUID | None, shared: bool, db_session: Session
persona_name: str, user: User | None, db_session: Session
) -> Persona | None:
"""Cannot do shared and user owned simultaneously as there may be two of those"""
"""Admins can see all, regular users can only fetch their own.
If user is None, assume the user is an admin or auth is disabled."""
stmt = select(Persona).where(Persona.name == persona_name)
if shared:
stmt = stmt.where(Persona.user_id.is_(None))
else:
stmt = stmt.where(Persona.user_id == user_id)
if user and user.role != UserRole.ADMIN:
stmt = stmt.where(Persona.user_id == user.id)
result = db_session.execute(stmt).scalar_one_or_none()
return result
def upsert_prompt(
user_id: UUID | None,
user: User | None,
name: str,
description: str,
system_prompt: str,
@ -431,7 +438,6 @@ def upsert_prompt(
include_citations: bool,
datetime_aware: bool,
personas: list[Persona] | None,
shared: bool,
db_session: Session,
prompt_id: int | None = None,
default_prompt: bool = True,
@ -440,9 +446,7 @@ def upsert_prompt(
if prompt_id is not None:
prompt = db_session.query(Prompt).filter_by(id=prompt_id).first()
else:
prompt = get_prompt_by_name(
prompt_name=name, user_id=user_id, shared=shared, db_session=db_session
)
prompt = get_prompt_by_name(prompt_name=name, user=user, db_session=db_session)
if prompt:
if not default_prompt and prompt.default_prompt:
@ -463,7 +467,7 @@ def upsert_prompt(
else:
prompt = Prompt(
id=prompt_id,
user_id=None if shared else user_id,
user_id=user.id if user else None,
name=name,
description=description,
system_prompt=system_prompt,
@ -485,7 +489,7 @@ def upsert_prompt(
def upsert_persona(
user_id: UUID | None,
user: User | None,
name: str,
description: str,
num_chunks: float,
@ -496,7 +500,6 @@ def upsert_persona(
document_sets: list[DBDocumentSet] | None,
llm_model_version_override: str | None,
starter_messages: list[StarterMessage] | None,
shared: bool,
is_public: bool,
db_session: Session,
persona_id: int | None = None,
@ -507,7 +510,7 @@ def upsert_persona(
persona = db_session.query(Persona).filter_by(id=persona_id).first()
else:
persona = get_persona_by_name(
persona_name=name, user_id=user_id, shared=shared, db_session=db_session
persona_name=name, user=user, db_session=db_session
)
if persona:
@ -539,7 +542,7 @@ def upsert_persona(
else:
persona = Persona(
id=persona_id,
user_id=None if shared else user_id,
user_id=user.id if user else None,
is_public=is_public,
name=name,
description=description,
@ -566,24 +569,20 @@ def upsert_persona(
def mark_prompt_as_deleted(
prompt_id: int,
user_id: UUID | None,
user: User | None,
db_session: Session,
) -> None:
prompt = get_prompt_by_id(
prompt_id=prompt_id, user_id=user_id, db_session=db_session
)
prompt = get_prompt_by_id(prompt_id=prompt_id, user=user, db_session=db_session)
prompt.deleted = True
db_session.commit()
def mark_persona_as_deleted(
persona_id: int,
user_id: UUID | None,
user: User | None,
db_session: Session,
) -> None:
persona = get_persona_by_id(
persona_id=persona_id, user_id=user_id, db_session=db_session
)
persona = get_persona_by_id(persona_id=persona_id, user=user, db_session=db_session)
persona.deleted = True
db_session.commit()
@ -621,9 +620,7 @@ def update_persona_visibility(
is_visible: bool,
db_session: Session,
) -> None:
persona = get_persona_by_id(
persona_id=persona_id, user_id=None, db_session=db_session
)
persona = get_persona_by_id(persona_id=persona_id, user=None, db_session=db_session)
persona.is_visible = is_visible
db_session.commit()

View File

@ -736,7 +736,6 @@ class Prompt(Base):
__tablename__ = "prompt"
id: Mapped[int] = mapped_column(primary_key=True)
# If not belong to a user, then it's shared
user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True)
name: Mapped[str] = mapped_column(String)
description: Mapped[str] = mapped_column(String)
@ -770,7 +769,6 @@ class Persona(Base):
__tablename__ = "persona"
id: Mapped[int] = mapped_column(primary_key=True)
# If not belong to a user, then it's shared
user_id: Mapped[UUID | None] = mapped_column(ForeignKey("user.id"), nullable=True)
name: Mapped[str] = mapped_column(String)
description: Mapped[str] = mapped_column(String)
@ -824,7 +822,7 @@ class Persona(Base):
back_populates="personas",
)
# Owner
user: Mapped[User] = relationship("User", back_populates="personas")
user: Mapped[User | None] = relationship("User", back_populates="personas")
# Other users with access
users: Mapped[list[User]] = relationship(
"User",

View File

@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from danswer.db.chat import get_prompts_by_ids
from danswer.db.chat import upsert_persona
from danswer.db.document_set import get_document_sets_by_ids
from danswer.db.models import Persona__User
from danswer.db.models import User
from danswer.server.features.persona.models import CreatePersonaRequest
from danswer.server.features.persona.models import PersonaSnapshot
@ -21,9 +22,19 @@ def make_persona_private(
group_ids: list[int] | None,
db_session: Session,
) -> None:
if user_ids is not None:
db_session.query(Persona__User).filter(
Persona__User.persona_id == persona_id
).delete(synchronize_session="fetch")
for user_uuid in user_ids:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid))
db_session.commit()
# May cause error if someone switches down to MIT from EE
if user_ids or group_ids:
raise NotImplementedError("Danswer MIT does not support private Document Sets")
if group_ids:
raise NotImplementedError("Danswer MIT does not support private Personas")
def create_update_persona(
@ -32,8 +43,6 @@ def create_update_persona(
user: User | None,
db_session: Session,
) -> PersonaSnapshot:
user_id = user.id if user is not None else None
# Permission to actually use these is checked later
document_sets = list(
get_document_sets_by_ids(
@ -51,7 +60,7 @@ def create_update_persona(
try:
persona = upsert_persona(
persona_id=persona_id,
user_id=user_id,
user=user,
name=create_persona_request.name,
description=create_persona_request.description,
num_chunks=create_persona_request.num_chunks,
@ -62,7 +71,6 @@ def create_update_persona(
document_sets=document_sets,
llm_model_version_override=create_persona_request.llm_model_version_override,
starter_messages=create_persona_request.starter_messages,
shared=create_persona_request.shared,
is_public=create_persona_request.is_public,
db_session=db_session,
)

View File

@ -49,7 +49,7 @@ def create_slack_bot_persona(
# create/update persona associated with the slack bot
persona_name = _build_persona_name(channel_names)
persona = upsert_persona(
user_id=None, # Slack Bot Personas are not attached to users
user=None, # Slack Bot Personas are not attached to users
persona_id=existing_persona_id,
name=persona_name,
description="",
@ -61,7 +61,6 @@ def create_slack_bot_persona(
document_sets=document_sets,
llm_model_version_override=None,
starter_messages=None,
shared=True,
is_public=True,
default_persona=False,
db_session=db_session,

View File

@ -173,7 +173,7 @@ def stream_answer_objects(
prompt = None
if query_req.prompt_id is not None:
prompt = get_prompt_by_id(
prompt_id=query_req.prompt_id, user_id=user_id, db_session=db_session
prompt_id=query_req.prompt_id, user=user, db_session=db_session
)
if prompt is None:
if not chat_session.persona.prompts:

View File

@ -28,35 +28,6 @@ admin_router = APIRouter(prefix="/admin/persona")
basic_router = APIRouter(prefix="/persona")
@admin_router.post("")
def create_persona(
create_persona_request: CreatePersonaRequest,
user: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
return create_update_persona(
persona_id=None,
create_persona_request=create_persona_request,
user=user,
db_session=db_session,
)
@admin_router.patch("/{persona_id}")
def update_persona(
persona_id: int,
update_persona_request: CreatePersonaRequest,
user: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
return create_update_persona(
persona_id=persona_id,
create_persona_request=update_persona_request,
user=user,
db_session=db_session,
)
class IsVisibleRequest(BaseModel):
is_visible: bool
@ -92,19 +63,6 @@ def patch_persona_display_priority(
)
@admin_router.delete("/{persona_id}")
def delete_persona(
persona_id: int,
user: User | None = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
mark_persona_as_deleted(
persona_id=persona_id,
user_id=user.id if user is not None else None,
db_session=db_session,
)
@admin_router.get("")
def list_personas_admin(
_: User | None = Depends(current_admin_user),
@ -124,6 +82,48 @@ def list_personas_admin(
"""Endpoints for all"""
@basic_router.post("")
def create_persona(
create_persona_request: CreatePersonaRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
return create_update_persona(
persona_id=None,
create_persona_request=create_persona_request,
user=user,
db_session=db_session,
)
@basic_router.patch("/{persona_id}")
def update_persona(
persona_id: int,
update_persona_request: CreatePersonaRequest,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> PersonaSnapshot:
return create_update_persona(
persona_id=persona_id,
create_persona_request=update_persona_request,
user=user,
db_session=db_session,
)
@basic_router.delete("/{persona_id}")
def delete_persona(
persona_id: int,
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> None:
mark_persona_as_deleted(
persona_id=persona_id,
user=user,
db_session=db_session,
)
@basic_router.get("")
def list_personas(
user: User | None = Depends(current_user),
@ -148,7 +148,7 @@ def get_persona(
return PersonaSnapshot.from_model(
get_persona_by_id(
persona_id=persona_id,
user_id=user.id if user is not None else None,
user=user,
db_session=db_session,
)
)
@ -194,9 +194,9 @@ GPT_3_5_TURBO_MODEL_VERSIONS = [
]
@admin_router.get("/utils/list-available-models")
@basic_router.get("/utils/list-available-models")
def list_available_model_versions(
_: User | None = Depends(current_admin_user),
_: User | None = Depends(current_user),
) -> list[str]:
# currently only support selecting different models for OpenAI
if GEN_AI_MODEL_PROVIDER != "openai":
@ -205,9 +205,9 @@ def list_available_model_versions(
return GPT_4_MODEL_VERSIONS + GPT_3_5_TURBO_MODEL_VERSIONS
@admin_router.get("/utils/default-model")
@basic_router.get("/utils/default-model")
def get_default_model(
_: User | None = Depends(current_admin_user),
_: User | None = Depends(current_user),
) -> str:
# currently only support selecting different models for OpenAI
if GEN_AI_MODEL_PROVIDER != "openai":

View File

@ -7,12 +7,12 @@ from danswer.db.models import StarterMessage
from danswer.search.enums import RecencyBiasSetting
from danswer.server.features.document_set.models import DocumentSet
from danswer.server.features.prompt.models import PromptSnapshot
from danswer.server.models import MinimalUserSnapshot
class CreatePersonaRequest(BaseModel):
name: str
description: str
shared: bool
num_chunks: float
llm_relevance_filter: bool
is_public: bool
@ -29,8 +29,8 @@ class CreatePersonaRequest(BaseModel):
class PersonaSnapshot(BaseModel):
id: int
owner: MinimalUserSnapshot | None
name: str
shared: bool
is_visible: bool
is_public: bool
display_priority: int | None
@ -43,6 +43,7 @@ class PersonaSnapshot(BaseModel):
default_persona: bool
prompts: list[PromptSnapshot]
document_sets: list[DocumentSet]
users: list[UUID]
groups: list[int]
@classmethod
@ -53,7 +54,11 @@ class PersonaSnapshot(BaseModel):
return PersonaSnapshot(
id=persona.id,
name=persona.name,
shared=persona.user_id is None,
owner=(
MinimalUserSnapshot(id=persona.user.id, email=persona.user.email)
if persona.user
else None
),
is_visible=persona.is_visible,
is_public=persona.is_public,
display_priority=persona.display_priority,
@ -69,6 +74,7 @@ class PersonaSnapshot(BaseModel):
DocumentSet.from_model(document_set_model)
for document_set_model in persona.document_sets
],
users=[user.id for user in persona.users],
groups=[user_group.id for user_group in persona.groups],
)

View File

@ -4,7 +4,6 @@ from fastapi import HTTPException
from sqlalchemy.orm import Session
from starlette import status
from danswer.auth.users import current_admin_user
from danswer.auth.users import current_user
from danswer.db.chat import get_personas_by_ids
from danswer.db.chat import get_prompt_by_id
@ -32,8 +31,6 @@ def create_update_prompt(
user: User | None,
db_session: Session,
) -> PromptSnapshot:
user_id = user.id if user is not None else None
personas = (
list(
get_personas_by_ids(
@ -47,7 +44,7 @@ def create_update_prompt(
prompt = upsert_prompt(
prompt_id=prompt_id,
user_id=user_id,
user=user,
name=create_prompt_request.name,
description=create_prompt_request.description,
system_prompt=create_prompt_request.system_prompt,
@ -55,7 +52,6 @@ def create_update_prompt(
include_citations=create_prompt_request.include_citations,
datetime_aware=create_prompt_request.datetime_aware,
personas=personas,
shared=create_prompt_request.shared,
db_session=db_session,
)
return PromptSnapshot.from_model(prompt)
@ -64,7 +60,7 @@ def create_update_prompt(
@basic_router.post("")
def create_prompt(
create_prompt_request: CreatePromptRequest,
user: User | None = Depends(current_admin_user),
user: User | None = Depends(current_user),
db_session: Session = Depends(get_session),
) -> PromptSnapshot:
try:
@ -124,7 +120,7 @@ def delete_prompt(
) -> None:
mark_prompt_as_deleted(
prompt_id=prompt_id,
user_id=user.id if user is not None else None,
user=user,
db_session=db_session,
)
@ -150,7 +146,7 @@ def get_prompt(
return PromptSnapshot.from_model(
get_prompt_by_id(
prompt_id=prompt_id,
user_id=user.id if user is not None else None,
user=user,
db_session=db_session,
)
)

View File

@ -6,7 +6,6 @@ from danswer.db.models import Prompt
class CreatePromptRequest(BaseModel):
name: str
description: str
shared: bool
system_prompt: str
task_prompt: str
include_citations: bool = False
@ -17,7 +16,6 @@ class CreatePromptRequest(BaseModel):
class PromptSnapshot(BaseModel):
id: int
name: str
shared: bool
description: str
system_prompt: str
task_prompt: str
@ -34,7 +32,6 @@ class PromptSnapshot(BaseModel):
return PromptSnapshot(
id=prompt.id,
name=prompt.name,
shared=prompt.user_id is None,
description=prompt.description,
system_prompt=prompt.system_prompt,
task_prompt=prompt.task_prompt,

View File

@ -140,7 +140,7 @@ def patch_slack_bot_config(
existing_persona_id = existing_slack_bot_config.persona_id
if existing_persona_id is not None:
persona = get_persona_by_id(
persona_id=existing_persona_id, user_id=None, db_session=db_session
persona_id=existing_persona_id, user=None, db_session=db_session
)
if not persona.name.startswith(SLACK_BOT_PERSONA_PREFIX):

View File

@ -1,6 +1,7 @@
from typing import Generic
from typing import Optional
from typing import TypeVar
from uuid import UUID
from pydantic import BaseModel
from pydantic.generics import GenericModel
@ -21,3 +22,8 @@ class ApiKey(BaseModel):
class IdReturn(BaseModel):
id: int
class MinimalUserSnapshot(BaseModel):
id: UUID
email: str

View File

@ -327,7 +327,7 @@ def get_max_document_tokens(
try:
persona = get_persona_by_id(
persona_id=persona_id,
user_id=user.id if user else None,
user=user,
db_session=db_session,
)
except ValueError:

View File

@ -1,7 +1,7 @@
"use client";
import { DocumentSet, UserGroup } from "@/lib/types";
import { Button, Divider, Text } from "@tremor/react";
import { CCPairBasicInfo, DocumentSet, User, UserGroup } from "@/lib/types";
import { Button, Divider, Italic, Text } from "@tremor/react";
import {
ArrayHelpers,
ErrorMessage,
@ -29,6 +29,8 @@ import { EE_ENABLED } from "@/lib/constants";
import { useUserGroups } from "@/lib/hooks";
import { Bubble } from "@/components/Bubble";
import { GroupsIcon } from "@/components/icons/icons";
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable";
function Label({ children }: { children: string | JSX.Element }) {
return (
@ -40,16 +42,24 @@ function SubLabel({ children }: { children: string | JSX.Element }) {
return <div className="text-sm text-subtle mb-2">{children}</div>;
}
export function PersonaEditor({
export function AssistantEditor({
existingPersona,
ccPairs,
documentSets,
llmOverrideOptions,
defaultLLM,
user,
defaultPublic,
redirectType,
}: {
existingPersona?: Persona | null;
ccPairs: CCPairBasicInfo[];
documentSets: DocumentSet[];
llmOverrideOptions: string[];
defaultLLM: string;
user: User | null;
defaultPublic: boolean;
redirectType: SuccessfulPersonaUpdateRedirectType;
}) {
const router = useRouter();
const { popup, setPopup } = usePopup();
@ -99,7 +109,7 @@ export function PersonaEditor({
system_prompt: existingPrompt?.system_prompt ?? "",
task_prompt: existingPrompt?.task_prompt ?? "",
disable_retrieval: (existingPersona?.num_chunks ?? 10) === 0,
is_public: existingPersona?.is_public ?? true,
is_public: existingPersona?.is_public ?? defaultPublic,
document_set_ids:
existingPersona?.document_sets?.map(
(documentSet) => documentSet.id
@ -116,9 +126,9 @@ export function PersonaEditor({
}}
validationSchema={Yup.object()
.shape({
name: Yup.string().required("Must give the Persona a name!"),
name: Yup.string().required("Must give the Assistant a name!"),
description: Yup.string().required(
"Must give the Persona a description!"
"Must give the Assistant a description!"
),
system_prompt: Yup.string(),
task_prompt: Yup.string(),
@ -187,12 +197,14 @@ export function PersonaEditor({
existingPromptId: existingPrompt?.id,
...values,
num_chunks: numChunks,
users: user ? [user.id] : undefined,
groups,
});
} else {
[promptResponse, personaResponse] = await createPersona({
...values,
num_chunks: numChunks,
users: user ? [user.id] : undefined,
groups,
});
}
@ -201,51 +213,53 @@ export function PersonaEditor({
if (!promptResponse.ok) {
error = await promptResponse.text();
}
if (personaResponse && !personaResponse.ok) {
if (!personaResponse) {
error = "Failed to create Assistant - no response received";
} else if (!personaResponse.ok) {
error = await personaResponse.text();
}
if (error) {
if (error || !personaResponse) {
setPopup({
type: "error",
message: `Failed to create Persona - ${error}`,
message: `Failed to create Assistant - ${error}`,
});
formikHelpers.setSubmitting(false);
} else {
router.push(`/admin/personas?u=${Date.now()}`);
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat?assistantId=${
((await personaResponse.json()) as Persona).id
}`
);
}
}}
>
{({ isSubmitting, values, setFieldValue }) => (
<Form>
<div className="pb-6">
<HidableSection sectionTitle="Who am I?">
<HidableSection sectionTitle="Basics">
<>
<TextFormField
name="name"
label="Name"
disabled={isUpdate}
subtext="Users will be able to select this Persona based on this name."
subtext="Users will be able to select this Assistant based on this name."
/>
<TextFormField
name="description"
label="Description"
subtext="Provide a short descriptions which gives users a hint as to what they should use this Persona for."
subtext="Provide a short descriptions which gives users a hint as to what they should use this Assistant for."
/>
</>
</HidableSection>
<Divider />
<HidableSection sectionTitle="Customize my response style">
<>
<TextFormField
name="system_prompt"
label="System Prompt"
isTextArea={true}
subtext={
'Give general info about what the Persona is about. For example, "You are an assistant for On-Call engineers. Your goal is to read the provided context documents and give recommendations as to how to resolve the issue."'
'Give general info about what the Assistant is about. For example, "You are an assistant for On-Call engineers. Your goal is to read the provided context documents and give recommendations as to how to resolve the issue."'
}
onChange={(e) => {
setFieldValue("system_prompt", e.target.value);
@ -260,11 +274,11 @@ export function PersonaEditor({
<TextFormField
name="task_prompt"
label="Task Prompt"
label="Task Prompt (Optional)"
isTextArea={true}
subtext={
'Give specific instructions as to what to do with the user query. For example, "Find any relevant sections from the provided documents that can help the user resolve their issue and explain how they are relevant."'
}
subtext={`Give specific instructions as to what to do with the user query.
For example, "Find any relevant sections from the provided documents that can
help the user resolve their issue and explain how they are relevant."`}
onChange={(e) => {
setFieldValue("task_prompt", e.target.value);
triggerFinalPromptUpdate(
@ -276,35 +290,6 @@ export function PersonaEditor({
error={finalPromptError}
/>
{!values.disable_retrieval && (
<BooleanFormField
name="include_citations"
label="Include Citations"
subtext={`
If set, the response will include bracket citations ([1], [2], etc.)
for each document used by the LLM to help inform the response. This is
the same technique used by the default Personas. In general, we recommend
to leave this enabled in order to increase trust in the LLM answer.`}
/>
)}
<BooleanFormField
name="disable_retrieval"
label="Disable Retrieval"
subtext={`
If set, the Persona will not fetch any context documents to aid in the response.
Instead, it will only use the supplied system and task prompts plus the user
query in order to generate a response`}
onChange={(e) => {
setFieldValue("disable_retrieval", e.target.checked);
triggerFinalPromptUpdate(
values.system_prompt,
values.task_prompt,
e.target.checked
);
}}
/>
<Label>Final Prompt</Label>
{finalPrompt ? (
@ -319,73 +304,100 @@ export function PersonaEditor({
<Divider />
{!values.disable_retrieval && (
{ccPairs.length > 0 && (
<>
<HidableSection sectionTitle="What data should I have access to?">
<HidableSection
sectionTitle="[Advanced] Knowledge Base"
defaultHidden
>
<>
<FieldArray
name="document_set_ids"
render={(arrayHelpers: ArrayHelpers) => (
<BooleanFormField
name="disable_retrieval"
label="Disable Retrieval"
subtext={`
If set, the Assistant will not fetch any context documents to aid in the response.
Instead, it will only use the supplied system and task prompts plus the user
query in order to generate a response`}
onChange={(e) => {
setFieldValue("disable_retrieval", e.target.checked);
triggerFinalPromptUpdate(
values.system_prompt,
values.task_prompt,
e.target.checked
);
}}
/>
{!values.disable_retrieval && (
<>
<div>
<div>
<SubLabel>
<>
Select which{" "}
<SubLabel>
<>
Select which{" "}
{!user || user.role === "admin" ? (
<Link
href="/admin/documents/sets"
className="text-blue-500"
target="_blank"
>
Document Sets
</Link>{" "}
that this Persona should search through. If
none are specified, the Persona will search
through all available documents in order to
try and response to queries.
</>
</SubLabel>
</div>
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
{documentSets.map((documentSet) => {
const ind = values.document_set_ids.indexOf(
documentSet.id
);
let isSelected = ind !== -1;
return (
<div
key={documentSet.id}
className={
`
px-3
py-1
rounded-lg
border
border-border
w-fit
flex
cursor-pointer ` +
(isSelected
? " bg-hover"
: " bg-background hover:bg-hover-light")
}
onClick={() => {
if (isSelected) {
arrayHelpers.remove(ind);
} else {
arrayHelpers.push(documentSet.id);
}
}}
>
<div className="my-auto">
{documentSet.name}
</div>
</div>
);
})}
</div>
</Link>
) : (
"Document Sets"
)}{" "}
that this Assistant should search through. If
none are specified, the Assistant will search
through all available documents in order to try
and respond to queries.
</>
</SubLabel>
</div>
)}
/>
{documentSets.length > 0 ? (
<FieldArray
name="document_set_ids"
render={(arrayHelpers: ArrayHelpers) => (
<div>
<div className="mb-3 mt-2 flex gap-2 flex-wrap text-sm">
{documentSets.map((documentSet) => {
const ind =
values.document_set_ids.indexOf(
documentSet.id
);
let isSelected = ind !== -1;
return (
<DocumentSetSelectable
key={documentSet.id}
documentSet={documentSet}
isSelected={isSelected}
onSelect={() => {
if (isSelected) {
arrayHelpers.remove(ind);
} else {
arrayHelpers.push(documentSet.id);
}
}}
/>
);
})}
</div>
</div>
)}
/>
) : (
<Italic className="text-sm">
No Document Sets available.{" "}
{user?.role !== "admin" && (
<>
If this functionality would be useful, reach
out to the administrators of Danswer for
assistance.
</>
)}
</Italic>
)}
</>
)}
</>
</HidableSection>
@ -393,73 +405,38 @@ export function PersonaEditor({
</>
)}
{EE_ENABLED && userGroups && (
{!values.disable_retrieval && (
<>
<HidableSection sectionTitle="Which groups should have access to this Persona?">
<HidableSection
sectionTitle="[Advanced] Response Style"
defaultHidden
>
<>
<BooleanFormField
name="is_public"
label="Is Public?"
subtext="If set, this Persona will be available to all users. If not, only the specified User Groups will be able to access it."
name="include_citations"
label="Include Citations"
subtext={`
If set, the response will include bracket citations ([1], [2], etc.)
for each document used by the LLM to help inform the response. This is
the same technique used by the default Assistants. In general, we recommend
to leave this enabled in order to increase trust in the LLM answer.`}
/>
{userGroups &&
userGroups.length > 0 &&
!values.is_public && (
<div>
<Text>
Select which User Groups should have access to
this Persona.
</Text>
<div className="flex flex-wrap gap-2 mt-2">
{userGroups.map((userGroup) => {
const isSelected = values.groups.includes(
userGroup.id
);
return (
<Bubble
key={userGroup.id}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setFieldValue(
"groups",
values.groups.filter(
(id) => id !== userGroup.id
)
);
} else {
setFieldValue("groups", [
...values.groups,
userGroup.id,
]);
}
}}
>
<div className="flex">
<GroupsIcon />
<div className="ml-1">
{userGroup.name}
</div>
</div>
</Bubble>
);
})}
</div>
</div>
)}
</>
</HidableSection>
<Divider />
</>
)}
{llmOverrideOptions.length > 0 && defaultLLM && (
<>
<HidableSection sectionTitle="[Advanced] Model Selection">
<HidableSection
sectionTitle="[Advanced] Model Selection"
defaultHidden
>
<>
<Text>
Pick which LLM to use for this Persona. If left as
Pick which LLM to use for this Assistant. If left as
Default, will use <b className="italic">{defaultLLM}</b>
.
<br />
@ -496,7 +473,10 @@ export function PersonaEditor({
{!values.disable_retrieval && (
<>
<HidableSection sectionTitle="[Advanced] Retrieval Customization">
<HidableSection
sectionTitle="[Advanced] Retrieval Customization"
defaultHidden
>
<>
<TextFormField
name="num_chunks"
@ -505,10 +485,7 @@ export function PersonaEditor({
<div>
How many chunks should we feed into the LLM when
generating the final response? Each chunk is ~400
words long. If you are using gpt-3.5-turbo or other
similar models, setting this to a value greater than
5 will result in errors at query time due to the
model&apos;s input length limit.
words long.
<br />
<br />
If unspecified, will use 10 chunks.
@ -537,14 +514,17 @@ export function PersonaEditor({
</>
)}
<HidableSection sectionTitle="[Advanced] Starter Messages">
<HidableSection
sectionTitle="[Advanced] Starter Messages"
defaultHidden
>
<>
<div className="mb-4">
<SubLabel>
Starter Messages help guide users to use this Persona.
Starter Messages help guide users to use this Assistant.
They are shown to the user as clickable options when they
select this Persona. When selected, the specified message
is sent to the LLM as the initial user message.
select this Assistant. When selected, the specified
message is sent to the LLM as the initial user message.
</SubLabel>
</div>
@ -686,6 +666,67 @@ export function PersonaEditor({
<Divider />
{EE_ENABLED && userGroups && (!user || user.role === "admin") && (
<>
<HidableSection sectionTitle="Access">
<>
<BooleanFormField
name="is_public"
label="Is Public?"
subtext="If set, this Assistant will be available to all users. If not, only the specified User Groups will be able to access it."
/>
{userGroups &&
userGroups.length > 0 &&
!values.is_public && (
<div>
<Text>
Select which User Groups should have access to
this Assistant.
</Text>
<div className="flex flex-wrap gap-2 mt-2">
{userGroups.map((userGroup) => {
const isSelected = values.groups.includes(
userGroup.id
);
return (
<Bubble
key={userGroup.id}
isSelected={isSelected}
onClick={() => {
if (isSelected) {
setFieldValue(
"groups",
values.groups.filter(
(id) => id !== userGroup.id
)
);
} else {
setFieldValue("groups", [
...values.groups,
userGroup.id,
]);
}
}}
>
<div className="flex">
<GroupsIcon />
<div className="ml-1">
{userGroup.name}
</div>
</div>
</Bubble>
);
})}
</div>
</div>
)}
</>
</HidableSection>
<Divider />
</>
)}
<div className="flex">
<Button
className="mx-auto"

View File

@ -12,6 +12,18 @@ import { deletePersona, personaComparator } from "./lib";
import { FiEdit } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons";
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
if (persona.default_persona) {
return <Text>Built-In</Text>;
}
if (persona.is_public) {
return <Text>Global</Text>;
}
return <Text>Personal {persona.owner && <>({persona.owner.email})</>}</Text>;
}
export function PersonasTable({ personas }: { personas: Persona[] }) {
const router = useRouter();
const { popup, setPopup } = usePopup();
@ -64,13 +76,13 @@ export function PersonasTable({ personas }: { personas: Persona[] }) {
{popup}
<Text className="my-2">
Personas will be displayed as options on the Chat / Search interfaces in
the order they are displayed below. Personas marked as hidden will not
be displayed.
Assistants will be displayed as options on the Chat / Search interfaces
in the order they are displayed below. Assistants marked as hidden will
not be displayed.
</Text>
<DraggableTable
headers={["Name", "Description", "Built-In", "Is Visible", "Delete"]}
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
rows={finalPersonaValues.map((persona) => {
return {
id: persona.id.toString(),
@ -81,7 +93,7 @@ export function PersonasTable({ personas }: { personas: Persona[] }) {
className="mr-1 my-auto cursor-pointer"
onClick={() =>
router.push(
`/admin/personas/${persona.id}?u=${Date.now()}`
`/admin/assistants/${persona.id}?u=${Date.now()}`
)
}
/>
@ -96,7 +108,7 @@ export function PersonasTable({ personas }: { personas: Persona[] }) {
>
{persona.description}
</p>,
persona.default_persona ? "Yes" : "No",
<PersonaTypeDisplay key={persona.id} persona={persona} />,
<div
key="is_visible"
onClick={async () => {

View File

@ -0,0 +1,53 @@
import { ErrorCallout } from "@/components/ErrorCallout";
import { AssistantEditor } from "../AssistantEditor";
import { BackButton } from "@/components/BackButton";
import { Card, Title } from "@tremor/react";
import { DeletePersonaButton } from "./DeletePersonaButton";
import { fetchPersonaEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
import { RobotIcon } from "@/components/icons/icons";
import { AdminPageTitle } from "@/components/admin/Title";
export default async function Page({
params,
}: {
params: { personaId: string };
}) {
const [values, error] = await fetchPersonaEditorInfoSS(params.personaId);
let body;
if (!values) {
body = (
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
);
} else {
body = (
<>
<Card>
<AssistantEditor
{...values}
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</Card>
<div className="mt-12">
<Title>Delete Assistant</Title>
<div className="flex mt-6">
<DeletePersonaButton personaId={values.existingPersona!.id} />
</div>
</div>
</>
);
}
return (
<div>
<BackButton />
<AdminPageTitle title="Edit Assistant" icon={<RobotIcon size={32} />} />
{body}
</div>
);
}

View File

@ -0,0 +1,4 @@
export enum SuccessfulPersonaUpdateRedirectType {
ADMIN = "ADMIN",
CHAT = "CHAT",
}

View File

@ -1,4 +1,4 @@
import { DocumentSet } from "@/lib/types";
import { DocumentSet, MinimalUserSnapshot } from "@/lib/types";
export interface StarterMessage {
name: string;
@ -9,7 +9,6 @@ export interface StarterMessage {
export interface Prompt {
id: number;
name: string;
shared: boolean;
description: string;
system_prompt: string;
task_prompt: string;
@ -21,7 +20,7 @@ export interface Prompt {
export interface Persona {
id: number;
name: string;
shared: boolean;
owner: MinimalUserSnapshot | null;
is_visible: boolean;
is_public: boolean;
display_priority: number | null;
@ -34,5 +33,6 @@ export interface Persona {
llm_model_version_override?: string;
starter_messages: StarterMessage[] | null;
default_persona: boolean;
users: string[];
groups: number[];
}

View File

@ -12,6 +12,7 @@ interface PersonaCreationRequest {
llm_relevance_filter: boolean | null;
llm_model_version_override: string | null;
starter_messages: StarterMessage[] | null;
users?: string[];
groups: number[];
}
@ -29,6 +30,7 @@ interface PersonaUpdateRequest {
llm_relevance_filter: boolean | null;
llm_model_version_override: string | null;
starter_messages: StarterMessage[] | null;
users?: string[];
groups: number[];
}
@ -55,7 +57,6 @@ function createPrompt({
body: JSON.stringify({
name: promptNameFromPersonaName(personaName),
description: `Default prompt for persona ${personaName}`,
shared: true,
system_prompt: systemPrompt,
task_prompt: taskPrompt,
include_citations: includeCitations,
@ -84,7 +85,6 @@ function updatePrompt({
body: JSON.stringify({
name: promptNameFromPersonaName(personaName),
description: `Default prompt for persona ${personaName}`,
shared: true,
system_prompt: systemPrompt,
task_prompt: taskPrompt,
include_citations: includeCitations,
@ -104,12 +104,12 @@ function buildPersonaAPIBody(
llm_relevance_filter,
is_public,
groups,
users,
} = creationRequest;
return {
name,
description,
shared: true,
num_chunks,
llm_relevance_filter,
llm_filter_extraction: false,
@ -119,6 +119,7 @@ function buildPersonaAPIBody(
document_set_ids,
llm_model_version_override: creationRequest.llm_model_version_override,
starter_messages: creationRequest.starter_messages,
users,
groups,
};
}
@ -139,7 +140,7 @@ export async function createPersona(
const createPersonaResponse =
promptId !== null
? await fetch("/api/admin/persona", {
? await fetch("/api/persona", {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -182,7 +183,7 @@ export async function updatePersona(
const updatePersonaResponse =
promptResponse.ok && promptId
? await fetch(`/api/admin/persona/${id}`, {
? await fetch(`/api/persona/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
@ -197,7 +198,7 @@ export async function updatePersona(
}
export function deletePersona(personaId: number) {
return fetch(`/api/admin/persona/${personaId}`, {
return fetch(`/api/persona/${personaId}`, {
method: "DELETE",
});
}

View File

@ -0,0 +1,42 @@
import { AssistantEditor } from "../AssistantEditor";
import { ErrorCallout } from "@/components/ErrorCallout";
import { RobotIcon } from "@/components/icons/icons";
import { BackButton } from "@/components/BackButton";
import { Card } from "@tremor/react";
import { AdminPageTitle } from "@/components/admin/Title";
import { fetchPersonaEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { SuccessfulPersonaUpdateRedirectType } from "../enums";
export default async function Page() {
const [values, error] = await fetchPersonaEditorInfoSS();
let body;
if (!values) {
body = (
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
);
} else {
body = (
<Card>
<AssistantEditor
{...values}
defaultPublic={true}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
</Card>
);
}
return (
<div>
<BackButton />
<AdminPageTitle
title="Create a New Persona"
icon={<RobotIcon size={32} />}
/>
{body}
</div>
);
}

View File

@ -24,11 +24,11 @@ export default async function Page() {
return (
<div className="mx-auto container">
<AdminPageTitle icon={<RobotIcon size={32} />} title="Personas" />
<AdminPageTitle icon={<RobotIcon size={32} />} title="Assistants" />
<Text className="mb-2">
Personas are a way to build custom search/question-answering experiences
for different use cases.
Assistants are a way to build custom search/question-answering
experiences for different use cases.
</Text>
<Text className="mt-2">They allow you to customize:</Text>
<div className="text-sm">
@ -43,20 +43,20 @@ export default async function Page() {
<div>
<Divider />
<Title>Create a Persona</Title>
<Title>Create an Assistant</Title>
<Link
href="/admin/personas/new"
className="flex py-2 px-4 mt-2 border border-border h-fit cursor-pointer hover:bg-hover text-sm w-36"
className="flex py-2 px-4 mt-2 border border-border h-fit cursor-pointer hover:bg-hover text-sm w-40"
>
<div className="mx-auto flex">
<FiPlusSquare className="my-auto mr-2" />
New Persona
New Assistant
</div>
</Link>
<Divider />
<Title>Existing Personas</Title>
<Title>Existing Assistants</Title>
<PersonasTable personas={personas} />
</div>
</div>

View File

@ -28,7 +28,7 @@ import {
Text,
} from "@tremor/react";
import { useRouter } from "next/navigation";
import { Persona } from "../personas/interfaces";
import { Persona } from "../assistants/interfaces";
import { useState } from "react";
import { BookmarkIcon, RobotIcon } from "@/components/icons/icons";

View File

@ -6,7 +6,7 @@ import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet, SlackBotConfig } from "@/lib/types";
import { Text } from "@tremor/react";
import { BackButton } from "@/components/BackButton";
import { Persona } from "../../personas/interfaces";
import { Persona } from "../../assistants/interfaces";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
async function Page({ params }: { params: { id: string } }) {

View File

@ -3,7 +3,7 @@ import {
SlackBotResponseType,
SlackBotTokens,
} from "@/lib/types";
import { Persona } from "../personas/interfaces";
import { Persona } from "../assistants/interfaces";
interface SlackBotConfigCreationRequest {
document_sets: number[];

View File

@ -6,7 +6,7 @@ import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet } from "@/lib/types";
import { BackButton } from "@/components/BackButton";
import { Text } from "@tremor/react";
import { Persona } from "../../personas/interfaces";
import { Persona } from "../../assistants/interfaces";
async function Page() {
const tasks = [fetchSS("/manage/document-set"), fetchSS("/persona")];

View File

@ -1,92 +0,0 @@
import { ErrorCallout } from "@/components/ErrorCallout";
import { fetchSS } from "@/lib/utilsSS";
import { Persona } from "../interfaces";
import { PersonaEditor } from "../PersonaEditor";
import { DocumentSet } from "@/lib/types";
import { BackButton } from "@/components/BackButton";
import { Card, Title } from "@tremor/react";
import { DeletePersonaButton } from "./DeletePersonaButton";
export default async function Page({
params,
}: {
params: { personaId: string };
}) {
const [
personaResponse,
documentSetsResponse,
llmOverridesResponse,
defaultLLMResponse,
] = await Promise.all([
fetchSS(`/persona/${params.personaId}`),
fetchSS("/manage/document-set"),
fetchSS("/admin/persona/utils/list-available-models"),
fetchSS("/admin/persona/utils/default-model"),
]);
if (!personaResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch Persona - ${await personaResponse.text()}`}
/>
);
}
if (!documentSetsResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch document sets - ${await documentSetsResponse.text()}`}
/>
);
}
if (!llmOverridesResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch LLM override options - ${await documentSetsResponse.text()}`}
/>
);
}
if (!defaultLLMResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch default LLM - ${await documentSetsResponse.text()}`}
/>
);
}
const documentSets = (await documentSetsResponse.json()) as DocumentSet[];
const persona = (await personaResponse.json()) as Persona;
const llmOverrideOptions = (await llmOverridesResponse.json()) as string[];
const defaultLLM = (await defaultLLMResponse.json()) as string;
return (
<div>
<BackButton />
<div className="pb-2 mb-4 flex">
<h1 className="text-3xl font-bold pl-2">Edit Persona</h1>
</div>
<Card>
<PersonaEditor
existingPersona={persona}
documentSets={documentSets}
llmOverrideOptions={llmOverrideOptions}
defaultLLM={defaultLLM}
/>
</Card>
<div className="mt-12">
<Title>Delete Persona</Title>
<div className="flex mt-6">
<DeletePersonaButton personaId={persona.id} />
</div>
</div>
</div>
);
}

View File

@ -1,66 +0,0 @@
import { PersonaEditor } from "../PersonaEditor";
import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { DocumentSet } from "@/lib/types";
import { RobotIcon } from "@/components/icons/icons";
import { BackButton } from "@/components/BackButton";
import { Card } from "@tremor/react";
import { AdminPageTitle } from "@/components/admin/Title";
export default async function Page() {
const [documentSetsResponse, llmOverridesResponse, defaultLLMResponse] =
await Promise.all([
fetchSS("/manage/document-set"),
fetchSS("/admin/persona/utils/list-available-models"),
fetchSS("/admin/persona/utils/default-model"),
]);
if (!documentSetsResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch document sets - ${await documentSetsResponse.text()}`}
/>
);
}
const documentSets = (await documentSetsResponse.json()) as DocumentSet[];
if (!llmOverridesResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch LLM override options - ${await documentSetsResponse.text()}`}
/>
);
}
const llmOverrideOptions = (await llmOverridesResponse.json()) as string[];
if (!defaultLLMResponse.ok) {
return (
<ErrorCallout
errorTitle="Something went wrong :("
errorMsg={`Failed to fetch default LLM - ${await documentSetsResponse.text()}`}
/>
);
}
const defaultLLM = (await defaultLLMResponse.json()) as string;
return (
<div>
<BackButton />
<AdminPageTitle
title="Create a New Persona"
icon={<RobotIcon size={32} />}
/>
<Card>
<PersonaEditor
documentSets={documentSets}
llmOverrideOptions={llmOverrideOptions}
defaultLLM={defaultLLM}
/>
</Card>
</div>
);
}

View File

@ -3,3 +3,19 @@ export interface Settings {
search_page_enabled: boolean;
default_page: "search" | "chat";
}
export interface EnterpriseSettings {
application_name: string | null;
use_custom_logo: boolean;
// custom Chat components
custom_header_content: string | null;
custom_popup_header: string | null;
custom_popup_content: string | null;
}
export interface CombinedSettings {
settings: Settings;
enterpriseSettings: EnterpriseSettings | null;
customAnalyticsScript: string | null;
}

View File

@ -0,0 +1,59 @@
import { ErrorCallout } from "@/components/ErrorCallout";
import { Card } from "@tremor/react";
import { HeaderWrapper } from "@/components/header/HeaderWrapper";
import { FiChevronLeft } from "react-icons/fi";
import Link from "next/link";
import { AssistantEditor } from "@/app/admin/assistants/AssistantEditor";
import { SuccessfulPersonaUpdateRedirectType } from "@/app/admin/assistants/enums";
import { fetchPersonaEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
export default async function Page({ params }: { params: { id: string } }) {
const [values, error] = await fetchPersonaEditorInfoSS(params.id);
let body;
if (!values) {
body = (
<div className="px-32">
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
</div>
);
} else {
body = (
<div className="w-full my-16">
<div className="px-32">
<div className="mx-auto container">
<Card>
<AssistantEditor
{...values}
defaultPublic={false}
redirectType={SuccessfulPersonaUpdateRedirectType.CHAT}
/>
</Card>
</div>
</div>
</div>
);
}
return (
<div>
<HeaderWrapper>
<div className="h-full flex flex-col">
<div className="flex my-auto">
<Link href="/chat">
<FiChevronLeft
className="mr-1 my-auto p-1 hover:bg-hover rounded cursor-pointer"
size={32}
/>
</Link>
<h1 className="flex text-xl text-strong font-bold my-auto">
Edit Assistant
</h1>
</div>
</div>
</HeaderWrapper>
{body}
</div>
);
}

View File

@ -0,0 +1,59 @@
import { Card } from "@tremor/react";
import { HeaderWrapper } from "@/components/header/HeaderWrapper";
import { FiChevronLeft } from "react-icons/fi";
import Link from "next/link";
import { AssistantEditor } from "@/app/admin/assistants/AssistantEditor";
import { SuccessfulPersonaUpdateRedirectType } from "@/app/admin/assistants/enums";
import { fetchPersonaEditorInfoSS } from "@/lib/assistants/fetchPersonaEditorInfoSS";
import { ErrorCallout } from "@/components/ErrorCallout";
export default async function Page() {
const [values, error] = await fetchPersonaEditorInfoSS();
let body;
if (!values) {
body = (
<div className="px-32">
<ErrorCallout errorTitle="Something went wrong :(" errorMsg={error} />
</div>
);
} else {
body = (
<div className="w-full my-16">
<div className="px-32">
<div className="mx-auto container">
<Card>
<AssistantEditor
{...values}
defaultPublic={false}
redirectType={SuccessfulPersonaUpdateRedirectType.CHAT}
/>
</Card>
</div>
</div>
</div>
);
}
return (
<div>
<HeaderWrapper>
<div className="h-full flex flex-col">
<div className="flex my-auto">
<Link href="/chat">
<FiChevronLeft
className="mr-1 my-auto p-1 hover:bg-hover rounded cursor-pointer"
size={32}
/>
</Link>
<h1 className="flex text-xl text-strong font-bold my-auto">
New Assistant
</h1>
</div>
</div>
</HeaderWrapper>
{body}
</div>
);
}

View File

@ -1,966 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { FiSend, FiShare2, FiStopCircle } from "react-icons/fi";
import { AIMessage, HumanMessage } from "./message/Messages";
import { AnswerPiecePacket, DanswerDocument } from "@/lib/search/interfaces";
import {
BackendChatSession,
BackendMessage,
ChatSessionSharedStatus,
DocumentsResponse,
Message,
RetrievalType,
StreamingError,
} from "./interfaces";
import { useRouter, useSearchParams } from "next/navigation";
import { FeedbackType } from "./types";
import {
buildChatUrl,
createChatSession,
getCitedDocumentsFromMessage,
getHumanAndAIMessageFromMessageNumber,
getLastSuccessfulMessageId,
handleAutoScroll,
handleChatFeedback,
nameChatSession,
personaIncludesRetrieval,
processRawChatHistory,
sendMessage,
} from "./lib";
import { ThreeDots } from "react-loader-spinner";
import { FeedbackModal } from "./modal/FeedbackModal";
import { DocumentSidebar } from "./documentSidebar/DocumentSidebar";
import { Persona } from "../admin/personas/interfaces";
import { ChatPersonaSelector } from "./ChatPersonaSelector";
import { useFilters } from "@/lib/hooks";
import { DocumentSet, Tag, ValidSources } from "@/lib/types";
import { ChatFilters } from "./modifiers/ChatFilters";
import { buildFilters } from "@/lib/search/utils";
import { SelectedDocuments } from "./modifiers/SelectedDocuments";
import { usePopup } from "@/components/admin/connectors/Popup";
import { ResizableSection } from "@/components/resizable/ResizableSection";
import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader";
import { ChatIntro } from "./ChatIntro";
import { HEADER_PADDING } from "@/lib/constants";
import { computeAvailableFilters } from "@/lib/filters";
import { useDocumentSelection } from "./useDocumentSelection";
import { StarterMessage } from "./StarterMessage";
import { ShareChatSessionModal } from "./modal/ShareChatSessionModal";
import { SEARCH_PARAM_NAMES, shouldSubmitOnLoad } from "./searchParams";
const MAX_INPUT_HEIGHT = 200;
export const Chat = ({
existingChatSessionId,
existingChatSessionPersonaId,
availableSources,
availableDocumentSets,
availablePersonas,
availableTags,
defaultSelectedPersonaId,
documentSidebarInitialWidth,
shouldhideBeforeScroll,
}: {
existingChatSessionId: number | null;
existingChatSessionPersonaId: number | undefined;
availableSources: ValidSources[];
availableDocumentSets: DocumentSet[];
availablePersonas: Persona[];
availableTags: Tag[];
defaultSelectedPersonaId?: number; // what persona to default to
documentSidebarInitialWidth?: number;
shouldhideBeforeScroll?: boolean;
}) => {
const router = useRouter();
const searchParams = useSearchParams();
// used to track whether or not the initial "submit on load" has been performed
// this only applies if `?submit-on-load=true` or `?submit-on-load=1` is in the URL
// NOTE: this is required due to React strict mode, where all `useEffect` hooks
// are run twice on initial load during development
const submitOnLoadPerformed = useRef<boolean>(false);
const { popup, setPopup } = usePopup();
// fetch messages for the chat session
const [isFetchingChatMessages, setIsFetchingChatMessages] = useState(
existingChatSessionId !== null
);
// needed so closures (e.g. onSubmit) can access the current value
const urlChatSessionId = useRef<number | null>();
// this is triggered every time the user switches which chat
// session they are using
useEffect(() => {
urlChatSessionId.current = existingChatSessionId;
textareaRef.current?.focus();
// only clear things if we're going from one chat session to another
if (chatSessionId !== null && existingChatSessionId !== chatSessionId) {
// de-select documents
clearSelectedDocuments();
// reset all filters
filterManager.setSelectedDocumentSets([]);
filterManager.setSelectedSources([]);
filterManager.setSelectedTags([]);
filterManager.setTimeRange(null);
if (isStreaming) {
setIsCancelled(true);
}
}
setChatSessionId(existingChatSessionId);
async function initialSessionFetch() {
if (existingChatSessionId === null) {
setIsFetchingChatMessages(false);
if (defaultSelectedPersonaId !== undefined) {
setSelectedPersona(
availablePersonas.find(
(persona) => persona.id === defaultSelectedPersonaId
)
);
} else {
setSelectedPersona(undefined);
}
setMessageHistory([]);
setChatSessionSharedStatus(ChatSessionSharedStatus.Private);
// if we're supposed to submit on initial load, then do that here
if (
shouldSubmitOnLoad(searchParams) &&
!submitOnLoadPerformed.current
) {
submitOnLoadPerformed.current = true;
await onSubmit();
}
return;
}
setIsFetchingChatMessages(true);
const response = await fetch(
`/api/chat/get-chat-session/${existingChatSessionId}`
);
const chatSession = (await response.json()) as BackendChatSession;
setSelectedPersona(
availablePersonas.find(
(persona) => persona.id === chatSession.persona_id
)
);
const newMessageHistory = processRawChatHistory(chatSession.messages);
setMessageHistory(newMessageHistory);
const latestMessageId =
newMessageHistory[newMessageHistory.length - 1]?.messageId;
setSelectedMessageForDocDisplay(
latestMessageId !== undefined ? latestMessageId : null
);
setChatSessionSharedStatus(chatSession.shared_status);
setIsFetchingChatMessages(false);
// if this is a seeded chat, then kick off the AI message generation
if (newMessageHistory.length === 1 && !submitOnLoadPerformed.current) {
submitOnLoadPerformed.current = true;
const seededMessage = newMessageHistory[0].message;
await onSubmit({
isSeededChat: true,
messageOverride: seededMessage,
});
// force re-name if the chat session doesn't have one
if (!chatSession.description) {
await nameChatSession(existingChatSessionId, seededMessage);
router.refresh(); // need to refresh to update name on sidebar
}
}
}
initialSessionFetch();
}, [existingChatSessionId]);
const [chatSessionId, setChatSessionId] = useState<number | null>(
existingChatSessionId
);
const [message, setMessage] = useState(
searchParams.get(SEARCH_PARAM_NAMES.USER_MESSAGE) || ""
);
const [messageHistory, setMessageHistory] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
// for document display
// NOTE: -1 is a special designation that means the latest AI message
const [selectedMessageForDocDisplay, setSelectedMessageForDocDisplay] =
useState<number | null>(null);
const { aiMessage } = selectedMessageForDocDisplay
? getHumanAndAIMessageFromMessageNumber(
messageHistory,
selectedMessageForDocDisplay
)
: { aiMessage: null };
const [selectedPersona, setSelectedPersona] = useState<Persona | undefined>(
existingChatSessionPersonaId !== undefined
? availablePersonas.find(
(persona) => persona.id === existingChatSessionPersonaId
)
: defaultSelectedPersonaId !== undefined
? availablePersonas.find(
(persona) => persona.id === defaultSelectedPersonaId
)
: undefined
);
const livePersona = selectedPersona || availablePersonas[0];
const [chatSessionSharedStatus, setChatSessionSharedStatus] =
useState<ChatSessionSharedStatus>(ChatSessionSharedStatus.Private);
useEffect(() => {
if (messageHistory.length === 0 && chatSessionId === null) {
setSelectedPersona(
availablePersonas.find(
(persona) => persona.id === defaultSelectedPersonaId
)
);
}
}, [defaultSelectedPersonaId]);
const [
selectedDocuments,
toggleDocumentSelection,
clearSelectedDocuments,
selectedDocumentTokens,
] = useDocumentSelection();
// just choose a conservative default, this will be updated in the
// background on initial load / on persona change
const [maxTokens, setMaxTokens] = useState<number>(4096);
// fetch # of allowed document tokens for the selected Persona
useEffect(() => {
async function fetchMaxTokens() {
const response = await fetch(
`/api/chat/max-selected-document-tokens?persona_id=${livePersona.id}`
);
if (response.ok) {
const maxTokens = (await response.json()).max_tokens as number;
setMaxTokens(maxTokens);
}
}
fetchMaxTokens();
}, [livePersona]);
const filterManager = useFilters();
const [finalAvailableSources, finalAvailableDocumentSets] =
computeAvailableFilters({
selectedPersona,
availableSources,
availableDocumentSets,
});
// state for cancelling streaming
const [isCancelled, setIsCancelled] = useState(false);
const isCancelledRef = useRef(isCancelled);
useEffect(() => {
isCancelledRef.current = isCancelled;
}, [isCancelled]);
const [currentFeedback, setCurrentFeedback] = useState<
[FeedbackType, number] | null
>(null);
const [sharingModalVisible, setSharingModalVisible] =
useState<boolean>(false);
// auto scroll as message comes out
const scrollableDivRef = useRef<HTMLDivElement>(null);
const endDivRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isStreaming || !message) {
handleAutoScroll(endDivRef, scrollableDivRef);
}
});
// scroll to bottom initially
const [hasPerformedInitialScroll, setHasPerformedInitialScroll] = useState(
shouldhideBeforeScroll !== true
);
useEffect(() => {
endDivRef.current?.scrollIntoView();
setHasPerformedInitialScroll(true);
}, [isFetchingChatMessages]);
// handle re-sizing of the text area
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = "0px";
textarea.style.height = `${Math.min(
textarea.scrollHeight,
MAX_INPUT_HEIGHT
)}px`;
}
}, [message]);
// used for resizing of the document sidebar
const masterFlexboxRef = useRef<HTMLDivElement>(null);
const [maxDocumentSidebarWidth, setMaxDocumentSidebarWidth] = useState<
number | null
>(null);
const adjustDocumentSidebarWidth = () => {
if (masterFlexboxRef.current && document.documentElement.clientWidth) {
// numbers below are based on the actual width the center section for different
// screen sizes. `1700` corresponds to the custom "3xl" tailwind breakpoint
// NOTE: some buffer is needed to account for scroll bars
if (document.documentElement.clientWidth > 1700) {
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 950);
} else if (document.documentElement.clientWidth > 1420) {
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 760);
} else {
setMaxDocumentSidebarWidth(masterFlexboxRef.current.clientWidth - 660);
}
}
};
useEffect(() => {
adjustDocumentSidebarWidth(); // Adjust the width on initial render
window.addEventListener("resize", adjustDocumentSidebarWidth); // Add resize event listener
return () => {
window.removeEventListener("resize", adjustDocumentSidebarWidth); // Cleanup the event listener
};
}, []);
if (!documentSidebarInitialWidth && maxDocumentSidebarWidth) {
documentSidebarInitialWidth = Math.min(700, maxDocumentSidebarWidth);
}
const onSubmit = async ({
messageIdToResend,
messageOverride,
queryOverride,
forceSearch,
isSeededChat,
}: {
messageIdToResend?: number;
messageOverride?: string;
queryOverride?: string;
forceSearch?: boolean;
isSeededChat?: boolean;
} = {}) => {
let currChatSessionId: number;
let isNewSession = chatSessionId === null;
const searchParamBasedChatSessionName =
searchParams.get(SEARCH_PARAM_NAMES.TITLE) || null;
if (isNewSession) {
currChatSessionId = await createChatSession(
livePersona?.id || 0,
searchParamBasedChatSessionName
);
} else {
currChatSessionId = chatSessionId as number;
}
setChatSessionId(currChatSessionId);
const messageToResend = messageHistory.find(
(message) => message.messageId === messageIdToResend
);
const messageToResendIndex = messageToResend
? messageHistory.indexOf(messageToResend)
: null;
if (!messageToResend && messageIdToResend !== undefined) {
setPopup({
message:
"Failed to re-send message - please refresh the page and try again.",
type: "error",
});
return;
}
let currMessage = messageToResend ? messageToResend.message : message;
if (messageOverride) {
currMessage = messageOverride;
}
const currMessageHistory =
messageToResendIndex !== null
? messageHistory.slice(0, messageToResendIndex)
: messageHistory;
setMessageHistory([
...currMessageHistory,
{
messageId: 0,
message: currMessage,
type: "user",
},
]);
setMessage("");
setIsStreaming(true);
let answer = "";
let query: string | null = null;
let retrievalType: RetrievalType =
selectedDocuments.length > 0
? RetrievalType.SelectedDocs
: RetrievalType.None;
let documents: DanswerDocument[] = selectedDocuments;
let error: string | null = null;
let finalMessage: BackendMessage | null = null;
try {
const lastSuccessfulMessageId =
getLastSuccessfulMessageId(currMessageHistory);
for await (const packetBunch of sendMessage({
message: currMessage,
parentMessageId: lastSuccessfulMessageId,
chatSessionId: currChatSessionId,
promptId: livePersona?.prompts[0]?.id || 0,
filters: buildFilters(
filterManager.selectedSources,
filterManager.selectedDocumentSets,
filterManager.timeRange,
filterManager.selectedTags
),
selectedDocumentIds: selectedDocuments
.filter(
(document) =>
document.db_doc_id !== undefined && document.db_doc_id !== null
)
.map((document) => document.db_doc_id as number),
queryOverride,
forceSearch,
modelVersion:
searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) || undefined,
temperature:
parseFloat(searchParams.get(SEARCH_PARAM_NAMES.TEMPERATURE) || "") ||
undefined,
systemPromptOverride:
searchParams.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined,
useExistingUserMessage: isSeededChat,
})) {
for (const packet of packetBunch) {
if (Object.hasOwn(packet, "answer_piece")) {
answer += (packet as AnswerPiecePacket).answer_piece;
} else if (Object.hasOwn(packet, "top_documents")) {
documents = (packet as DocumentsResponse).top_documents;
query = (packet as DocumentsResponse).rephrased_query;
retrievalType = RetrievalType.Search;
if (documents && documents.length > 0) {
// point to the latest message (we don't know the messageId yet, which is why
// we have to use -1)
setSelectedMessageForDocDisplay(-1);
}
} else if (Object.hasOwn(packet, "error")) {
error = (packet as StreamingError).error;
} else if (Object.hasOwn(packet, "message_id")) {
finalMessage = packet as BackendMessage;
}
}
setMessageHistory([
...currMessageHistory,
{
messageId: finalMessage?.parent_message || null,
message: currMessage,
type: "user",
},
{
messageId: finalMessage?.message_id || null,
message: error || answer,
type: error ? "error" : "assistant",
retrievalType,
query: finalMessage?.rephrased_query || query,
documents: finalMessage?.context_docs?.top_documents || documents,
citations: finalMessage?.citations || {},
},
]);
if (isCancelledRef.current) {
setIsCancelled(false);
break;
}
}
} catch (e: any) {
const errorMsg = e.message;
setMessageHistory([
...currMessageHistory,
{
messageId: null,
message: currMessage,
type: "user",
},
{
messageId: null,
message: errorMsg,
type: "error",
},
]);
}
setIsStreaming(false);
if (isNewSession) {
if (finalMessage) {
setSelectedMessageForDocDisplay(finalMessage.message_id);
}
if (!searchParamBasedChatSessionName) {
await nameChatSession(currChatSessionId, currMessage);
}
// NOTE: don't switch pages if the user has navigated away from the chat
if (
currChatSessionId === urlChatSessionId.current ||
urlChatSessionId.current === null
) {
router.push(buildChatUrl(searchParams, currChatSessionId, null), {
scroll: false,
});
}
}
if (
finalMessage?.context_docs &&
finalMessage.context_docs.top_documents.length > 0 &&
retrievalType === RetrievalType.Search
) {
setSelectedMessageForDocDisplay(finalMessage.message_id);
}
};
const onFeedback = async (
messageId: number,
feedbackType: FeedbackType,
feedbackDetails: string
) => {
if (chatSessionId === null) {
return;
}
const response = await handleChatFeedback(
messageId,
feedbackType,
feedbackDetails
);
if (response.ok) {
setPopup({
message: "Thanks for your feedback!",
type: "success",
});
} else {
const responseJson = await response.json();
const errorMsg = responseJson.detail || responseJson.message;
setPopup({
message: `Failed to submit feedback - ${errorMsg}`,
type: "error",
});
}
};
const retrievalDisabled = !personaIncludesRetrieval(livePersona);
return (
<div className="flex w-full overflow-x-hidden" ref={masterFlexboxRef}>
{popup}
{currentFeedback && (
<FeedbackModal
feedbackType={currentFeedback[0]}
onClose={() => setCurrentFeedback(null)}
onSubmit={(feedbackDetails) => {
onFeedback(currentFeedback[1], currentFeedback[0], feedbackDetails);
setCurrentFeedback(null);
}}
/>
)}
{sharingModalVisible && chatSessionId !== null && (
<ShareChatSessionModal
chatSessionId={chatSessionId}
existingSharedStatus={chatSessionSharedStatus}
onClose={() => setSharingModalVisible(false)}
onShare={(shared) =>
setChatSessionSharedStatus(
shared
? ChatSessionSharedStatus.Public
: ChatSessionSharedStatus.Private
)
}
/>
)}
{documentSidebarInitialWidth !== undefined ? (
<>
<div
className={`w-full sm:relative h-screen ${
retrievalDisabled ? "pb-[111px]" : "pb-[140px]"
}`}
>
<div
className={`w-full h-full ${HEADER_PADDING} flex flex-col overflow-y-auto overflow-x-hidden relative`}
ref={scrollableDivRef}
>
{livePersona && (
<div className="sticky top-0 left-80 z-10 w-full bg-background/90 flex">
<div className="ml-2 p-1 rounded mt-2 w-fit">
<ChatPersonaSelector
personas={availablePersonas}
selectedPersonaId={livePersona.id}
onPersonaChange={(persona) => {
if (persona) {
setSelectedPersona(persona);
textareaRef.current?.focus();
router.push(
buildChatUrl(searchParams, null, persona.id)
);
}
}}
/>
</div>
{chatSessionId !== null && (
<div
onClick={() => setSharingModalVisible(true)}
className="ml-auto mr-6 my-auto border-border border p-2 rounded cursor-pointer hover:bg-hover-light"
>
<FiShare2 />
</div>
)}
</div>
)}
{messageHistory.length === 0 &&
!isFetchingChatMessages &&
!isStreaming && (
<ChatIntro
availableSources={finalAvailableSources}
availablePersonas={availablePersonas}
selectedPersona={selectedPersona}
handlePersonaSelect={(persona) => {
setSelectedPersona(persona);
textareaRef.current?.focus();
router.push(buildChatUrl(searchParams, null, persona.id));
}}
/>
)}
<div
className={
"mt-4 pt-12 sm:pt-0 mx-8" +
(hasPerformedInitialScroll ? "" : " invisible")
}
>
{messageHistory.map((message, i) => {
if (message.type === "user") {
return (
<div key={i}>
<HumanMessage content={message.message} />
</div>
);
} else if (message.type === "assistant") {
const isShowingRetrieved =
(selectedMessageForDocDisplay !== null &&
selectedMessageForDocDisplay === message.messageId) ||
(selectedMessageForDocDisplay === -1 &&
i === messageHistory.length - 1);
const previousMessage =
i !== 0 ? messageHistory[i - 1] : null;
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
content={message.message}
query={messageHistory[i]?.query || undefined}
personaName={livePersona.name}
citedDocuments={getCitedDocumentsFromMessage(message)}
isComplete={
i !== messageHistory.length - 1 || !isStreaming
}
hasDocs={
(message.documents &&
message.documents.length > 0) === true
}
handleFeedback={
i === messageHistory.length - 1 && isStreaming
? undefined
: (feedbackType) =>
setCurrentFeedback([
feedbackType,
message.messageId as number,
])
}
handleSearchQueryEdit={
i === messageHistory.length - 1 && !isStreaming
? (newQuery) => {
if (!previousMessage) {
setPopup({
type: "error",
message:
"Cannot edit query of first message - please refresh the page and try again.",
});
return;
}
if (previousMessage.messageId === null) {
setPopup({
type: "error",
message:
"Cannot edit query of a pending message - please wait a few seconds and try again.",
});
return;
}
onSubmit({
messageIdToResend:
previousMessage.messageId,
queryOverride: newQuery,
});
}
: undefined
}
isCurrentlyShowingRetrieved={isShowingRetrieved}
handleShowRetrieved={(messageNumber) => {
if (isShowingRetrieved) {
setSelectedMessageForDocDisplay(null);
} else {
if (messageNumber !== null) {
setSelectedMessageForDocDisplay(messageNumber);
} else {
setSelectedMessageForDocDisplay(-1);
}
}
}}
handleForceSearch={() => {
if (previousMessage && previousMessage.messageId) {
onSubmit({
messageIdToResend: previousMessage.messageId,
forceSearch: true,
});
} else {
setPopup({
type: "error",
message:
"Failed to force search - please refresh the page and try again.",
});
}
}}
retrievalDisabled={retrievalDisabled}
/>
</div>
);
} else {
return (
<div key={i}>
<AIMessage
messageId={message.messageId}
personaName={livePersona.name}
content={
<p className="text-red-700 text-sm my-auto">
{message.message}
</p>
}
/>
</div>
);
}
})}
{isStreaming &&
messageHistory.length &&
messageHistory[messageHistory.length - 1].type === "user" && (
<div key={messageHistory.length}>
<AIMessage
messageId={null}
personaName={livePersona.name}
content={
<div className="text-sm my-auto">
<ThreeDots
height="30"
width="50"
color="#3b82f6"
ariaLabel="grid-loading"
radius="12.5"
wrapperStyle={{}}
wrapperClass=""
visible={true}
/>
</div>
}
/>
</div>
)}
{/* Some padding at the bottom so the search bar has space at the bottom to not cover the last message*/}
<div className={`min-h-[30px] w-full`}></div>
{livePersona &&
livePersona.starter_messages &&
livePersona.starter_messages.length > 0 &&
selectedPersona &&
messageHistory.length === 0 &&
!isFetchingChatMessages && (
<div
className={`
mx-auto
px-4
w-searchbar-xs
2xl:w-searchbar-sm
3xl:w-searchbar
grid
gap-4
grid-cols-1
grid-rows-1
mt-4
md:grid-cols-2
mb-6`}
>
{livePersona.starter_messages.map((starterMessage, i) => (
<div key={i} className="w-full">
<StarterMessage
starterMessage={starterMessage}
onClick={() =>
onSubmit({
messageOverride: starterMessage.message,
})
}
/>
</div>
))}
</div>
)}
<div ref={endDivRef} />
</div>
</div>
<div className="absolute bottom-0 z-10 w-full bg-background border-t border-border">
<div className="w-full pb-4 pt-2">
{!retrievalDisabled && (
<div className="flex">
<div className="w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar mx-auto px-4 pt-1 flex">
{selectedDocuments.length > 0 ? (
<SelectedDocuments
selectedDocuments={selectedDocuments}
/>
) : (
<ChatFilters
{...filterManager}
existingSources={finalAvailableSources}
availableDocumentSets={finalAvailableDocumentSets}
availableTags={availableTags}
/>
)}
</div>
</div>
)}
<div className="flex justify-center py-2 max-w-screen-lg mx-auto mb-2">
<div className="w-full shrink relative px-4 w-searchbar-xs 2xl:w-searchbar-sm 3xl:w-searchbar mx-auto">
<textarea
ref={textareaRef}
autoFocus
className={`
opacity-100
w-full
shrink
border
border-border
rounded-lg
outline-none
placeholder-gray-400
pl-4
pr-12
py-4
overflow-hidden
h-14
${
(textareaRef?.current?.scrollHeight || 0) >
MAX_INPUT_HEIGHT
? "overflow-y-auto"
: ""
}
whitespace-normal
break-word
overscroll-contain
resize-none
`}
style={{ scrollbarWidth: "thin" }}
role="textarea"
aria-multiline
placeholder="Ask me anything..."
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
message &&
!isStreaming
) {
onSubmit();
event.preventDefault();
}
}}
suppressContentEditableWarning={true}
/>
<div className="absolute bottom-4 right-10">
<div
className={"cursor-pointer"}
onClick={() => {
if (!isStreaming) {
if (message) {
onSubmit();
}
} else {
setIsCancelled(true);
}
}}
>
{isStreaming ? (
<FiStopCircle
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg hover:bg-hover"
}
/>
) : (
<FiSend
size={18}
className={
"text-emphasis w-9 h-9 p-2 rounded-lg " +
(message ? "bg-blue-200" : "")
}
/>
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{!retrievalDisabled ? (
<ResizableSection
intialWidth={documentSidebarInitialWidth}
minWidth={400}
maxWidth={maxDocumentSidebarWidth || undefined}
>
<DocumentSidebar
selectedMessage={aiMessage}
selectedDocuments={selectedDocuments}
toggleDocumentSelection={toggleDocumentSelection}
clearSelectedDocuments={clearSelectedDocuments}
selectedDocumentTokens={selectedDocumentTokens}
maxTokens={maxTokens}
isLoading={isFetchingChatMessages}
/>
</ResizableSection>
) : // Another option is to use a div with the width set to the initial width, so that the
// chat section appears in the same place as before
// <div style={documentSidebarInitialWidth ? {width: documentSidebarInitialWidth} : {}}></div>
null}
</>
) : (
<div className="mx-auto h-full flex flex-col">
<div className="my-auto">
<DanswerInitializingLoader />
</div>
</div>
)}
</div>
);
};

View File

@ -1,7 +1,7 @@
import { getSourceMetadataForSources, listSourceMetadata } from "@/lib/sources";
import { ValidSources } from "@/lib/types";
import Image from "next/image";
import { Persona } from "../admin/personas/interfaces";
import { Persona } from "../admin/assistants/interfaces";
import { Divider } from "@tremor/react";
import { FiBookmark, FiCpu, FiInfo, FiX, FiZoomIn } from "react-icons/fi";
import { HoverPopup } from "@/components/HoverPopup";

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { FiCheck, FiChevronDown } from "react-icons/fi";
import { CustomDropdown } from "@/components/Dropdown";

View File

@ -1,4 +1,4 @@
import { StarterMessage } from "../admin/personas/interfaces";
import { StarterMessage } from "../admin/assistants/interfaces";
export function StarterMessage({
starterMessage,

View File

@ -14,7 +14,7 @@ import {
RetrievalType,
StreamingError,
} from "./interfaces";
import { Persona } from "../admin/personas/interfaces";
import { Persona } from "../admin/assistants/interfaces";
import { ReadonlyURLSearchParams } from "next/navigation";
import { SEARCH_PARAM_NAMES } from "./searchParams";
@ -402,7 +402,7 @@ export function buildChatUrl(
if (chatSessionId) {
finalSearchParams.push(`${SEARCH_PARAM_NAMES.CHAT_ID}=${chatSessionId}`);
}
if (personaId) {
if (personaId !== null) {
finalSearchParams.push(`${SEARCH_PARAM_NAMES.PERSONA_ID}=${personaId}`);
}

View File

@ -14,7 +14,7 @@ import {
} from "@/lib/types";
import { ChatSession } from "./interfaces";
import { unstable_noStore as noStore } from "next/cache";
import { Persona } from "../admin/personas/interfaces";
import { Persona } from "../admin/assistants/interfaces";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import {
WelcomeModal,
@ -23,12 +23,12 @@ import {
import { ApiKeyModal } from "@/components/openai/ApiKeyModal";
import { cookies } from "next/headers";
import { DOCUMENT_SIDEBAR_WIDTH_COOKIE_NAME } from "@/components/resizable/contants";
import { personaComparator } from "../admin/personas/lib";
import { personaComparator } from "../admin/assistants/lib";
import { ChatLayout } from "./ChatPage";
import { FullEmbeddingModelResponse } from "../admin/models/embedding/embeddingModels";
import { NoCompleteSourcesModal } from "@/components/initialSetup/search/NoCompleteSourceModal";
import { getSettingsSS } from "@/lib/settings";
import { Settings } from "../admin/settings/interfaces";
import { SIDEBAR_TAB_COOKIE, Tabs } from "./sessionSidebar/constants";
export default async function Page({
searchParams,
@ -45,7 +45,6 @@ export default async function Page({
fetchSS("/persona?include_default=true"),
fetchSS("/chat/get-user-chat-sessions"),
fetchSS("/query/valid-tags"),
getSettingsSS(),
];
// catch cases where the backend is completely unreachable here
@ -58,7 +57,7 @@ export default async function Page({
| FullEmbeddingModelResponse
| Settings
| null
)[] = [null, null, null, null, null, null, null, null, null, null];
)[] = [null, null, null, null, null, null, null, null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
@ -71,7 +70,6 @@ export default async function Page({
const personasResponse = results[4] as Response | null;
const chatSessionsResponse = results[5] as Response | null;
const tagsResponse = results[6] as Response | null;
const settings = results[7] as Settings | null;
const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) {
@ -82,10 +80,6 @@ export default async function Page({
return redirect("/auth/waiting-on-verification");
}
if (settings && !settings.chat_page_enabled) {
return redirect("/search");
}
let ccPairs: CCPairBasicInfo[] = [];
if (ccPairsResponse?.ok) {
ccPairs = await ccPairsResponse.json();
@ -137,7 +131,7 @@ export default async function Page({
console.log(`Failed to fetch tags - ${tagsResponse?.status}`);
}
const defaultPersonaIdRaw = searchParams["personaId"];
const defaultPersonaIdRaw = searchParams["assistantId"];
const defaultPersonaId = defaultPersonaIdRaw
? parseInt(defaultPersonaIdRaw)
: undefined;
@ -149,6 +143,10 @@ export default async function Page({
? parseInt(documentSidebarCookieInitialWidth.value)
: undefined;
const defaultSidebarTab = cookies().get(SIDEBAR_TAB_COOKIE)?.value as
| Tabs
| undefined;
const hasAnyConnectors = ccPairs.length > 0;
const shouldShowWelcomeModal =
!hasCompletedWelcomeFlowSS() &&
@ -181,7 +179,6 @@ export default async function Page({
<ChatLayout
user={user}
settings={settings}
chatSessions={chatSessions}
availableSources={availableSources}
availableDocumentSets={documentSets}
@ -189,6 +186,7 @@ export default async function Page({
availableTags={tags}
defaultSelectedPersonaId={defaultPersonaId}
documentSidebarInitialWidth={finalDocumentSidebarInitialWidth}
defaultSidebarTab={defaultSidebarTab}
/>
</>
);

View File

@ -3,7 +3,7 @@ import { ReadonlyURLSearchParams } from "next/navigation";
// search params
export const SEARCH_PARAM_NAMES = {
CHAT_ID: "chatId",
PERSONA_ID: "personaId",
PERSONA_ID: "assistantId",
// overrides
TEMPERATURE: "temperature",
MODEL_VERSION: "model-version",

View File

@ -0,0 +1,105 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import { BasicSelectable } from "@/components/BasicClickable";
import { User } from "@/lib/types";
import { Text } from "@tremor/react";
import Link from "next/link";
import { FiEdit } from "react-icons/fi";
function AssistantDisplay({
persona,
onSelect,
user,
}: {
persona: Persona;
onSelect: (persona: Persona) => void;
user: User | null;
}) {
const isEditable =
(!user || user.id === persona.owner?.id) &&
!persona.default_persona &&
(!persona.is_public || !user || user.role === "admin");
return (
<div className="flex">
<div className="w-full" onClick={() => onSelect(persona)}>
<BasicSelectable selected={false} fullWidth>
<div className="flex">
<div className="truncate w-48 3xl:w-56">{persona.name}</div>
</div>
</BasicSelectable>
</div>
{isEditable && (
<div className="pl-2 my-auto">
<Link href={`/assistants/edit/${persona.id}`}>
<FiEdit
className="my-auto ml-auto hover:bg-hover p-0.5"
size={20}
/>
</Link>
</div>
)}
</div>
);
}
export function AssistantsTab({
personas,
onPersonaChange,
user,
}: {
personas: Persona[];
onPersonaChange: (persona: Persona | null) => void;
user: User | null;
}) {
const globalAssistants = personas.filter((persona) => persona.is_public);
const personalAssistants = personas.filter(
(persona) =>
!user || (persona.users.includes(user.id) && !persona.is_public)
);
return (
<div className="mt-4 pb-1 overflow-y-auto h-full flex flex-col gap-y-1">
<Text className="mx-3 text-xs mb-4">
Select an Assistant below to begin a new chat with them!
</Text>
<div className="mx-3">
{globalAssistants.length > 0 && (
<>
<div className="text-xs text-subtle flex pb-0.5 ml-1 mb-1.5 font-bold">
Global
</div>
{globalAssistants.map((persona) => {
return (
<AssistantDisplay
key={persona.id}
persona={persona}
onSelect={onPersonaChange}
user={user}
/>
);
})}
</>
)}
{personalAssistants.length > 0 && (
<>
<div className="text-xs text-subtle flex pb-0.5 ml-1 mb-1.5 mt-5 font-bold">
Personal
</div>
{personalAssistants.map((persona) => {
return (
<AssistantDisplay
key={persona.id}
persona={persona}
onSelect={onPersonaChange}
user={user}
/>
);
})}
</>
)}
</div>
</div>
);
}

View File

@ -21,21 +21,50 @@ import {
HEADER_PADDING,
NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA,
} from "@/lib/constants";
interface ChatSidebarProps {
existingChats: ChatSession[];
currentChatSession: ChatSession | null | undefined;
user: User | null;
}
import { ChatTab } from "./ChatTab";
import { AssistantsTab } from "./AssistantsTab";
import { Persona } from "@/app/admin/assistants/interfaces";
import Cookies from "js-cookie";
import { SIDEBAR_TAB_COOKIE, Tabs } from "./constants";
export const ChatSidebar = ({
existingChats,
currentChatSession,
personas,
onPersonaChange,
user,
}: ChatSidebarProps) => {
defaultTab,
}: {
existingChats: ChatSession[];
currentChatSession: ChatSession | null | undefined;
personas: Persona[];
onPersonaChange: (persona: Persona | null) => void;
user: User | null;
defaultTab?: Tabs;
}) => {
const router = useRouter();
const groupedChatSessions = groupSessionsByDateRange(existingChats);
const [openTab, _setOpenTab] = useState(defaultTab || Tabs.CHATS);
const setOpenTab = (tab: Tabs) => {
Cookies.set(SIDEBAR_TAB_COOKIE, tab);
_setOpenTab(tab);
};
function TabOption({ tab }: { tab: Tabs }) {
return (
<div
className={
"font-bold p-1 rounded-lg hover:bg-hover cursor-pointer " +
(openTab === tab ? "bg-hover" : "")
}
onClick={() => {
setOpenTab(tab);
}}
>
{tab}
</div>
);
}
const [userInfoVisible, setUserInfoVisible] = useState(false);
const userInfoRef = useRef<HTMLDivElement>(null);
@ -78,8 +107,9 @@ export const ChatSidebar = ({
return (
<div
className={`
flex-none
w-64
2xl:w-72
3xl:w-72
${HEADER_PADDING}
border-r
border-border
@ -89,49 +119,53 @@ export const ChatSidebar = ({
transition-transform`}
id="chat-sidebar"
>
<Link
href={
"/chat" +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA && currentChatSession
? `?personaId=${currentChatSession.persona_id}`
: "")
}
className="mx-3 mt-5"
>
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Chat
</div>
</BasicClickable>
</Link>
<div className="mt-1 pb-1 mb-1 ml-3 overflow-y-auto h-full">
{Object.entries(groupedChatSessions).map(
([dateRange, chatSessions]) => {
if (chatSessions.length > 0) {
return (
<div key={dateRange}>
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-5 font-bold">
{dateRange}
</div>
{chatSessions.map((chat) => {
const isSelected = currentChatId === chat.id;
return (
<div key={`${chat.id}-${chat.name}`} className="mr-3">
<ChatSessionDisplay
chatSession={chat}
isSelected={isSelected}
/>
</div>
);
})}
</div>
);
}
}
)}
<div className="flex w-full mx-4 mt-4 text-sm gap-x-4 pb-2 border-b border-border">
<TabOption tab={Tabs.CHATS} />
<TabOption tab={Tabs.ASSISTANTS} />
</div>
{openTab == Tabs.CHATS && (
<>
<Link
href={
"/chat" +
(NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA &&
currentChatSession
? `?assistantId=${currentChatSession.persona_id}`
: "")
}
className="mx-3 mt-5"
>
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Chat
</div>
</BasicClickable>
</Link>
<ChatTab
existingChats={existingChats}
currentChatId={currentChatId}
/>
</>
)}
{openTab == Tabs.ASSISTANTS && (
<>
<Link href="/assistants/new" className="mx-3 mt-5">
<BasicClickable fullWidth>
<div className="flex text-sm">
<FiPlusSquare className="my-auto mr-2" /> New Assistant
</div>
</BasicClickable>
</Link>
<AssistantsTab
personas={personas}
onPersonaChange={onPersonaChange}
user={user}
/>
</>
)}
<div
className="mt-auto py-2 border-t border-border px-3"
ref={userInfoRef}

View File

@ -0,0 +1,40 @@
import { ChatSession } from "../interfaces";
import { groupSessionsByDateRange } from "../lib";
import { ChatSessionDisplay } from "./SessionDisplay";
export function ChatTab({
existingChats,
currentChatId,
}: {
existingChats: ChatSession[];
currentChatId?: number;
}) {
const groupedChatSessions = groupSessionsByDateRange(existingChats);
return (
<div className="mt-1 pb-1 mb-1 ml-3 overflow-y-auto h-full">
{Object.entries(groupedChatSessions).map(([dateRange, chatSessions]) => {
if (chatSessions.length > 0) {
return (
<div key={dateRange}>
<div className="text-xs text-subtle flex pb-0.5 mb-1.5 mt-5 font-bold">
{dateRange}
</div>
{chatSessions.map((chat) => {
const isSelected = currentChatId === chat.id;
return (
<div key={`${chat.id}-${chat.name}`} className="mr-3">
<ChatSessionDisplay
chatSession={chat}
isSelected={isSelected}
/>
</div>
);
})}
</div>
);
}
})}
</div>
);
}

View File

@ -0,0 +1,6 @@
export const SIDEBAR_TAB_COOKIE = "chatSidebarTab";
export enum Tabs {
CHATS = "Chats",
ASSISTANTS = "Assistants",
}

View File

@ -7,10 +7,8 @@ import {
import { fetchSS } from "@/lib/utilsSS";
import { redirect } from "next/navigation";
import { BackendChatSession } from "../../interfaces";
import { Header } from "@/components/Header";
import { Header } from "@/components/header/Header";
import { SharedChatDisplay } from "./SharedChatDisplay";
import { getSettingsSS } from "@/lib/settings";
import { Settings } from "@/app/admin/settings/interfaces";
async function getSharedChat(chatId: string) {
const response = await fetchSS(
@ -27,13 +25,12 @@ export default async function Page({ params }: { params: { chatId: string } }) {
getAuthTypeMetadataSS(),
getCurrentUserSS(),
getSharedChat(params.chatId),
getSettingsSS(),
];
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let results: (User | AuthTypeMetadata | null)[] = [null, null, null, null];
let results: (User | AuthTypeMetadata | null)[] = [null, null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
@ -42,7 +39,6 @@ export default async function Page({ params }: { params: { chatId: string } }) {
const authTypeMetadata = results[0] as AuthTypeMetadata | null;
const user = results[1] as User | null;
const chatSession = results[2] as BackendChatSession | null;
const settings = results[3] as Settings | null;
const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) {
@ -56,7 +52,7 @@ export default async function Page({ params }: { params: { chatId: string } }) {
return (
<div>
<div className="absolute top-0 z-40 w-full">
<Header user={user} settings={settings} />
<Header user={user} />
</div>
<div className="flex relative bg-background text-default overflow-hidden pt-16 h-screen">

View File

@ -1,6 +1,8 @@
import { fetchSettingsSS } from "@/components/settings/lib";
import "./globals.css";
import { Inter } from "next/font/google";
import { SettingsProvider } from "@/components/settings/SettingsProvider";
const inter = Inter({
subsets: ["latin"],
@ -19,12 +21,19 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
const combinedSettings = await fetchSettingsSS();
return (
<html lang="en">
<body
className={`${inter.variable} font-sans text-default bg-background`}
className={`${inter.variable} font-sans text-default bg-background ${
// TODO: remove this once proper dark mode exists
process.env.THEME_IS_DARK?.toLowerCase() === "true" ? "dark" : ""
}`}
>
{children}
<SettingsProvider settings={combinedSettings}>
{children}
</SettingsProvider>
</body>
</html>
);

View File

@ -1,14 +1,14 @@
import { getSettingsSS } from "@/lib/settings";
import { fetchSettingsSS } from "@/components/settings/lib";
import { redirect } from "next/navigation";
export default async function Page() {
const settings = await getSettingsSS();
const settings = await fetchSettingsSS();
if (!settings) {
redirect("/search");
}
if (settings.default_page === "search") {
if (settings.settings.default_page === "search") {
redirect("/search");
} else {
redirect("/chat");

View File

@ -1,5 +1,5 @@
import { SearchSection } from "@/components/search/SearchSection";
import { Header } from "@/components/Header";
import { Header } from "@/components/header/Header";
import {
AuthTypeMetadata,
getAuthTypeMetadataSS,
@ -12,19 +12,17 @@ import { fetchSS } from "@/lib/utilsSS";
import { CCPairBasicInfo, DocumentSet, Tag, User } from "@/lib/types";
import { cookies } from "next/headers";
import { SearchType } from "@/lib/search/interfaces";
import { Persona } from "../admin/personas/interfaces";
import { Persona } from "../admin/assistants/interfaces";
import {
WelcomeModal,
hasCompletedWelcomeFlowSS,
} from "@/components/initialSetup/welcome/WelcomeModalWrapper";
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { personaComparator } from "../admin/personas/lib";
import { personaComparator } from "../admin/assistants/lib";
import { FullEmbeddingModelResponse } from "../admin/models/embedding/embeddingModels";
import { NoSourcesModal } from "@/components/initialSetup/search/NoSourcesModal";
import { NoCompleteSourcesModal } from "@/components/initialSetup/search/NoCompleteSourceModal";
import { getSettingsSS } from "@/lib/settings";
import { Settings } from "../admin/settings/interfaces";
export default async function Home() {
// Disable caching so we always get the up to date connector / document set / persona info
@ -40,7 +38,6 @@ export default async function Home() {
fetchSS("/persona"),
fetchSS("/query/valid-tags"),
fetchSS("/secondary-index/get-embedding-models"),
getSettingsSS(),
];
// catch cases where the backend is completely unreachable here
@ -51,9 +48,8 @@ export default async function Home() {
| Response
| AuthTypeMetadata
| FullEmbeddingModelResponse
| Settings
| null
)[] = [null, null, null, null, null, null, null];
)[] = [null, null, null, null, null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
@ -66,7 +62,6 @@ export default async function Home() {
const personaResponse = results[4] as Response | null;
const tagsResponse = results[5] as Response | null;
const embeddingModelResponse = results[6] as Response | null;
const settings = results[7] as Settings | null;
const authDisabled = authTypeMetadata?.authType === "disabled";
if (!authDisabled && !user) {
@ -77,10 +72,6 @@ export default async function Home() {
return redirect("/auth/waiting-on-verification");
}
if (settings && !settings.search_page_enabled) {
return redirect("/chat");
}
let ccPairs: CCPairBasicInfo[] = [];
if (ccPairsResponse?.ok) {
ccPairs = await ccPairsResponse.json();
@ -152,7 +143,7 @@ export default async function Home() {
return (
<>
<Header user={user} settings={settings} />
<Header user={user} />
<div className="m-3">
<HealthCheckBanner />
</div>

View File

@ -1,5 +1,4 @@
import { Settings } from "@/app/admin/settings/interfaces";
import { Header } from "@/components/Header";
import { Header } from "@/components/header/Header";
import { AdminSidebar } from "@/components/admin/connectors/AdminSidebar";
import {
NotebookIcon,
@ -13,7 +12,6 @@ import {
ConnectorIcon,
SlackIcon,
} from "@/components/icons/icons";
import { getSettingsSS } from "@/lib/settings";
import { User } from "@/lib/types";
import {
AuthTypeMetadata,
@ -30,12 +28,12 @@ import {
} from "react-icons/fi";
export async function Layout({ children }: { children: React.ReactNode }) {
const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS(), getSettingsSS()];
const tasks = [getAuthTypeMetadataSS(), getCurrentUserSS()];
// catch cases where the backend is completely unreachable here
// without try / catch, will just raise an exception and the page
// will not render
let results: (User | AuthTypeMetadata | Settings | null)[] = [null, null];
let results: (User | AuthTypeMetadata | null)[] = [null, null];
try {
results = await Promise.all(tasks);
} catch (e) {
@ -44,7 +42,6 @@ export async function Layout({ children }: { children: React.ReactNode }) {
const authTypeMetadata = results[0] as AuthTypeMetadata | null;
const user = results[1] as User | null;
const settings = results[2] as Settings | null;
const authDisabled = authTypeMetadata?.authType === "disabled";
const requiresVerification = authTypeMetadata?.requiresVerification;
@ -63,7 +60,7 @@ export async function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="h-screen overflow-y-hidden">
<div className="absolute top-0 z-50 w-full">
<Header user={user} settings={settings} />
<Header user={user} />
</div>
<div className="flex h-full pt-16">
<div className="w-80 pt-12 pb-8 h-full border-r border-border">
@ -131,10 +128,10 @@ export async function Layout({ children }: { children: React.ReactNode }) {
name: (
<div className="flex">
<RobotIcon size={18} />
<div className="ml-1">Personas</div>
<div className="ml-1">Assistants</div>
</div>
),
link: "/admin/personas",
link: "/admin/assistants",
},
{
name: (

View File

@ -0,0 +1,54 @@
import { DocumentSet, ValidSources } from "@/lib/types";
import { CustomCheckbox } from "../CustomCheckbox";
import { SourceIcon } from "../SourceIcon";
export function DocumentSetSelectable({
documentSet,
isSelected,
onSelect,
}: {
documentSet: DocumentSet;
isSelected: boolean;
onSelect: () => void;
}) {
const uniqueSources = new Set<ValidSources>();
documentSet.cc_pair_descriptors.forEach((ccPairDescriptor) => {
uniqueSources.add(ccPairDescriptor.connector.source);
});
return (
<div
key={documentSet.id}
className={
`
w-72
px-3
py-1
rounded-lg
border
border-border
flex
cursor-pointer ` +
(isSelected ? " bg-hover" : " bg-background hover:bg-hover-light")
}
onClick={onSelect}
>
<div className="flex w-full">
<div className="flex flex-col h-full">
<div className="font-bold">{documentSet.name}</div>
<div className="text-xs">{documentSet.description}</div>
<div className="flex gap-x-2 pt-1 mt-auto mb-1">
{Array.from(uniqueSources).map((source) => (
<SourceIcon key={source} sourceType={source} iconSize={16} />
))}
</div>
</div>
<div className="ml-auto my-auto">
<div className="pl-1">
<CustomCheckbox checked={isSelected} onChange={() => null} />
</div>
</div>
</div>
</div>
);
}

View File

@ -5,18 +5,17 @@ import { logout } from "@/lib/user";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import React, { useEffect, useRef, useState } from "react";
import { CustomDropdown, DefaultDropdownElement } from "./Dropdown";
import React, { useContext, useEffect, useRef, useState } from "react";
import { CustomDropdown, DefaultDropdownElement } from "../Dropdown";
import { FiMessageSquare, FiSearch } from "react-icons/fi";
import { usePathname } from "next/navigation";
import { Settings } from "@/app/admin/settings/interfaces";
import { HeaderWrapper } from "./HeaderWrapper";
import { SettingsContext } from "../settings/SettingsProvider";
interface HeaderProps {
user: User | null;
settings: Settings | null;
}
export function Header({ user, settings }: HeaderProps) {
export function Header({ user }: HeaderProps) {
const router = useRouter();
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
@ -54,9 +53,15 @@ export function Header({ user, settings }: HeaderProps) {
};
}, [dropdownOpen]);
const combinedSettings = useContext(SettingsContext);
if (!combinedSettings) {
return null;
}
const settings = combinedSettings.settings;
return (
<header className="border-b border-border bg-background-emphasis">
<div className="mx-8 flex h-16">
<HeaderWrapper>
<div className="flex h-full">
<Link
className="py-4"
href={
@ -133,7 +138,7 @@ export function Header({ user, settings }: HeaderProps) {
</div>
</div>
</div>
</header>
</HeaderWrapper>
);
}

View File

@ -0,0 +1,11 @@
export function HeaderWrapper({
children,
}: {
children: JSX.Element | string;
}) {
return (
<header className="border-b border-border bg-background-emphasis">
<div className="mx-8 h-16">{children}</div>
</header>
);
}

View File

@ -1,4 +1,4 @@
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { CustomDropdown, DefaultDropdownElement } from "../Dropdown";
import { FiChevronDown } from "react-icons/fi";

View File

@ -1,6 +1,6 @@
"use client";
import { useRef, useState } from "react";
import { useContext, useRef, useState } from "react";
import { SearchBar } from "./SearchBar";
import { SearchResultsDisplay } from "./SearchResultsDisplay";
import { SourceSelector } from "./filtering/Filters";
@ -20,9 +20,11 @@ import { SearchHelper } from "./SearchHelper";
import { CancellationToken, cancellable } from "@/lib/search/cancellable";
import { useFilters, useObjectState } from "@/lib/hooks";
import { questionValidationStreamed } from "@/lib/search/streamingQuestionValidation";
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { PersonaSelector } from "./PersonaSelector";
import { computeAvailableFilters } from "@/lib/filters";
import { useRouter } from "next/router";
import { SettingsContext } from "../settings/SettingsProvider";
const SEARCH_DEFAULT_OVERRIDES_START: SearchDefaultOverrides = {
forceDisplayQA: false,
@ -211,6 +213,16 @@ export const SearchSection = ({
setIsFetching(false);
};
// handle redirect if search page is disabled
// NOTE: this must be done here, in a client component since
// settings are passed in via Context and therefore aren't
// available in server-side components
const router = useRouter();
const settings = useContext(SettingsContext);
if (settings?.settings?.search_page_enabled === false) {
router.push("/chat");
}
return (
<div className="relative max-w-[2000px] xl:max-w-[1430px] mx-auto">
<div className="absolute left-0 hidden 2xl:block w-52 3xl:w-64">

View File

@ -0,0 +1,20 @@
"use client";
import { CombinedSettings } from "@/app/admin/settings/interfaces";
import { createContext } from "react";
export const SettingsContext = createContext<CombinedSettings | null>(null);
export function SettingsProvider({
children,
settings,
}: {
children: React.ReactNode | JSX.Element;
settings: CombinedSettings;
}) {
return (
<SettingsContext.Provider value={settings}>
{children}
</SettingsContext.Provider>
);
}

View File

@ -0,0 +1,30 @@
import { EnterpriseSettings, Settings } from "@/app/admin/settings/interfaces";
import { CUSTOM_ANALYTICS_ENABLED, EE_ENABLED } from "@/lib/constants";
import { fetchSS } from "@/lib/utilsSS";
export async function fetchSettingsSS() {
const tasks = [fetchSS("/settings")];
if (EE_ENABLED) {
tasks.push(fetchSS("/enterprise-settings"));
if (CUSTOM_ANALYTICS_ENABLED) {
tasks.push(fetchSS("/enterprise-settings/custom-analytics-script"));
}
}
const results = await Promise.all(tasks);
const settings = (await results[0].json()) as Settings;
const enterpriseSettings =
tasks.length > 1 ? ((await results[1].json()) as EnterpriseSettings) : null;
const customAnalyticsScript = (
tasks.length > 2 ? await results[2].json() : null
) as string | null;
const combinedSettings = {
settings,
enterpriseSettings,
customAnalyticsScript,
};
return combinedSettings;
}

View File

@ -0,0 +1,103 @@
import { Persona } from "@/app/admin/assistants/interfaces";
import { CCPairBasicInfo, DocumentSet, User } from "../types";
import { getCurrentUserSS } from "../userSS";
import { fetchSS } from "../utilsSS";
export async function fetchPersonaEditorInfoSS(
personaId?: number | string
): Promise<
| [
{
ccPairs: CCPairBasicInfo[];
documentSets: DocumentSet[];
llmOverrideOptions: string[];
defaultLLM: string;
user: User | null;
existingPersona: Persona | null;
},
null,
]
| [null, string]
> {
const tasks = [
fetchSS("/manage/indexing-status"),
fetchSS("/manage/document-set"),
fetchSS("/persona/utils/list-available-models"),
fetchSS("/persona/utils/default-model"),
// duplicate fetch, but shouldn't be too big of a deal
// this page is not a high traffic page
getCurrentUserSS(),
];
if (personaId) {
tasks.push(fetchSS(`/persona/${personaId}`));
} else {
tasks.push((async () => null)());
}
const [
ccPairsInfoResponse,
documentSetsResponse,
llmOverridesResponse,
defaultLLMResponse,
user,
personaResponse,
] = (await Promise.all(tasks)) as [
Response,
Response,
Response,
Response,
User | null,
Response | null,
];
if (!ccPairsInfoResponse.ok) {
return [
null,
`Failed to fetch connectors - ${await ccPairsInfoResponse.text()}`,
];
}
const ccPairs = (await ccPairsInfoResponse.json()) as CCPairBasicInfo[];
if (!documentSetsResponse.ok) {
return [
null,
`Failed to fetch document sets - ${await documentSetsResponse.text()}`,
];
}
const documentSets = (await documentSetsResponse.json()) as DocumentSet[];
if (!llmOverridesResponse.ok) {
return [
null,
`Failed to fetch LLM override options - ${await llmOverridesResponse.text()}`,
];
}
const llmOverrideOptions = (await llmOverridesResponse.json()) as string[];
if (!defaultLLMResponse.ok) {
return [
null,
`Failed to fetch default LLM - ${await defaultLLMResponse.text()}`,
];
}
const defaultLLM = (await defaultLLMResponse.json()) as string;
if (personaId && personaResponse && !personaResponse.ok) {
return [null, `Failed to fetch Persona - ${await personaResponse.text()}`];
}
const existingPersona = personaResponse
? ((await personaResponse.json()) as Persona)
: null;
return [
{
ccPairs,
documentSets,
llmOverrideOptions,
defaultLLM,
user,
existingPersona,
},
null,
];
}

View File

@ -23,3 +23,8 @@ export const HEADER_PADDING = "pt-[64px]";
// can be the single source of truth
export const EE_ENABLED =
process.env.NEXT_PUBLIC_ENABLE_PAID_EE_FEATURES?.toLowerCase() === "true";
// Enterprise-only settings
export const CUSTOM_ANALYTICS_ENABLED = process.env.CUSTOM_ANALYTICS_SECRET_KEY
? true
: false;

View File

@ -1,4 +1,4 @@
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
import { DocumentSet, ValidSources } from "./types";
import { getSourcesForPersona } from "./sources";

View File

@ -1,6 +1,6 @@
import { DateRangePickerValue } from "@tremor/react";
import { Tag, ValidSources } from "../types";
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
export const FlowType = {
SEARCH: "search",

View File

@ -1,10 +0,0 @@
import { Settings } from "@/app/admin/settings/interfaces";
import { fetchSS } from "./utilsSS";
export async function getSettingsSS(): Promise<Settings | null> {
const response = await fetchSS("/settings");
if (response.ok) {
return await response.json();
}
return null;
}

View File

@ -27,7 +27,7 @@ import {
} from "@/components/icons/icons";
import { ValidSources } from "./types";
import { SourceCategory, SourceMetadata } from "./search/interfaces";
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
interface PartialSourceMetadata {
icon: React.FC<{ size?: number; className?: string }>;

View File

@ -1,4 +1,4 @@
import { Persona } from "@/app/admin/personas/interfaces";
import { Persona } from "@/app/admin/assistants/interfaces";
export interface User {
id: string;
@ -9,6 +9,11 @@ export interface User {
role: "basic" | "admin";
}
export interface MinimalUserSnapshot {
id: string;
email: string;
}
export type ValidSources =
| "web"
| "github"