diff --git a/backend/danswer/background/update.py b/backend/danswer/background/update.py index 540be58b0..adbe94f3c 100755 --- a/backend/danswer/background/update.py +++ b/backend/danswer/background/update.py @@ -81,18 +81,18 @@ def _should_create_new_indexing( return False return True - # If the connector is disabled, don't index + # If the connector is disabled or is the ingestion API, don't index # NOTE: during an embedding model switch over, the following logic # is bypassed by the above check for a future model - if connector.disabled: - return False - - if connector.refresh_freq is None: + if connector.disabled or connector.id == 0: return False if not last_index: return True + if connector.refresh_freq is None: + return False + # Only one scheduled job per connector at a time # Can schedule another one if the current one is already running however # Because the currently running one will not be until the latest time diff --git a/backend/danswer/chat/load_yamls.py b/backend/danswer/chat/load_yamls.py index ed0c0a2d7..0690f08b7 100644 --- a/backend/danswer/chat/load_yamls.py +++ b/backend/danswer/chat/load_yamls.py @@ -9,7 +9,9 @@ from danswer.db.document_set import get_or_create_document_set_by_name from danswer.db.engine import get_sqlalchemy_engine from danswer.db.input_prompt import insert_input_prompt_if_not_exists from danswer.db.models import DocumentSet as DocumentSetDBModel +from danswer.db.models import Persona from danswer.db.models import Prompt as PromptDBModel +from danswer.db.models import Tool as ToolDBModel from danswer.db.persona import get_prompt_by_name from danswer.db.persona import upsert_persona from danswer.db.persona import upsert_prompt @@ -78,9 +80,31 @@ def load_personas_from_yaml( prompt_ids = [prompt.id for prompt in prompts if prompt is not None] p_id = persona.get("id") + tool_ids = [] + if persona.get("image_generation"): + image_gen_tool = ( + db_session.query(ToolDBModel) + .filter(ToolDBModel.name == "ImageGenerationTool") + .first() + ) + if image_gen_tool: + tool_ids.append(image_gen_tool.id) + + llm_model_provider_override = persona.get("llm_model_provider_override") + llm_model_version_override = persona.get("llm_model_version_override") + + # Set specific overrides for image generation persona + if persona.get("image_generation"): + llm_model_version_override = "gpt-4o" + + existing_persona = ( + db_session.query(Persona) + .filter(Persona.name == persona["name"]) + .first() + ) + upsert_persona( 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"], description=persona["description"], @@ -92,13 +116,20 @@ def load_personas_from_yaml( llm_filter_extraction=persona.get("llm_filter_extraction"), icon_shape=persona.get("icon_shape"), icon_color=persona.get("icon_color"), - llm_model_provider_override=None, - llm_model_version_override=None, + llm_model_provider_override=llm_model_provider_override, + llm_model_version_override=llm_model_version_override, recency_bias=RecencyBiasSetting(persona["recency_bias"]), prompt_ids=prompt_ids, document_set_ids=doc_set_ids, + tool_ids=tool_ids, default_persona=True, is_public=True, + display_priority=existing_persona.display_priority + if existing_persona is not None + else persona.get("display_priority"), + is_visible=existing_persona.is_visible + if existing_persona is not None + else persona.get("is_visible"), db_session=db_session, ) diff --git a/backend/danswer/chat/personas.yaml b/backend/danswer/chat/personas.yaml index e85029ccc..8d5fcdb88 100644 --- a/backend/danswer/chat/personas.yaml +++ b/backend/danswer/chat/personas.yaml @@ -5,7 +5,7 @@ personas: # this is for DanswerBot to use when tagged in a non-configured channel # Careful setting specific IDs, this won't autoincrement the next ID value for postgres - id: 0 - name: "Danswer" + name: "Knowledge" description: > Assistant with access to documents from your Connected Sources. # Default Prompt objects attached to the persona, see prompts.yaml @@ -39,11 +39,13 @@ personas: document_sets: [] icon_shape: 23013 icon_color: "#6FB1FF" + display_priority: 1 + is_visible: true - id: 1 - name: "GPT" + name: "General" description: > - Assistant with no access to documents. Chat with just the Language Model. + Assistant with no access to documents. Chat with just the Large Language Model. prompts: - "OnlyLLM" num_chunks: 0 @@ -53,7 +55,8 @@ personas: document_sets: [] icon_shape: 50910 icon_color: "#FF6F6F" - + display_priority: 0 + is_visible: true - id: 2 name: "Paraphrase" @@ -68,4 +71,23 @@ personas: document_sets: [] icon_shape: 45519 icon_color: "#6FFF8D" + display_priority: 2 + is_visible: false + + - id: 3 + name: "Art" + description: > + Assistant for generating images based on descriptions. + prompts: + - "ImageGeneration" + num_chunks: 0 + llm_relevance_filter: false + llm_filter_extraction: false + recency_bias: "no_decay" + document_sets: [] + icon_shape: 234124 + icon_color: "#9B59B6" + image_generation: true + display_priority: 3 + is_visible: true diff --git a/backend/danswer/chat/prompts.yaml b/backend/danswer/chat/prompts.yaml index 474327f04..89fffcfa8 100644 --- a/backend/danswer/chat/prompts.yaml +++ b/backend/danswer/chat/prompts.yaml @@ -30,7 +30,25 @@ prompts: # Prompts the LLM to include citations in the for [1], [2] etc. # which get parsed to match the passed in sources include_citations: true - + + - name: "ImageGeneration" + description: "Generates images based on user prompts!" + system: > + You are an advanced image generation system capable of creating diverse and detailed images. + The current date is DANSWER_DATETIME_REPLACEMENT. + + You can interpret user prompts and generate high-quality, creative images that match their descriptions. + + You always strive to create safe and appropriate content, avoiding any harmful or offensive imagery. + task: > + Generate an image based on the user's description. + If the user's request is unclear or too vague, ask for clarification to ensure the best possible result. + + Provide a detailed description of the generated image, including key elements, colors, and composition. + + If the request is not possible or appropriate, explain why and suggest alternatives. + datetime_aware: true + include_citations: false - name: "OnlyLLM" description: "Chat directly with the LLM!" diff --git a/backend/danswer/configs/app_configs.py b/backend/danswer/configs/app_configs.py index 88213f87a..13c8b3a18 100644 --- a/backend/danswer/configs/app_configs.py +++ b/backend/danswer/configs/app_configs.py @@ -212,6 +212,7 @@ EXPERIMENTAL_CHECKPOINTING_ENABLED = ( os.environ.get("EXPERIMENTAL_CHECKPOINTING_ENABLED", "").lower() == "true" ) +PRUNING_DISABLED = -1 DEFAULT_PRUNING_FREQ = 60 * 60 * 24 # Once a day ALLOW_SIMULTANEOUS_PRUNING = ( diff --git a/backend/danswer/db/connector.py b/backend/danswer/db/connector.py index 00412dfcb..59a57ff95 100644 --- a/backend/danswer/db/connector.py +++ b/backend/danswer/db/connector.py @@ -87,9 +87,7 @@ def create_connector( connector_specific_config=connector_data.connector_specific_config, refresh_freq=connector_data.refresh_freq, indexing_start=connector_data.indexing_start, - prune_freq=connector_data.prune_freq - if connector_data.prune_freq is not None - else DEFAULT_PRUNING_FREQ, + prune_freq=connector_data.prune_freq, disabled=connector_data.disabled, ) db_session.add(connector) @@ -249,20 +247,32 @@ def fetch_unique_document_sources(db_session: Session) -> list[DocumentSource]: def create_initial_default_connector(db_session: Session) -> None: default_connector_id = 0 default_connector = fetch_connector_by_id(default_connector_id, db_session) - if default_connector is not None: + # Check if the existing connector has the correct values if ( default_connector.source != DocumentSource.INGESTION_API or default_connector.input_type != InputType.LOAD_STATE or default_connector.refresh_freq is not None or default_connector.disabled + or default_connector.name != "Ingestion API" + or default_connector.connector_specific_config != {} + or default_connector.prune_freq is not None ): - raise ValueError( - "DB is not in a valid initial state. " - "Default connector does not have expected values." + logger.warning( + "Default connector does not have expected values. Updating to proper state." ) + # Ensure default connector has correct valuesg + default_connector.source = DocumentSource.INGESTION_API + default_connector.input_type = InputType.LOAD_STATE + default_connector.refresh_freq = None + default_connector.disabled = False + default_connector.name = "Ingestion API" + default_connector.connector_specific_config = {} + default_connector.prune_freq = None + db_session.commit() return + # Create a new default connector if it doesn't exist connector = Connector( id=default_connector_id, name="Ingestion API", @@ -271,6 +281,7 @@ def create_initial_default_connector(db_session: Session) -> None: connector_specific_config={}, refresh_freq=None, prune_freq=None, + disabled=False, ) db_session.add(connector) db_session.commit() diff --git a/backend/danswer/db/credentials.py b/backend/danswer/db/credentials.py index 10c07a4c4..433e63dd9 100644 --- a/backend/danswer/db/credentials.py +++ b/backend/danswer/db/credentials.py @@ -172,14 +172,12 @@ def alter_credential( return None credential.name = credential_data.name - credential.name = credential_data.name # Update only the keys present in credential_data.credential_json for key, value in credential_data.credential_json.items(): credential.credential_json[key] = value credential.user_id = user.id if user is not None else None - db_session.commit() return credential diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 397d26ac7..7297f5a55 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -1153,7 +1153,9 @@ class Persona(Base): # controls the ordering of personas in the UI # higher priority personas are displayed first, ties are resolved by the ID, # where lower value IDs (e.g. created earlier) are displayed first - display_priority: Mapped[int] = mapped_column(Integer, nullable=True, default=None) + display_priority: Mapped[int | None] = mapped_column( + Integer, nullable=True, default=None + ) deleted: Mapped[bool] = mapped_column(Boolean, default=False) uploaded_image_id: Mapped[str | None] = mapped_column(String, nullable=True) diff --git a/backend/danswer/db/persona.py b/backend/danswer/db/persona.py index 17ea7db62..ff821e307 100644 --- a/backend/danswer/db/persona.py +++ b/backend/danswer/db/persona.py @@ -336,6 +336,8 @@ def upsert_persona( icon_color: str | None = None, icon_shape: int | None = None, uploaded_image_id: str | None = None, + display_priority: int | None = None, + is_visible: bool = True, ) -> Persona: if persona_id is not None: persona = db_session.query(Persona).filter_by(id=persona_id).first() @@ -394,6 +396,8 @@ def upsert_persona( persona.icon_color = icon_color persona.icon_shape = icon_shape persona.uploaded_image_id = uploaded_image_id + persona.display_priority = display_priority + persona.is_visible = is_visible # Do not delete any associations manually added unless # a new updated list is provided @@ -429,6 +433,8 @@ def upsert_persona( icon_shape=icon_shape, icon_color=icon_color, uploaded_image_id=uploaded_image_id, + display_priority=display_priority, + is_visible=is_visible, ) db_session.add(persona) diff --git a/backend/danswer/server/documents/cc_pair.py b/backend/danswer/server/documents/cc_pair.py index 002da68a9..1cb2385a7 100644 --- a/backend/danswer/server/documents/cc_pair.py +++ b/backend/danswer/server/documents/cc_pair.py @@ -72,6 +72,28 @@ def get_cc_pair_full_info( ) +@router.put("/admin/cc-pair/{cc_pair_id}/name") +def update_cc_pair_name( + cc_pair_id: int, + new_name: str, + user: User | None = Depends(current_user), + db_session: Session = Depends(get_session), +) -> StatusResponse[int]: + cc_pair = get_connector_credential_pair_from_id(cc_pair_id, db_session) + if not cc_pair: + raise HTTPException(status_code=404, detail="CC Pair not found") + + try: + cc_pair.name = new_name + db_session.commit() + return StatusResponse( + success=True, message="Name updated successfully", data=cc_pair_id + ) + except IntegrityError: + db_session.rollback() + raise HTTPException(status_code=400, detail="Name must be unique") + + @router.put("/connector/{connector_id}/credential/{credential_id}") def associate_credential_to_connector( connector_id: int, diff --git a/backend/tests/regression/answer_quality/api_utils.py b/backend/tests/regression/answer_quality/api_utils.py index 19b61315a..13455832b 100644 --- a/backend/tests/regression/answer_quality/api_utils.py +++ b/backend/tests/regression/answer_quality/api_utils.py @@ -170,10 +170,8 @@ def create_connector(env_name: str, file_paths: list[str]) -> int: ) body = connector.dict() - print("body:", body) response = requests.post(url, headers=GENERAL_HEADERS, json=body) if response.status_code == 200: - print("Connector created successfully:", response.json()) return response.json()["id"] else: raise RuntimeError(response.__dict__) diff --git a/web/src/app/admin/connector/[ccPairId]/page.tsx b/web/src/app/admin/connector/[ccPairId]/page.tsx index d750d7f5b..30c2a4790 100644 --- a/web/src/app/admin/connector/[ccPairId]/page.tsx +++ b/web/src/app/admin/connector/[ccPairId]/page.tsx @@ -4,7 +4,7 @@ import { CCPairFullInfo } from "./types"; import { HealthCheckBanner } from "@/components/health/healthcheck"; import { CCPairStatus } from "@/components/Status"; import { BackButton } from "@/components/BackButton"; -import { Divider, Title } from "@tremor/react"; +import { Button, Divider, Title } from "@tremor/react"; import { IndexingAttemptsTable } from "./IndexingAttemptsTable"; import { ConfigDisplay } from "./ConfigDisplay"; import { ModifyStatusButtonCluster } from "./ModifyStatusButtonCluster"; @@ -19,8 +19,11 @@ import { ThreeDotsLoader } from "@/components/Loading"; import CredentialSection from "@/components/credentials/CredentialSection"; import { buildCCPairInfoUrl } from "./lib"; import { SourceIcon } from "@/components/SourceIcon"; -import { connectorConfigs } from "@/lib/connectors/connectors"; import { credentialTemplates } from "@/lib/connectors/credentials"; +import { useEffect, useRef, useState } from "react"; +import { CheckmarkIcon, EditIcon, XIcon } from "@/components/icons/icons"; +import { usePopup } from "@/components/admin/connectors/Popup"; +import { updateConnectorCredentialPairName } from "@/lib/connector"; // since the uploaded files are cleaned up after some period of time // re-indexing will not work for the file connector. Also, it would not @@ -38,6 +41,43 @@ function Main({ ccPairId }: { ccPairId: number }) { { refreshInterval: 5000 } // 5 seconds ); + const [editableName, setEditableName] = useState(ccPair?.name || ""); + const [isEditing, setIsEditing] = useState(false); + const inputRef = useRef(null); + + const { popup, setPopup } = usePopup(); + useEffect(() => { + if (isEditing && inputRef.current) { + inputRef.current.focus(); + } + }, [isEditing]); + const handleNameChange = (e: React.ChangeEvent) => { + setEditableName(e.target.value); + }; + + const handleUpdateName = async () => { + try { + const response = await updateConnectorCredentialPairName( + ccPair?.id!, + editableName + ); + if (!response.ok) { + throw new Error(await response.text()); + } + mutate(buildCCPairInfoUrl(ccPairId)); + setIsEditing(false); + setPopup({ + message: "Connector name updated successfully", + type: "success", + }); + } catch (error) { + setPopup({ + message: `Failed to update connector name`, + type: "error", + }); + } + }; + if (isLoading) { return ; } @@ -68,18 +108,52 @@ function Main({ ccPairId }: { ccPairId: number }) { mutate(buildCCPairInfoUrl(ccPairId)); }; + const startEditing = () => { + setEditableName(ccPair.name); + setIsEditing(true); + }; const deleting = ccPair.latest_deletion_attempt?.status == "PENDING" || ccPair.latest_deletion_attempt?.status == "STARTED"; + const resetEditing = () => { + setIsEditing(false); + setEditableName(ccPair.name); + }; return ( <> + {popup}
-

{ccPair.name}

+ + {isEditing ? ( +
+ + + +
+ ) : ( +

startEditing()} + className="group flex cursor-pointer text-3xl text-emphasis gap-x-2 items-center font-bold" + > + {ccPair.name} + +

+ )}
{!CONNECTOR_TYPES_THAT_CANT_REINDEX.includes( diff --git a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx index e25680346..1c47f03b1 100644 --- a/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx +++ b/web/src/app/admin/connectors/[connector]/AddConnectorPage.tsx @@ -140,9 +140,9 @@ export default function AddConnector({ const createConnector = async () => { const AdvancedConfig: AdvancedConfig = { - pruneFreq: (pruneFreq || defaultPrune) * 60, + pruneFreq: pruneFreq * 60, indexingStart, - refreshFreq: (refreshFreq || defaultRefresh) * 60, + refreshFreq: refreshFreq * 60, }; // google sites-specific handling @@ -186,8 +186,8 @@ export default function AddConnector({ input_type: connector == "web" ? "load_state" : "poll", // single case name: name, source: connector, - refresh_freq: (refreshFreq || defaultRefresh) * 60, - prune_freq: (pruneFreq || defaultPrune) * 60, + refresh_freq: refreshFreq * 60 || null, + prune_freq: pruneFreq * 60 || null, indexing_start: indexingStart, disabled: false, }, diff --git a/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx b/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx index 232c00b20..d3c40ad80 100644 --- a/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx +++ b/web/src/app/admin/connectors/[connector]/pages/Advanced.tsx @@ -50,11 +50,10 @@ const AdvancedFormPage = forwardRef, AdvancedFormPageProps>(
{ setPruneFreq(value); setFieldValue("pruneFreq", value); @@ -67,11 +66,10 @@ const AdvancedFormPage = forwardRef, AdvancedFormPageProps>(
{ setRefreshFreq(value); setFieldValue("refreshFreq", value); diff --git a/web/src/app/admin/indexing/status/CCPairIndexingStatusTable.tsx b/web/src/app/admin/indexing/status/CCPairIndexingStatusTable.tsx index 4e449ec30..92b88beff 100644 --- a/web/src/app/admin/indexing/status/CCPairIndexingStatusTable.tsx +++ b/web/src/app/admin/indexing/status/CCPairIndexingStatusTable.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo, useEffect } from "react"; +import React, { useState, useMemo, useEffect, useRef } from "react"; import { Table, TableRow, @@ -6,6 +6,7 @@ import { TableBody, TableCell, Badge, + Button, } from "@tremor/react"; import { IndexAttemptStatus } from "@/components/Status"; import { timeAgo } from "@/lib/time"; @@ -28,6 +29,8 @@ import { SourceIcon } from "@/components/SourceIcon"; import { getSourceDisplayName } from "@/lib/sources"; import { CustomTooltip } from "@/components/tooltip/CustomTooltip"; import { Warning } from "@phosphor-icons/react"; +import Cookies from "js-cookie"; +import { TOGGLED_CONNECTORS_COOKIE_NAME } from "@/lib/constants"; const columnWidths = { first: "20%", @@ -253,10 +256,22 @@ export function CCPairIndexingStatusTable({ }: { ccPairsIndexingStatuses: ConnectorIndexingStatus[]; }) { - const [allToggleTracker, setAllToggleTracker] = useState(true); - const [openSources, setOpenSources] = useState>( - {} as Record - ); + const [searchTerm, setSearchTerm] = useState(""); + + const searchInputRef = useRef(null); + + useEffect(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, []); + + const [connectorsToggled, setConnectorsToggled] = useState< + Record + >(() => { + const savedState = Cookies.get(TOGGLED_CONNECTORS_COOKIE_NAME); + return savedState ? JSON.parse(savedState) : {}; + }); const { groupedStatuses, sortedSources, groupSummaries } = useMemo(() => { const grouped: Record[]> = @@ -295,39 +310,39 @@ export function CCPairIndexingStatusTable({ }; }, [ccPairsIndexingStatuses]); - const toggleSource = (source: ValidSources) => { - setOpenSources((prev) => ({ - ...prev, - [source]: !prev[source], - })); - }; - - const toggleSources = (toggle: boolean) => { - const updatedSources = Object.fromEntries( - sortedSources.map((item) => [item, toggle]) + const toggleSource = ( + source: ValidSources, + toggled: boolean | null = null + ) => { + const newConnectorsToggled = { + ...connectorsToggled, + [source]: toggled == null ? !connectorsToggled[source] : toggled, + }; + setConnectorsToggled(newConnectorsToggled); + Cookies.set( + TOGGLED_CONNECTORS_COOKIE_NAME, + JSON.stringify(newConnectorsToggled) ); - setOpenSources(updatedSources as Record); - setAllToggleTracker(!toggle); }; + const toggleSources = () => { + const currentToggledCount = + Object.values(connectorsToggled).filter(Boolean).length; + const shouldToggleOn = currentToggledCount < sortedSources.length / 2; - const router = useRouter(); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if (event.metaKey || event.ctrlKey) { - switch (event.key.toLowerCase()) { - case "e": - toggleSources(false); - event.preventDefault(); - break; - } - } - }; - toggleSources(true); - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [router, allToggleTracker]); + const connectors = sortedSources.reduce( + (acc, source) => { + acc[source] = shouldToggleOn; + return acc; + }, + {} as Record + ); + + setConnectorsToggled(connectors); + Cookies.set(TOGGLED_CONNECTORS_COOKIE_NAME, JSON.stringify(connectors)); + }; + const shouldExpand = + Object.values(connectorsToggled).filter(Boolean).length < + sortedSources.length / 2; return (
@@ -348,56 +363,99 @@ export function CCPairIndexingStatusTable({ }} />
+ - {sortedSources.map((source, ind) => ( - -
+
+ setSearchTerm(e.target.value)} + className="ml-2 w-96 h-9 flex-none rounded-md border-2 border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> - toggleSource(source)} - /> + +
+ {sortedSources.map((source, ind) => { + const sourceMatches = source + .toLowerCase() + .includes(searchTerm.toLowerCase()); + const matchingConnectors = groupedStatuses[source].filter( + (status) => + (status.name || "") + .toLowerCase() + .includes(searchTerm.toLowerCase()) + ); + if (sourceMatches || matchingConnectors.length > 0) { + return ( + +
- {openSources[source] && ( - <> - - - Name - - - Last Indexed - - - Activity - - - Public - - - Total Docs - - - Last Status - - - - {groupedStatuses[source].map((ccPairsIndexingStatus) => ( - - ))} - - )} - - ))} + toggleSource(source)} + /> + + {connectorsToggled[source] && ( + <> + + + Name + + + Last Indexed + + + Activity + + + Public + + + Total Docs + + + Last Status + + + + {(sourceMatches + ? groupedStatuses[source] + : matchingConnectors + ).map((ccPairsIndexingStatus) => ( + + ))} + + )} + + ); + } + return null; + })} - {/* Padding between table and bottom of page */}
diff --git a/web/src/app/admin/settings/SettingsForm.tsx b/web/src/app/admin/settings/SettingsForm.tsx index 858a5c0ff..03a017136 100644 --- a/web/src/app/admin/settings/SettingsForm.tsx +++ b/web/src/app/admin/settings/SettingsForm.tsx @@ -103,84 +103,117 @@ function IntegerInput({ export function SettingsForm() { const router = useRouter(); - const combinedSettings = useContext(SettingsContext); + const [settings, setSettings] = useState(null); const [chatRetention, setChatRetention] = useState(""); const { popup, setPopup } = usePopup(); const isEnterpriseEnabled = usePaidEnterpriseFeaturesEnabled(); + const combinedSettings = useContext(SettingsContext); + useEffect(() => { - if (combinedSettings?.settings.maximum_chat_retention_days !== undefined) { + if (combinedSettings) { + setSettings(combinedSettings.settings); setChatRetention( combinedSettings.settings.maximum_chat_retention_days?.toString() || "" ); } - }, [combinedSettings?.settings.maximum_chat_retention_days]); + }, []); - if (!combinedSettings) { + if (!settings) { return null; } - const settings = combinedSettings.settings; async function updateSettingField( updateRequests: { fieldName: keyof Settings; newValue: any }[] ) { - const newValues: any = {}; - updateRequests.forEach(({ fieldName, newValue }) => { - newValues[fieldName] = newValue; - }); + // Optimistically update the local state + const newSettings: Settings | null = settings + ? { + ...settings, + ...updateRequests.reduce((acc, { fieldName, newValue }) => { + acc[fieldName] = newValue ?? settings[fieldName]; + return acc; + }, {} as Partial), + } + : null; + setSettings(newSettings); + + try { + const response = await fetch("/api/admin/settings", { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(newSettings), + }); + + if (!response.ok) { + const errorMsg = (await response.json()).detail; + throw new Error(errorMsg); + } - const response = await fetch("/api/admin/settings", { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - ...settings, - ...newValues, - }), - }); - if (response.ok) { router.refresh(); - } else { - const errorMsg = (await response.json()).detail; - alert(`Failed to update settings. ${errorMsg}`); + setPopup({ + message: "Settings updated successfully!", + type: "success", + }); + } catch (error) { + // Revert the optimistic update + setSettings(settings); + console.error("Error updating settings:", error); + setPopup({ + message: `Failed to update settings`, + type: "error", + }); } } + function handleToggleSettingsField( + fieldName: keyof Settings, + checked: boolean + ) { + const updates: { fieldName: keyof Settings; newValue: any }[] = [ + { fieldName, newValue: checked }, + ]; + + // If we're disabling a page, check if we need to update the default page + if ( + !checked && + (fieldName === "search_page_enabled" || fieldName === "chat_page_enabled") + ) { + const otherPageField = + fieldName === "search_page_enabled" + ? "chat_page_enabled" + : "search_page_enabled"; + const otherPageEnabled = settings && settings[otherPageField]; + + if ( + otherPageEnabled && + settings?.default_page === + (fieldName === "search_page_enabled" ? "search" : "chat") + ) { + updates.push({ + fieldName: "default_page", + newValue: fieldName === "search_page_enabled" ? "chat" : "search", + }); + } + } + + updateSettingField(updates); + } + function handleSetChatRetention() { - // Convert chatRetention to a number or null and update the global settings - const newValue = - chatRetention === "" ? null : parseInt(chatRetention.toString(), 10); + const newValue = chatRetention === "" ? null : parseInt(chatRetention, 10); updateSettingField([ - { fieldName: "maximum_chat_retention_days", newValue: newValue }, - ]) - .then(() => { - setPopup({ - message: "Chat retention settings updated successfully!", - type: "success", - }); - }) - .catch((error) => { - console.error("Error updating settings:", error); - const errorMessage = - error.response?.data?.message || error.message || "Unknown error"; - setPopup({ - message: `Failed to update settings: ${errorMessage}`, - type: "error", - }); - }); + { fieldName: "maximum_chat_retention_days", newValue }, + ]); } function handleClearChatRetention() { - setChatRetention(""); // Clear the chat retention input + setChatRetention(""); updateSettingField([ { fieldName: "maximum_chat_retention_days", newValue: null }, - ]).then(() => { - setPopup({ - message: "Chat retention cleared successfully!", - type: "success", - }); - }); + ]); } return ( @@ -190,36 +223,20 @@ export function SettingsForm() { { - const updates: any[] = [ - { fieldName: "search_page_enabled", newValue: e.target.checked }, - ]; - if (!e.target.checked && settings.default_page === "search") { - updates.push({ fieldName: "default_page", newValue: "chat" }); - } - updateSettingField(updates); - }} + onChange={(e) => + handleToggleSettingsField("search_page_enabled", e.target.checked) + } /> { - const updates: any[] = [ - { fieldName: "chat_page_enabled", newValue: e.target.checked }, - ]; - if (!e.target.checked && settings.default_page === "chat") { - updates.push({ fieldName: "default_page", newValue: "search" }); - } - updateSettingField(updates); - }} + onChange={(e) => + handleToggleSettingsField("chat_page_enabled", e.target.checked) + } /> + {isEnterpriseEnabled && ( <> Chat Settings @@ -246,10 +264,8 @@ export function SettingsForm() { value={chatRetention === "" ? null : Number(chatRetention)} onChange={(e) => { const numValue = parseInt(e.target.value, 10); - if (numValue >= 1) { - setChatRetention(numValue.toString()); - } else if (e.target.value === "") { - setChatRetention(""); + if (numValue >= 1 || e.target.value === "") { + setChatRetention(e.target.value); } }} id="chatRetentionInput" diff --git a/web/src/app/assistants/gallery/page.tsx b/web/src/app/assistants/gallery/page.tsx index d5215f179..6ac8d61c7 100644 --- a/web/src/app/assistants/gallery/page.tsx +++ b/web/src/app/assistants/gallery/page.tsx @@ -6,9 +6,6 @@ import { WelcomeModal } from "@/components/initialSetup/welcome/WelcomeModalWrap import { fetchChatData } from "@/lib/chat/fetchChatData"; import { unstable_noStore as noStore } from "next/cache"; import { redirect } from "next/navigation"; -import { AssistantsGallery } from "./AssistantsGallery"; -import FixedLogo from "@/app/chat/shared_chat_search/FixedLogo"; -import GalleryWrapper from "../SidebarWrapper"; import WrappedAssistantsGallery from "./WrappedAssistantsGallery"; export default async function GalleryPage({ diff --git a/web/src/app/chat/ChatBanner.tsx b/web/src/app/chat/ChatBanner.tsx index de3e199f4..4a095e203 100644 --- a/web/src/app/chat/ChatBanner.tsx +++ b/web/src/app/chat/ChatBanner.tsx @@ -16,10 +16,12 @@ export function ChatBanner() { className={` mt-8 mb-2 + p-1 mx-2 z-[39] - w-full - h-[30px] + text-wrap + w-[500px] + mx-auto bg-background-100 shadow-sm rounded @@ -30,7 +32,7 @@ export function ChatBanner() {
( ), - p: ({ node, ...props }) =>

, + p: ({ node, ...props }) => ( +

+ ), }} remarkPlugins={[remarkGfm]} > diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index fd13be53b..a7d94c387 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -799,7 +799,6 @@ export function ChatPage({ if (!stack.isEmpty()) { const packet = stack.nextPacket(); - if (packet) { if (Object.hasOwn(packet, "answer_piece")) { answer += (packet as AnswerPiecePacket).answer_piece; @@ -1216,7 +1215,7 @@ export function ChatPage({ duration-300 ease-in-out h-full - ${toggledSidebar || showDocSidebar ? "w-[250px]" : "w-[0px]"} + ${toggledSidebar ? "w-[250px]" : "w-[0px]"} `} /> @@ -1512,17 +1511,13 @@ export function ChatPage({ messageId={null} personaName={liveAssistant.name} content={ -

- +
+ + Thinking... +
} /> diff --git a/web/src/app/chat/lib.tsx b/web/src/app/chat/lib.tsx index 190ab05d7..f6c973a6f 100644 --- a/web/src/app/chat/lib.tsx +++ b/web/src/app/chat/lib.tsx @@ -535,6 +535,13 @@ export function personaIncludesRetrieval(selectedPersona: Persona) { ); } +export function personaIncludesImage(selectedPersona: Persona) { + return selectedPersona.tools.some( + (tool) => + tool.in_code_tool_id && tool.in_code_tool_id == "ImageGenerationTool" + ); +} + const PARAMS_TO_SKIP = [ SEARCH_PARAM_NAMES.SUBMIT_ON_LOAD, SEARCH_PARAM_NAMES.USER_MESSAGE, diff --git a/web/src/app/chat/message/CodeBlock.tsx b/web/src/app/chat/message/CodeBlock.tsx index b9866452b..c6798333e 100644 --- a/web/src/app/chat/message/CodeBlock.tsx +++ b/web/src/app/chat/message/CodeBlock.tsx @@ -91,7 +91,8 @@ export function CodeBlock({ codeText = findTextNode(props.node); } - const handleCopy = () => { + const handleCopy = (event: React.MouseEvent) => { + event.preventDefault(); if (!codeText) { return; } @@ -109,7 +110,7 @@ export function CodeBlock({ {codeText && (
{copied ? (
diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index 5dfc0d259..c8a95c4ed 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -153,7 +153,25 @@ export const AIMessage = ({ handleForceSearch?: () => void; retrievalDisabled?: boolean; }) => { - const finalContent = content + (!isComplete ? "[*](test)" : ""); + const processContent = (content: string | JSX.Element) => { + if (typeof content !== "string") { + return content; + } + + const codeBlockRegex = /```[\s\S]*?```|```[\s\S]*?$/g; + const matches = content.match(codeBlockRegex); + + if (matches) { + const lastMatch = matches[matches.length - 1]; + if (!lastMatch.endsWith("```")) { + return content; + } + } + + return content + (!isComplete ? " [*]() " : ""); + }; + + const finalContent = processContent(content as string); const [isReady, setIsReady] = useState(false); useEffect(() => { @@ -228,7 +246,7 @@ export const AIMessage = ({ return (
-
+
- - } - isRunning={!toolCall.tool_result || !content} - /> -
+ + } + isRunning={!toolCall.tool_result || !content} + /> )} {toolCall && toolCall.tool_name === IMAGE_GENERATION_TOOL_NAME && !toolCall.tool_result && ( -
- - } - isRunning={!toolCall.tool_result} - /> -
+ + } + isRunning={!toolCall.tool_result} + /> )} {toolCall && toolCall.tool_name === INTERNET_SEARCH_TOOL_NAME && ( -
- - } - isRunning={!toolCall.tool_result} - /> -
+ + } + isRunning={!toolCall.tool_result} + /> )} {content ? ( @@ -362,64 +374,70 @@ export const AIMessage = ({ {typeof content === "string" ? ( - { - const { node, ...rest } = props; - const value = rest.children; +
+ { + const { node, ...rest } = props; + const value = rest.children; - if (value?.toString().startsWith("*")) { - return ( -
- ); - } else if (value?.toString().startsWith("[")) { - // for some reason tags cause the onClick to not apply - // and the links are unclickable - // TODO: fix the fact that you have to double click to follow link - // for the first link - return ( - - {rest.children} - - ); - } else { - return ( - - rest.href - ? window.open(rest.href, "_blank") - : undefined - } - className="cursor-pointer text-link hover:text-link-hover" - > - {rest.children} - - ); - } - }, - code: (props) => ( - - ), - p: ({ node, ...props }) => ( -

- ), - }} - remarkPlugins={[remarkGfm]} - rehypePlugins={[ - [rehypePrism, { ignoreMissing: true }], - ]} - > - {finalContent} - + if (value?.toString().startsWith("*")) { + return ( +

) : ( content )} @@ -772,11 +790,11 @@ export const HumanMessage = ({
) : typeof content === "string" ? ( <> - {onEdit && - isHovered && - !isEditing && - (!files || files.length === 0) ? ( -
+
+ {onEdit && + isHovered && + !isEditing && + (!files || files.length === 0) ? ( -
- ) : ( -
- )} + ) : ( +
+ )} +
{content}
- {/*
*/} ) : ( <> diff --git a/web/src/app/chat/message/SearchSummary.tsx b/web/src/app/chat/message/SearchSummary.tsx index d1f8f4f5d..8aac2a8cf 100644 --- a/web/src/app/chat/message/SearchSummary.tsx +++ b/web/src/app/chat/message/SearchSummary.tsx @@ -91,12 +91,11 @@ export function SearchSummary({
- {finished ? "Searched" : "Searching"} for: {finalQuery} + {finished ? "Searched" : "Searching"} for: {finalQuery}
); diff --git a/web/src/app/chat/message/SkippedSearch.tsx b/web/src/app/chat/message/SkippedSearch.tsx index 2fa7d611e..62c47b7d9 100644 --- a/web/src/app/chat/message/SkippedSearch.tsx +++ b/web/src/app/chat/message/SkippedSearch.tsx @@ -27,17 +27,19 @@ export function SkippedSearch({ handleForceSearch: () => void; }) { return ( -
- -
- - The AI decided this query didn't need a search - - No search +
+
+ +
+ + The AI decided this query didn't need a search + + No search +
- +
Force Search
diff --git a/web/src/app/chat/modal/configuration/AssistantsTab.tsx b/web/src/app/chat/modal/configuration/AssistantsTab.tsx index 9210c3e1b..9ec402de4 100644 --- a/web/src/app/chat/modal/configuration/AssistantsTab.tsx +++ b/web/src/app/chat/modal/configuration/AssistantsTab.tsx @@ -77,7 +77,7 @@ const AssistantCard = ({
-
+
{assistant.description}
diff --git a/web/src/app/chat/shared_chat_search/FixedLogo.tsx b/web/src/app/chat/shared_chat_search/FixedLogo.tsx index 5c25f0b60..27a18a353 100644 --- a/web/src/app/chat/shared_chat_search/FixedLogo.tsx +++ b/web/src/app/chat/shared_chat_search/FixedLogo.tsx @@ -15,7 +15,7 @@ export default function FixedLogo() { return ( <>
-
+
diff --git a/web/src/app/chat/shared_chat_search/FunctionalWrapper.tsx b/web/src/app/chat/shared_chat_search/FunctionalWrapper.tsx index 8d1922a3a..6926741ad 100644 --- a/web/src/app/chat/shared_chat_search/FunctionalWrapper.tsx +++ b/web/src/app/chat/shared_chat_search/FunctionalWrapper.tsx @@ -28,7 +28,7 @@ const ToggleSwitch = () => { const handleTabChange = (tab: string) => { setActiveTab(tab); localStorage.setItem("activeTab", tab); - if (settings?.isMobile) { + if (settings?.isMobile && window) { window.location.href = tab; } else { router.push(tab === "search" ? "/search" : "/chat"); diff --git a/web/src/app/chat/tools/ToolRunningAnimation.tsx b/web/src/app/chat/tools/ToolRunningAnimation.tsx index 139c9e921..11500a2c5 100644 --- a/web/src/app/chat/tools/ToolRunningAnimation.tsx +++ b/web/src/app/chat/tools/ToolRunningAnimation.tsx @@ -1,5 +1,3 @@ -import { LoadingAnimation } from "@/components/Loading"; - export function ToolRunDisplay({ toolName, toolLogo, @@ -12,7 +10,12 @@ export function ToolRunDisplay({ return (
{toolLogo} - {isRunning ? : toolName} +
+ {toolName} +
); } diff --git a/web/src/components/BasicClickable.tsx b/web/src/components/BasicClickable.tsx index 80cc0ab54..ffefc8b63 100644 --- a/web/src/components/BasicClickable.tsx +++ b/web/src/components/BasicClickable.tsx @@ -37,29 +37,35 @@ export function EmphasizedClickable({ children, onClick, fullWidth = false, + size = "md", }: { children: string | JSX.Element; onClick?: () => void; fullWidth?: boolean; + size?: "sm" | "md" | "lg"; }) { return (