diff --git a/backend/alembic/versions/f5437cc136c5_delete_non_search_assistants.py b/backend/alembic/versions/f5437cc136c5_delete_non_search_assistants.py new file mode 100644 index 000000000..b812c0cd6 --- /dev/null +++ b/backend/alembic/versions/f5437cc136c5_delete_non_search_assistants.py @@ -0,0 +1,53 @@ +"""delete non-search assistants + +Revision ID: f5437cc136c5 +Revises: eaa3b5593925 +Create Date: 2025-02-04 16:17:15.677256 + +""" +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "f5437cc136c5" +down_revision = "eaa3b5593925" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + # Fix: split the statements into multiple op.execute() calls + op.execute( + """ + WITH personas_without_search AS ( + SELECT p.id + FROM persona p + LEFT JOIN persona__tool pt ON p.id = pt.persona_id + LEFT JOIN tool t ON pt.tool_id = t.id + GROUP BY p.id + HAVING COUNT(CASE WHEN t.in_code_tool_id = 'run_search' THEN 1 END) = 0 + ) + UPDATE slack_channel_config + SET persona_id = NULL + WHERE is_default = TRUE AND persona_id IN (SELECT id FROM personas_without_search) + """ + ) + + op.execute( + """ + WITH personas_without_search AS ( + SELECT p.id + FROM persona p + LEFT JOIN persona__tool pt ON p.id = pt.persona_id + LEFT JOIN tool t ON pt.tool_id = t.id + GROUP BY p.id + HAVING COUNT(CASE WHEN t.in_code_tool_id = 'run_search' THEN 1 END) = 0 + ) + DELETE FROM slack_channel_config + WHERE is_default = FALSE AND persona_id IN (SELECT id FROM personas_without_search) + """ + ) diff --git a/backend/onyx/db/persona.py b/backend/onyx/db/persona.py index 94a60e7b5..638ee74a6 100644 --- a/backend/onyx/db/persona.py +++ b/backend/onyx/db/persona.py @@ -11,6 +11,7 @@ from sqlalchemy import Select from sqlalchemy import select from sqlalchemy import update from sqlalchemy.orm import aliased +from sqlalchemy.orm import joinedload from sqlalchemy.orm import selectinload from sqlalchemy.orm import Session @@ -708,3 +709,15 @@ def update_persona_label( def delete_persona_label(label_id: int, db_session: Session) -> None: db_session.query(PersonaLabel).filter(PersonaLabel.id == label_id).delete() db_session.commit() + + +def persona_has_search_tool(persona_id: int, db_session: Session) -> bool: + persona = ( + db_session.query(Persona) + .options(joinedload(Persona.tools)) + .filter(Persona.id == persona_id) + .one_or_none() + ) + if persona is None: + raise ValueError(f"Persona with ID {persona_id} does not exist") + return any(tool.in_code_tool_id == "run_search" for tool in persona.tools) diff --git a/backend/onyx/db/slack_channel_config.py b/backend/onyx/db/slack_channel_config.py index 5884fde52..06dd6a8ae 100644 --- a/backend/onyx/db/slack_channel_config.py +++ b/backend/onyx/db/slack_channel_config.py @@ -256,7 +256,7 @@ def fetch_slack_channel_config_for_channel_or_default( db_session: Session, slack_bot_id: int, channel_name: str | None ) -> SlackChannelConfig | None: # attempt to find channel-specific config first - if channel_name: + if channel_name is not None: sc_config = db_session.scalar( select(SlackChannelConfig).where( SlackChannelConfig.slack_bot_id == slack_bot_id, diff --git a/backend/onyx/onyxbot/slack/blocks.py b/backend/onyx/onyxbot/slack/blocks.py index 87036f160..3a6f01fd2 100644 --- a/backend/onyx/onyxbot/slack/blocks.py +++ b/backend/onyx/onyxbot/slack/blocks.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import cast import pytz import timeago # type: ignore @@ -338,6 +339,23 @@ def _build_citations_blocks( return citations_block +def _build_answer_blocks( + answer: ChatOnyxBotResponse, fallback_answer: str +) -> list[SectionBlock]: + if not answer.answer: + answer_blocks = [SectionBlock(text=fallback_answer)] + else: + # replaces markdown links with slack format links + formatted_answer = format_slack_message(answer.answer) + answer_processed = decode_escapes( + remove_slack_text_interactions(formatted_answer) + ) + answer_blocks = [ + SectionBlock(text=text) for text in _split_text(answer_processed) + ] + return answer_blocks + + def _build_qa_response_blocks( answer: ChatOnyxBotResponse, ) -> list[Block]: @@ -376,21 +394,10 @@ def _build_qa_response_blocks( filter_block = SectionBlock(text=f"_{filter_text}_") - if not answer.answer: - answer_blocks = [ - SectionBlock( - text="Sorry, I was unable to find an answer, but I did find some potentially relevant docs 🤓" - ) - ] - else: - # replaces markdown links with slack format links - formatted_answer = format_slack_message(answer.answer) - answer_processed = decode_escapes( - remove_slack_text_interactions(formatted_answer) - ) - answer_blocks = [ - SectionBlock(text=text) for text in _split_text(answer_processed) - ] + answer_blocks = _build_answer_blocks( + answer=answer, + fallback_answer="Sorry, I was unable to find an answer, but I did find some potentially relevant docs 🤓", + ) response_blocks: list[Block] = [] @@ -481,6 +488,7 @@ def build_slack_response_blocks( use_citations: bool, feedback_reminder_id: str | None, skip_ai_feedback: bool = False, + expecting_search_result: bool = False, ) -> list[Block]: """ This function is a top level function that builds all the blocks for the Slack response. @@ -491,9 +499,19 @@ def build_slack_response_blocks( message_info.thread_messages[-1].message, message_info.is_bot_msg ) - answer_blocks = _build_qa_response_blocks( - answer=answer, - ) + if expecting_search_result: + answer_blocks = _build_qa_response_blocks( + answer=answer, + ) + + else: + answer_blocks = cast( + list[Block], + _build_answer_blocks( + answer=answer, + fallback_answer="Sorry, I was unable to generate an answer.", + ), + ) web_follow_up_block = [] if channel_conf and channel_conf.get("show_continue_in_web_ui"): diff --git a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py index 0fcf12dea..4479bbcd5 100644 --- a/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py +++ b/backend/onyx/onyxbot/slack/handlers/handle_regular_answer.py @@ -27,6 +27,7 @@ from onyx.db.engine import get_session_with_tenant from onyx.db.models import SlackChannelConfig from onyx.db.models import User from onyx.db.persona import get_persona_by_id +from onyx.db.persona import persona_has_search_tool from onyx.db.users import get_user_by_email from onyx.onyxbot.slack.blocks import build_slack_response_blocks from onyx.onyxbot.slack.handlers.utils import send_team_member_message @@ -106,7 +107,8 @@ def handle_regular_answer( ] prompt = persona.prompts[0] if persona.prompts else None - should_respond_even_with_no_docs = persona.num_chunks == 0 if persona else False + with get_session_with_tenant(tenant_id) as db_session: + expecting_search_result = persona_has_search_tool(persona.id, db_session) # TODO: Add in support for Slack to truncate messages based on max LLM context # llm, _ = get_llms_for_persona(persona) @@ -303,12 +305,12 @@ def handle_regular_answer( return True retrieval_info = answer.docs - if not retrieval_info: + if not retrieval_info and expecting_search_result: # This should not happen, even with no docs retrieved, there is still info returned raise RuntimeError("Failed to retrieve docs, cannot answer question.") - top_docs = retrieval_info.top_documents - if not top_docs and not should_respond_even_with_no_docs: + top_docs = retrieval_info.top_documents if retrieval_info else [] + if not top_docs and expecting_search_result: logger.error( f"Unable to answer question: '{user_message}' - no documents found" ) @@ -337,7 +339,8 @@ def handle_regular_answer( ) if ( - only_respond_if_citations + expecting_search_result + and only_respond_if_citations and not answer.citations and not message_info.bypass_filters ): @@ -363,6 +366,7 @@ def handle_regular_answer( channel_conf=channel_conf, use_citations=True, # No longer supporting quotes feedback_reminder_id=feedback_reminder_id, + expecting_search_result=expecting_search_result, ) try: diff --git a/backend/onyx/onyxbot/slack/listener.py b/backend/onyx/onyxbot/slack/listener.py index c682ecadb..1fbc603b8 100644 --- a/backend/onyx/onyxbot/slack/listener.py +++ b/backend/onyx/onyxbot/slack/listener.py @@ -787,18 +787,6 @@ def process_message( channel_name=channel_name, ) - # Be careful about this default, don't want to accidentally spam every channel - # Users should be able to DM slack bot in their private channels though - if ( - not respond_every_channel - # Can't have configs for DMs so don't toss them out - and not is_dm - # If /OnyxBot (is_bot_msg) or @OnyxBot (bypass_filters) - # always respond with the default configs - and not (details.is_bot_msg or details.bypass_filters) - ): - return - follow_up = bool( slack_channel_config.channel_config and slack_channel_config.channel_config.get("follow_up_tags") diff --git a/backend/onyx/server/query_and_chat/chat_backend.py b/backend/onyx/server/query_and_chat/chat_backend.py index 0511683ab..839c79ac2 100644 --- a/backend/onyx/server/query_and_chat/chat_backend.py +++ b/backend/onyx/server/query_and_chat/chat_backend.py @@ -743,7 +743,7 @@ def upload_files_for_chat( # to re-extract it every time we send a message if file_type == ChatFileType.DOC: extracted_text = extract_file_text( - file=file_content_io, # use the bytes we already read + file=file_content_io, # use the bytes we already read file_name=file.filename or "", ) text_file_id = str(uuid.uuid4()) diff --git a/web/package-lock.json b/web/package-lock.json index d5a074eb1..3188f851e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@headlessui/react": "^2.2.0", "@headlessui/tailwindcss": "^0.2.1", "@phosphor-icons/react": "^2.0.8", + "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", @@ -3442,6 +3443,140 @@ "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.2.tgz", + "integrity": "sha512-b1oh54x4DMCdGsB4/7ahiSrViXxaBwRPotiZNnYXjLha9vfuURSAZErki6qjDoSIV0eXx5v57XnTGVtGwnfp2g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collapsible": "1.1.2", + "@radix-ui/react-collection": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-use-controllable-state": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-collection": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz", + "integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.1", + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz", + "integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", + "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", diff --git a/web/package.json b/web/package.json index 476880a99..f2db4b905 100644 --- a/web/package.json +++ b/web/package.json @@ -18,6 +18,7 @@ "@headlessui/react": "^2.2.0", "@headlessui/tailwindcss": "^0.2.1", "@phosphor-icons/react": "^2.0.8", + "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-collapsible": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", diff --git a/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx b/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx index e2a6ae19f..ac0ccabe2 100644 --- a/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx +++ b/web/src/app/admin/bots/[bot-id]/SlackChannelConfigsTable.tsx @@ -49,7 +49,7 @@ export function SlackChannelConfigsTable({ }} > - Edit Default Config + Edit Default Configuration + + + )} + + -
- -
- {showAdvancedOptions && ( -
-
({ + name: persona.name, + value: persona.id, + }))} />
+ )} +
+ + + {values.knowledge_source !== "non_search_assistant" && ( + + + Search Configuration + + +
+
+ +
+ - + +
+
+
+ )} + + + General Configuration + +
+ + + { + setFieldValue("still_need_help_enabled", checked); + if (!checked) { + setFieldValue("follow_up_tags", []); + } + }} + label={'Give a "Still need help?" button'} + tooltip={`OnyxBot's response will include a button at the bottom + of the response that asks the user if they still need help.`} + /> + {values.still_need_help_enabled && ( + + + The Slack users / groups we should tag if the user + clicks the "Still need help?" button. If no + emails are provided, we will not tag anyone and will + just react with a 🆘 emoji to the original message. +
+ } + placeholder="User email or user group name..." + /> + + )} + + + + - { - setFieldValue("still_need_help_enabled", checked); - if (!checked) { - setFieldValue("follow_up_tags", []); - } - }} - label={'Give a "Still need help?" button'} - tooltip={`OnyxBot's response will include a button at the bottom - of the response that asks the user if they still need help.`} - /> - {values.still_need_help_enabled && ( - - The Slack users / groups we should tag if the user clicks - the "Still need help?" button. If no emails are - provided, we will not tag anyone and will just react with a - 🆘 emoji to the original message. - + "If specified, OnyxBot responses will only " + + "be visible to the members or groups in this list." } + values={values} placeholder="User email or user group name..." /> - - )} - - - - - - - - - - setFieldValue("standard_answer_categories", categories) - } - /> - - )} + + setFieldValue("standard_answer_categories", categories) + } + /> + +
+
+
{shouldShowPrivacyAlert && ( diff --git a/web/src/components/admin/connectors/Field.tsx b/web/src/components/admin/connectors/Field.tsx index b6beb4b43..3d1d4219a 100644 --- a/web/src/components/admin/connectors/Field.tsx +++ b/web/src/components/admin/connectors/Field.tsx @@ -51,7 +51,7 @@ export function Label({ }) { return (
diff --git a/web/src/components/ui/CheckField.tsx b/web/src/components/ui/CheckField.tsx new file mode 100644 index 000000000..d3a62ced4 --- /dev/null +++ b/web/src/components/ui/CheckField.tsx @@ -0,0 +1,102 @@ +"use client"; + +import React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; +import { useField } from "formik"; +import { cn } from "@/lib/utils"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface CheckFieldProps { + name: string; + label: string; + sublabel?: string; + size?: "sm" | "md" | "lg"; + tooltip?: string; + onChange?: (checked: boolean) => void; +} + +export const CheckFormField: React.FC = ({ + name, + label, + onChange, + sublabel, + size = "md", + tooltip, + ...props +}) => { + const [field, , helpers] = useField({ name, type: "checkbox" }); + + const sizeClasses = { + sm: "h-2 w-2", + md: "h-3 w-3", + lg: "h-4 w-4", + }; + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + helpers.setValue(!field.value); + onChange?.(field.value); + }; + + const checkboxContent = ( +
+ { + helpers.setValue(Boolean(checked)); + onChange?.(Boolean(checked)); + }} + className={cn( + "peer shrink-0 rounded-sm border border-neutral-200 bg-white ring-offset-white " + + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 " + + "focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 " + + "data-[state=checked]:bg-neutral-900 data-[state=checked]:text-neutral-50 " + + "dark:border-neutral-800 dark:ring-offset-neutral-950 dark:focus-visible:ring-neutral-300 " + + "dark:data-[state=checked]:bg-neutral-50 dark:data-[state=checked]:text-neutral-900", + sizeClasses[size] + )} + {...props} + > + + + + +
+ +
+
+ ); + + return tooltip ? ( + + + {checkboxContent} + +

{tooltip}

+
+
+
+ ) : ( + checkboxContent + ); +}; diff --git a/web/src/components/ui/accordion.tsx b/web/src/components/ui/accordion.tsx new file mode 100644 index 000000000..75ce5ff24 --- /dev/null +++ b/web/src/components/ui/accordion.tsx @@ -0,0 +1,60 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = "AccordionTrigger"; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = "AccordionContent"; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };