mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-23 03:42:59 -05:00
fix(projects): add special logic for internal search tool when no connectors available (#6774)
Co-authored-by: Yuhong Sun <yuhongsun96@gmail.com>
This commit is contained in:
1 parent
f1a81d45a1
commit
7f79e34aa4
6 files changed
+162
-43
No files matched your search
@@ -64,10 +64,12 @@ from onyx.server.query_and_chat.streaming_models import AgentResponseStart
|
||||
from onyx.server.query_and_chat.streaming_models import CitationInfo
|
||||
from onyx.server.query_and_chat.streaming_models import Packet
|
||||
from onyx.server.utils import get_json_line
|
||||
from onyx.tools.constants import SEARCH_TOOL_ID
|
||||
from onyx.tools.tool import Tool
|
||||
from onyx.tools.tool_constructor import construct_tools
|
||||
from onyx.tools.tool_constructor import CustomToolConfig
|
||||
from onyx.tools.tool_constructor import SearchToolConfig
|
||||
from onyx.tools.tool_constructor import SearchToolUsage
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.long_term_log import LongTermLogger
|
||||
from onyx.utils.timing import log_function_time
|
||||
@@ -212,6 +214,46 @@ def _extract_project_file_texts_and_images(
|
||||
)
|
||||
|
||||
|
||||
def _get_project_search_availability(
|
||||
project_id: int | None,
|
||||
persona_id: int | None,
|
||||
has_project_file_texts: bool,
|
||||
forced_tool_ids: list[int] | None,
|
||||
search_tool_id: int | None,
|
||||
) -> SearchToolUsage:
|
||||
"""Determine search tool availability based on project context.
|
||||
|
||||
Args:
|
||||
project_id: The project ID if the user is in a project
|
||||
persona_id: The persona ID to check if it's the default persona
|
||||
has_project_file_texts: Whether project files are loaded in context
|
||||
forced_tool_ids: List of forced tool IDs (may be mutated to remove search tool)
|
||||
search_tool_id: The search tool ID to check against
|
||||
|
||||
Returns:
|
||||
SearchToolUsage setting indicating how search should be used
|
||||
"""
|
||||
# There are cases where the internal search tool should be disabled
|
||||
# If the user is in a project, it should not use other sources / generic search
|
||||
# If they are in a project but using a custom agent, it should use the agent setup
|
||||
# (which means it can use search)
|
||||
# However if in a project and there are more files than can fit in the context,
|
||||
# it should use the search tool with the project filter on
|
||||
# If no files are uploaded, search should remain enabled
|
||||
search_usage_forcing_setting = SearchToolUsage.AUTO
|
||||
if project_id:
|
||||
if bool(persona_id is DEFAULT_PERSONA_ID and has_project_file_texts):
|
||||
search_usage_forcing_setting = SearchToolUsage.DISABLED
|
||||
# Remove search tool from forced_tool_ids if it's present
|
||||
if forced_tool_ids and search_tool_id and search_tool_id in forced_tool_ids:
|
||||
forced_tool_ids[:] = [
|
||||
tool_id for tool_id in forced_tool_ids if tool_id != search_tool_id
|
||||
]
|
||||
elif forced_tool_ids and search_tool_id and search_tool_id in forced_tool_ids:
|
||||
search_usage_forcing_setting = SearchToolUsage.ENABLED
|
||||
return search_usage_forcing_setting
|
||||
|
||||
|
||||
def _initialize_chat_session(
|
||||
message_text: str,
|
||||
files: list[FileDescriptor],
|
||||
@@ -405,17 +447,23 @@ def stream_chat_message_objects(
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# There are cases where the internal search tool should be disabled
|
||||
# If the user is in a project, it should not use other sources / generic search
|
||||
# If they are in a project but using a custom agent, it should use the agent setup
|
||||
# (which means it can use search)
|
||||
# However if in a project and there are more files than can fit in the context,
|
||||
# it should use the search tool with the project filter on
|
||||
# If no files are uploaded, search should remain enabled
|
||||
disable_internal_search = bool(
|
||||
chat_session.project_id
|
||||
and persona.id is DEFAULT_PERSONA_ID
|
||||
and extracted_project_files.project_file_texts
|
||||
# Build a mapping of tool_id to tool_name for history reconstruction
|
||||
all_tools = get_tools(db_session)
|
||||
tool_id_to_name_map = {tool.id: tool.name for tool in all_tools}
|
||||
|
||||
search_tool_id = next(
|
||||
(tool.id for tool in all_tools if tool.in_code_tool_id == SEARCH_TOOL_ID),
|
||||
None,
|
||||
)
|
||||
|
||||
# This may also mutate the new_msg_req.forced_tool_ids
|
||||
# This logic is specifically for projects
|
||||
search_usage_forcing_setting = _get_project_search_availability(
|
||||
project_id=chat_session.project_id,
|
||||
persona_id=persona.id,
|
||||
has_project_file_texts=bool(extracted_project_files.project_file_texts),
|
||||
forced_tool_ids=new_msg_req.forced_tool_ids,
|
||||
search_tool_id=search_tool_id,
|
||||
)
|
||||
|
||||
emitter = get_default_emitter()
|
||||
@@ -444,7 +492,7 @@ def stream_chat_message_objects(
|
||||
additional_headers=custom_tool_additional_headers,
|
||||
),
|
||||
allowed_tool_ids=new_msg_req.allowed_tool_ids,
|
||||
disable_internal_search=disable_internal_search,
|
||||
search_usage_forcing_setting=search_usage_forcing_setting,
|
||||
)
|
||||
tools: list[Tool] = []
|
||||
for tool_list in tool_dict.values():
|
||||
@@ -469,10 +517,6 @@ def stream_chat_message_objects(
|
||||
reserved_assistant_message_id=assistant_response.id,
|
||||
)
|
||||
|
||||
# Build a mapping of tool_id to tool_name for history reconstruction
|
||||
all_tools = get_tools(db_session)
|
||||
tool_id_to_name_map = {tool.id: tool.name for tool in all_tools}
|
||||
|
||||
# Convert the chat history into a simple format that is free of any DB objects
|
||||
# and is easy to parse for the agent loop
|
||||
simple_chat_history = convert_chat_history(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from enum import Enum
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
@@ -23,6 +24,7 @@ from onyx.db.models import Persona
|
||||
from onyx.db.models import User
|
||||
from onyx.db.oauth_config import get_oauth_config
|
||||
from onyx.db.search_settings import get_current_search_settings
|
||||
from onyx.db.tools import get_builtin_tool
|
||||
from onyx.document_index.factory import get_default_document_index
|
||||
from onyx.llm.interfaces import LLM
|
||||
from onyx.llm.interfaces import LLMConfig
|
||||
@@ -65,6 +67,12 @@ class CustomToolConfig(BaseModel):
|
||||
additional_headers: dict[str, str] | None = None
|
||||
|
||||
|
||||
class SearchToolUsage(str, Enum):
|
||||
DISABLED = "disabled"
|
||||
ENABLED = "enabled"
|
||||
AUTO = "auto"
|
||||
|
||||
|
||||
def _get_image_generation_config(llm: LLM, db_session: Session) -> LLMConfig:
|
||||
"""Helper function to get image generation LLM config based on available providers"""
|
||||
if llm and llm.config.api_key and llm.config.model_provider == "openai":
|
||||
@@ -127,7 +135,7 @@ def construct_tools(
|
||||
search_tool_config: SearchToolConfig | None = None,
|
||||
custom_tool_config: CustomToolConfig | None = None,
|
||||
allowed_tool_ids: list[int] | None = None,
|
||||
disable_internal_search: bool = False,
|
||||
search_usage_forcing_setting: SearchToolUsage = SearchToolUsage.AUTO,
|
||||
) -> dict[int, list[Tool]]:
|
||||
"""Constructs tools based on persona configuration and available APIs.
|
||||
|
||||
@@ -146,6 +154,7 @@ def construct_tools(
|
||||
if user and user.oauth_accounts:
|
||||
user_oauth_token = user.oauth_accounts[0].access_token
|
||||
|
||||
added_search_tool = False
|
||||
for db_tool_model in persona.tools:
|
||||
# If allowed_tool_ids is specified, skip tools not in the allowed list
|
||||
if allowed_tool_ids is not None and db_tool_model.id not in allowed_tool_ids:
|
||||
@@ -171,7 +180,8 @@ def construct_tools(
|
||||
|
||||
# Handle Internal Search Tool
|
||||
if tool_cls.__name__ == SearchTool.__name__:
|
||||
if disable_internal_search:
|
||||
added_search_tool = True
|
||||
if search_usage_forcing_setting == SearchToolUsage.DISABLED:
|
||||
continue
|
||||
|
||||
if not search_tool_config:
|
||||
@@ -180,7 +190,6 @@ def construct_tools(
|
||||
search_settings = get_current_search_settings(db_session)
|
||||
document_index = get_default_document_index(search_settings, None)
|
||||
|
||||
# TODO concerning passing the db_session here.
|
||||
search_tool = SearchTool(
|
||||
tool_id=db_tool_model.id,
|
||||
db_session=db_session,
|
||||
@@ -371,6 +380,36 @@ def construct_tools(
|
||||
f"Tool '{expected_tool_name}' not found in MCP server '{mcp_server.name}'"
|
||||
)
|
||||
|
||||
if (
|
||||
not added_search_tool
|
||||
and search_usage_forcing_setting == SearchToolUsage.ENABLED
|
||||
):
|
||||
# Get the database tool model for SearchTool
|
||||
search_tool_db_model = get_builtin_tool(db_session, SearchTool)
|
||||
|
||||
# Use the passed-in config if available, otherwise create a new one
|
||||
if not search_tool_config:
|
||||
search_tool_config = SearchToolConfig()
|
||||
|
||||
search_settings = get_current_search_settings(db_session)
|
||||
document_index = get_default_document_index(search_settings, None)
|
||||
search_tool = SearchTool(
|
||||
tool_id=search_tool_db_model.id,
|
||||
db_session=db_session,
|
||||
emitter=emitter,
|
||||
user=user,
|
||||
persona=persona,
|
||||
llm=llm,
|
||||
fast_llm=fast_llm,
|
||||
document_index=document_index,
|
||||
user_selected_filters=search_tool_config.user_selected_filters,
|
||||
project_id=search_tool_config.project_id,
|
||||
bypass_acl=search_tool_config.bypass_acl,
|
||||
slack_context=search_tool_config.slack_context,
|
||||
)
|
||||
|
||||
tool_dict[search_tool_db_model.id] = [search_tool]
|
||||
|
||||
tools: list[Tool] = []
|
||||
for tool_list in tool_dict.values():
|
||||
tools.extend(tool_list)
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { usePopup } from "@/components/admin/connectors/Popup";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/chat/services/searchParams";
|
||||
import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
import OnyxInitializingLoader from "@/components/OnyxInitializingLoader";
|
||||
import { OnyxDocument, MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import { useSettingsContext } from "@/components/settings/SettingsProvider";
|
||||
@@ -110,6 +111,12 @@ export default function ChatPage({ firstMessage, headerData }: ChatPageProps) {
|
||||
clearLastFailedFiles,
|
||||
} = useProjectsContext();
|
||||
|
||||
// When changing from project chat to main chat (or vice-versa), clear forced tools
|
||||
const { setForcedToolIds } = useForcedTools();
|
||||
useEffect(() => {
|
||||
setForcedToolIds([]);
|
||||
}, [currentProjectId, setForcedToolIds]);
|
||||
|
||||
// handle redirect if chat page is disabled
|
||||
// NOTE: this must be done here, in a client component since
|
||||
// settings are passed in via Context and therefore aren't
|
||||
@@ -666,7 +673,7 @@ export default function ChatPage({ firstMessage, headerData }: ChatPageProps) {
|
||||
{/* ChatInputBar container */}
|
||||
<div
|
||||
ref={inputRef}
|
||||
className="max-w-[50rem] w-full pointer-events-auto flex flex-col px-4 justify-center items-center"
|
||||
className="max-w-[50rem] w-full pointer-events-auto flex flex-col justify-center items-center"
|
||||
>
|
||||
{(showOnboarding ||
|
||||
(user?.role !== UserRole.ADMIN &&
|
||||
|
||||
@@ -52,7 +52,7 @@ const TooltipContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
side={side}
|
||||
className={cn(
|
||||
"z-tooltip rounded-08 px-3 py-2 text-text-inverted-05 animate-in fade-in-0 zoom-in-95 bg-background-neutral-dark-03 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
"z-[30001] rounded-08 px-3 py-2 text-text-inverted-05 animate-in fade-in-0 zoom-in-95 bg-background-neutral-dark-03 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
width,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ActionItemProps {
|
||||
hasNoConnectors?: boolean;
|
||||
toolAuthStatus?: ToolAuthStatus;
|
||||
onOAuthAuthenticate?: () => void;
|
||||
isProjectContext?: boolean;
|
||||
}
|
||||
|
||||
export default function ActionLineItem({
|
||||
@@ -38,13 +39,20 @@ export default function ActionLineItem({
|
||||
hasNoConnectors = false,
|
||||
toolAuthStatus,
|
||||
onOAuthAuthenticate,
|
||||
isProjectContext = false,
|
||||
}: ActionItemProps) {
|
||||
const Icon = tool ? getIconForAction(tool) : ProvidedIcon!;
|
||||
const label = tool ? tool.display_name || tool.name : providedLabel!;
|
||||
const toolName = tool?.name || providedLabel || "";
|
||||
|
||||
let label = tool ? tool.display_name || tool.name : providedLabel!;
|
||||
if (isProjectContext && tool?.in_code_tool_id === SEARCH_TOOL_ID) {
|
||||
label = "Project Search";
|
||||
}
|
||||
|
||||
const isSearchToolWithNoConnectors =
|
||||
tool?.in_code_tool_id === SEARCH_TOOL_ID && hasNoConnectors;
|
||||
!isProjectContext &&
|
||||
tool?.in_code_tool_id === SEARCH_TOOL_ID &&
|
||||
hasNoConnectors;
|
||||
|
||||
return (
|
||||
<SimpleTooltip tooltip={tool?.description} className="max-w-[30rem]">
|
||||
@@ -56,7 +64,7 @@ export default function ActionLineItem({
|
||||
onForceToggle();
|
||||
}}
|
||||
selected={isForced}
|
||||
strikethrough={disabled}
|
||||
strikethrough={disabled || isSearchToolWithNoConnectors}
|
||||
icon={Icon}
|
||||
rightChildren={
|
||||
<div className="flex flex-row items-center gap-1">
|
||||
@@ -92,25 +100,32 @@ export default function ActionLineItem({
|
||||
tooltip={disabled ? "Enable" : "Disable"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tool && tool.in_code_tool_id === SEARCH_TOOL_ID && (
|
||||
<IconButton
|
||||
icon={
|
||||
isSearchToolWithNoConnectors ? SvgSettings : SvgChevronRight
|
||||
}
|
||||
onClick={noProp(() => {
|
||||
if (isSearchToolWithNoConnectors)
|
||||
window.location.href = "/admin/add-connector";
|
||||
else onSourceManagementOpen?.();
|
||||
})}
|
||||
internal
|
||||
className={cn(
|
||||
isSearchToolWithNoConnectors &&
|
||||
"invisible grouop-hover/LineItem:visible"
|
||||
)}
|
||||
tooltip={isSearchToolWithNoConnectors ? "Settings" : "More"}
|
||||
/>
|
||||
)}
|
||||
{tool &&
|
||||
tool.in_code_tool_id === SEARCH_TOOL_ID &&
|
||||
!isProjectContext && (
|
||||
<IconButton
|
||||
icon={
|
||||
isSearchToolWithNoConnectors
|
||||
? SvgSettings
|
||||
: SvgChevronRight
|
||||
}
|
||||
onClick={noProp(() => {
|
||||
if (isSearchToolWithNoConnectors)
|
||||
window.location.href = "/admin/add-connector";
|
||||
else onSourceManagementOpen?.();
|
||||
})}
|
||||
internal
|
||||
className={cn(
|
||||
isSearchToolWithNoConnectors &&
|
||||
"invisible group-hover/LineItem:visible"
|
||||
)}
|
||||
tooltip={
|
||||
isSearchToolWithNoConnectors
|
||||
? "Setup Connectors"
|
||||
: "Configure Connectors"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -36,6 +36,7 @@ import ActionLineItem from "@/refresh-components/popovers/ActionsPopover/ActionL
|
||||
import MCPLineItem, {
|
||||
MCPServer,
|
||||
} from "@/refresh-components/popovers/ActionsPopover/MCPLineItem";
|
||||
import { useProjectsContext } from "@/app/chat/projects/ProjectsContext";
|
||||
import { SvgActions, SvgChevronRight, SvgKey, SvgSliders } from "@opal/icons";
|
||||
|
||||
// Get source metadata for configured sources - deduplicated by source type
|
||||
@@ -142,7 +143,9 @@ export default function ActionsPopover({
|
||||
|
||||
const { tools: availableTools } = useAvailableTools();
|
||||
const { ccPairs } = useCCPairs();
|
||||
const { currentProjectId, allCurrentProjectFiles } = useProjectsContext();
|
||||
const availableToolIds = availableTools.map((tool) => tool.id);
|
||||
const isProjectContext = currentProjectId !== null;
|
||||
|
||||
// Check if there are any connectors available
|
||||
const hasNoConnectors = !ccPairs || ccPairs.length === 0;
|
||||
@@ -184,6 +187,16 @@ export default function ActionsPopover({
|
||||
// Filter out tools that are not chat-selectable (visibility set by backend)
|
||||
if (!tool.chat_selectable) return false;
|
||||
|
||||
// Special handling for Project Search
|
||||
// Ensure Project Search is hidden if no files exist
|
||||
if (tool.in_code_tool_id === SEARCH_TOOL_ID && isProjectContext) {
|
||||
if (!allCurrentProjectFiles || allCurrentProjectFiles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
// If files exist, show it (even if backend thinks it's strictly unavailable due to no connectors)
|
||||
return true;
|
||||
}
|
||||
|
||||
// Advertise to admin/curator users that they can connect an internal search tool
|
||||
// even if it's not available or has no connectors
|
||||
if (tool.in_code_tool_id === SEARCH_TOOL_ID && (isAdmin || isCurator)) {
|
||||
@@ -518,6 +531,7 @@ export default function ActionsPopover({
|
||||
hasNoConnectors={hasNoConnectors}
|
||||
toolAuthStatus={getToolAuthStatus(tool)}
|
||||
onOAuthAuthenticate={() => authenticateTool(tool)}
|
||||
isProjectContext={isProjectContext}
|
||||
/>
|
||||
)),
|
||||
|
||||
|
||||
Reference in new issue
Block a user