Compare commits

..

21 Commits

Author SHA1 Message Date
Evan Lohn
022e2f0a24 added utils file 2025-02-10 11:05:40 -08:00
Evan Lohn
7afb390256 fixed unit tests 2025-02-10 11:05:07 -08:00
Evan Lohn
8ebb08df09 anthropic tool calling fix 2025-02-10 10:19:55 -08:00
Evan Lohn
02148670e2 k 2025-02-06 19:57:14 -08:00
evan-danswer
9b0cba367e small linear connector improvements (#3929)
* small linear connector improvements

* add todo for url handling
2025-02-07 01:31:49 +00:00
pablonyx
48ac690a70 Multi tenant tests (#3919)
* ensure fail on multi tenant successfully

* attempted fix

* udpate ingration tests

* minor update

* improve

* improve workflow

* fix migrations

* many more logs

* quick fix

* improve

* fix typo

* quick nit

* attempted fix

* very minor clean up
2025-02-07 01:24:00 +00:00
pablodanswer
bfa4fbd691 minor delay 2025-02-06 16:28:38 -08:00
rkuo-danswer
58fdc86d41 fix chromatic save/upload (#3927)
* try adding back some params

* raise timeout

* update chromatic version

* fix typo

* use chromatic imports

* update gitignore

* slim down the config file

* update readme

---------

Co-authored-by: Richard Kuo (Danswer) <rkuo@onyx.app>
2025-02-06 22:02:14 +00:00
pablonyx
6ff452a2e1 Update popup + misc standardization (#3906)
* pop

* various minor improvements

* improvement

* finalize

* update
2025-02-06 21:22:06 +00:00
pablonyx
e9b892301b Improvements to Redis + Vespa debugging
Improvements to Redis + Vespa debugging
2025-02-06 13:30:32 -08:00
pablodanswer
a202e2bf9d Improvements to Redis + Vespa debugging 2025-02-06 13:30:06 -08:00
pablonyx
3bc4e0d12f Very minor robustification (#3926)
* very minor robustification

* robust
2025-02-06 19:55:38 +00:00
trial-danswer
2fc41cd5df Helm Chart Fixes (#3900)
* initial commit for helm chart refactoring

* Continue refactoring helm. I was able to use helm to deploy all of the apps to a cluster in aws. The bottleneck was setting up PVC dynamic provisioning.

* use default storage class

* Fix linter errors

* Fix broken helm test

* update

* Helm chart fixes

* remove reference to ebsstorage

* Fix linter errors

---------

Co-authored-by: jpb80 <jordan.buttkevitz@gmail.com>
2025-02-06 10:41:09 -08:00
pablodanswer
8c42ff2ff8 slackbot configuration fix 2025-02-06 09:36:58 -08:00
rkuo-danswer
6ccb3f085a select only doc_id (#3920)
* select only doc_id

* select more doc ids

* fix user group

---------

Co-authored-by: Richard Kuo (Danswer) <rkuo@onyx.app>
2025-02-06 07:00:40 +00:00
pablonyx
a0a1b431be Various UX improvements
Various improvements
2025-02-05 21:13:22 -08:00
pablodanswer
f137fc78a6 various UX improvements 2025-02-05 21:12:55 -08:00
pablonyx
396f096dda Allows for Slackbots that do not have search enabled
Allow no search
2025-02-05 19:20:20 -08:00
pablodanswer
e04b2d6ff3 Allows for Slackbots that do not have search enabled 2025-02-05 19:19:50 -08:00
pablonyx
cbd8b094bd Minor misc docset updates
Minor misc docset updates
2025-02-05 19:14:32 -08:00
pablodanswer
5c7487e91f ensure tests pass 2025-02-05 17:02:49 -08:00
116 changed files with 2308 additions and 927 deletions

View File

@@ -67,6 +67,7 @@ jobs:
NEXT_PUBLIC_SENTRY_DSN=${{ secrets.SENTRY_DSN }}
NEXT_PUBLIC_GTM_ENABLED=true
NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=true
NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=true
NODE_OPTIONS=--max-old-space-size=8192
# needed due to weird interactions with the builds for different platforms
no-cache: true

View File

@@ -94,16 +94,20 @@ jobs:
cd deployment/docker_compose
ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true \
MULTI_TENANT=true \
AUTH_TYPE=basic \
LOG_LEVEL=DEBUG \
AUTH_TYPE=cloud \
REQUIRE_EMAIL_VERIFICATION=false \
DISABLE_TELEMETRY=true \
IMAGE_TAG=test \
docker compose -f docker-compose.dev.yml -p danswer-stack up -d
DEV_MODE=true \
docker compose -f docker-compose.multitenant-dev.yml -p danswer-stack up -d
id: start_docker_multi_tenant
# In practice, `cloud` Auth type would require OAUTH credentials to be set.
- name: Run Multi-Tenant Integration Tests
run: |
echo "Waiting for 3 minutes to ensure API server is ready..."
sleep 180
echo "Running integration tests..."
docker run --rm --network danswer-stack_default \
--name test-runner \
@@ -112,6 +116,7 @@ jobs:
-e POSTGRES_PASSWORD=password \
-e POSTGRES_DB=postgres \
-e VESPA_HOST=index \
-e LOG_LEVEL=DEBUG \
-e REDIS_HOST=cache \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
@@ -119,6 +124,10 @@ jobs:
-e TEST_WEB_HOSTNAME=test-runner \
-e AUTH_TYPE=cloud \
-e MULTI_TENANT=true \
-e REQUIRE_EMAIL_VERIFICATION=false \
-e DISABLE_TELEMETRY=true \
-e IMAGE_TAG=test \
-e DEV_MODE=true \
onyxdotapp/onyx-integration:test \
/app/tests/integration/multitenant_tests
continue-on-error: true
@@ -126,17 +135,17 @@ jobs:
- name: Check multi-tenant test results
run: |
if [ ${{ steps.run_tests.outcome }} == 'failure' ]; then
echo "Integration tests failed. Exiting with error."
if [ ${{ steps.run_multitenant_tests.outcome }} == 'failure' ]; then
echo "Multi-tenant integration tests failed. Exiting with error."
exit 1
else
echo "All integration tests passed successfully."
echo "All multi-tenant integration tests passed successfully."
fi
- name: Stop multi-tenant Docker containers
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack down -v
docker compose -f docker-compose.multitenant-dev.yml -p danswer-stack down -v
- name: Start Docker containers
run: |
@@ -146,6 +155,7 @@ jobs:
REQUIRE_EMAIL_VERIFICATION=false \
DISABLE_TELEMETRY=true \
IMAGE_TAG=test \
LOG_LEVEL=DEBUG \
docker compose -f docker-compose.dev.yml -p danswer-stack up -d
id: start_docker
@@ -194,6 +204,7 @@ jobs:
-e POSTGRES_DB=postgres \
-e VESPA_HOST=index \
-e REDIS_HOST=cache \
-e LOG_LEVEL=DEBUG \
-e API_SERVER_HOST=api_server \
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
-e SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} \
@@ -216,27 +227,30 @@ jobs:
echo "All integration tests passed successfully."
fi
# save before stopping the containers so the logs can be captured
- name: Save Docker logs
if: success() || failure()
# ------------------------------------------------------------
# Always gather logs BEFORE "down":
- name: Dump API server logs
if: always()
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack logs > docker-compose.log
mv docker-compose.log ${{ github.workspace }}/docker-compose.log
docker compose -f docker-compose.dev.yml -p danswer-stack logs --no-color api_server > $GITHUB_WORKSPACE/api_server.log || true
- name: Stop Docker containers
- name: Dump all-container logs (optional)
if: always()
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack down -v
docker compose -f docker-compose.dev.yml -p danswer-stack logs --no-color > $GITHUB_WORKSPACE/docker-compose.log || true
- name: Upload logs
if: success() || failure()
if: always()
uses: actions/upload-artifact@v4
with:
name: docker-logs
name: docker-all-logs
path: ${{ github.workspace }}/docker-compose.log
# ------------------------------------------------------------
- name: Stop Docker containers
if: always()
run: |
cd deployment/docker_compose
docker compose -f docker-compose.dev.yml -p danswer-stack down -v

View File

@@ -101,7 +101,8 @@ COPY ./alembic_tenants /app/alembic_tenants
COPY ./alembic.ini /app/alembic.ini
COPY supervisord.conf /usr/etc/supervisord.conf
# Escape hatch
# Escape hatch scripts
COPY ./scripts/debugging /app/scripts/debugging
COPY ./scripts/force_delete_connector_by_id.py /app/scripts/force_delete_connector_by_id.py
# Put logo in assets

View File

@@ -5,7 +5,6 @@ Revises: 47e5bef3a1d7
Create Date: 2024-11-06 13:15:53.302644
"""
import logging
from typing import cast
from alembic import op
import sqlalchemy as sa
@@ -20,13 +19,8 @@ down_revision = "47e5bef3a1d7"
branch_labels: None = None
depends_on: None = None
# Configure logging
logger = logging.getLogger("alembic.runtime.migration")
logger.setLevel(logging.INFO)
def upgrade() -> None:
logger.info(f"{revision}: create_table: slack_bot")
# Create new slack_bot table
op.create_table(
"slack_bot",
@@ -63,7 +57,6 @@ def upgrade() -> None:
)
# Handle existing Slack bot tokens first
logger.info(f"{revision}: Checking for existing Slack bot.")
bot_token = None
app_token = None
first_row_id = None
@@ -71,15 +64,12 @@ def upgrade() -> None:
try:
tokens = cast(dict, get_kv_store().load("slack_bot_tokens_config_key"))
except Exception:
logger.warning("No existing Slack bot tokens found.")
tokens = {}
bot_token = tokens.get("bot_token")
app_token = tokens.get("app_token")
if bot_token and app_token:
logger.info(f"{revision}: Found bot and app tokens.")
session = Session(bind=op.get_bind())
new_slack_bot = SlackBot(
name="Slack Bot (Migrated)",
@@ -170,10 +160,9 @@ def upgrade() -> None:
# Clean up old tokens if they existed
try:
if bot_token and app_token:
logger.info(f"{revision}: Removing old bot and app tokens.")
get_kv_store().delete("slack_bot_tokens_config_key")
except Exception:
logger.warning("tried to delete tokens in dynamic config but failed")
pass
# Rename the table
op.rename_table(
"slack_bot_config__standard_answer_category",
@@ -190,8 +179,6 @@ def upgrade() -> None:
# Drop the table with CASCADE to handle dependent objects
op.execute("DROP TABLE slack_bot_config CASCADE")
logger.info(f"{revision}: Migration complete.")
def downgrade() -> None:
# Recreate the old slack_bot_config table
@@ -273,7 +260,7 @@ def downgrade() -> None:
}
get_kv_store().store("slack_bot_tokens_config_key", tokens)
except Exception:
logger.warning("Failed to save tokens back to KV store")
pass
# Drop the new tables in reverse order
op.drop_table("slack_channel_config")

View File

@@ -52,7 +52,11 @@ def upgrade() -> None:
slack_bot_id, persona_id, channel_config, enable_auto_filters, is_default
) VALUES (
:bot_id, NULL,
'{"channel_name": null, "respond_member_group_list": [], "answer_filters": [], "follow_up_tags": []}',
'{"channel_name": null, '
'"respond_member_group_list": [], '
'"answer_filters": [], '
'"follow_up_tags": [], '
'"respond_tag_only": true}',
FALSE, TRUE
)
"""

View File

@@ -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)
"""
)

View File

@@ -2,8 +2,11 @@ from uuid import UUID
from sqlalchemy.orm import Session
from onyx.configs.constants import NotificationType
from onyx.db.models import Persona__User
from onyx.db.models import Persona__UserGroup
from onyx.db.notification import create_notification
from onyx.server.features.persona.models import PersonaSharedNotificationData
def make_persona_private(
@@ -23,6 +26,14 @@ def make_persona_private(
for user_uuid in user_ids:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid))
create_notification(
user_id=user_uuid,
notif_type=NotificationType.PERSONA_SHARED,
db_session=db_session,
additional_data=PersonaSharedNotificationData(
persona_id=persona_id,
).model_dump(),
)
if group_ids:
for group_id in group_ids:
db_session.add(

View File

@@ -218,14 +218,14 @@ def fetch_user_groups_for_user(
return db_session.scalars(stmt).all()
def construct_document_select_by_usergroup(
def construct_document_id_select_by_usergroup(
user_group_id: int,
) -> Select:
"""This returns a statement that should be executed using
.yield_per() to minimize overhead. The primary consumers of this function
are background processing task generators."""
stmt = (
select(Document)
select(Document.id)
.join(
DocumentByConnectorCredentialPair,
Document.id == DocumentByConnectorCredentialPair.id,

View File

@@ -64,6 +64,7 @@ async def _get_tenant_id_from_request(
try:
# Look up token data in Redis
token_data = await retrieve_auth_token_data_from_redis(request)
if not token_data:
@@ -87,13 +88,14 @@ async def _get_tenant_id_from_request(
if not is_valid_schema_name(tenant_id):
raise HTTPException(status_code=400, detail="Invalid tenant ID format")
return tenant_id
except Exception as e:
logger.error(f"Unexpected error in _get_tenant_id_from_request: {str(e)}")
raise HTTPException(status_code=500, detail="Internal server error")
finally:
if tenant_id:
return tenant_id
# As a final step, check for explicit tenant_id cookie
tenant_id_cookie = request.cookies.get(TENANT_ID_COOKIE_NAME)
if tenant_id_cookie and is_valid_schema_name(tenant_id_cookie):

View File

@@ -24,6 +24,7 @@ from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email
from ee.onyx.server.tenants.user_mapping import user_owns_a_tenant
from onyx.auth.users import exceptions
from onyx.configs.app_configs import CONTROL_PLANE_API_BASE_URL
from onyx.configs.app_configs import DEV_MODE
from onyx.configs.constants import MilestoneRecordType
from onyx.db.engine import get_session_with_tenant
from onyx.db.engine import get_sqlalchemy_engine
@@ -85,7 +86,8 @@ async def create_tenant(email: str, referral_source: str | None = None) -> str:
# Provision tenant on data plane
await provision_tenant(tenant_id, email)
# Notify control plane
await notify_control_plane(tenant_id, email, referral_source)
if not DEV_MODE:
await notify_control_plane(tenant_id, email, referral_source)
except Exception as e:
logger.error(f"Tenant provisioning failed: {e}")
await rollback_tenant_provisioning(tenant_id)

View File

@@ -3,7 +3,6 @@ from langgraph.graph import START
from langgraph.graph import StateGraph
from onyx.agents.agent_search.basic.states import BasicInput
from onyx.agents.agent_search.basic.states import BasicOutput
from onyx.agents.agent_search.basic.states import BasicState
from onyx.agents.agent_search.orchestration.nodes.basic_use_tool_response import (
basic_use_tool_response,
@@ -13,6 +12,8 @@ from onyx.agents.agent_search.orchestration.nodes.prepare_tool_input import (
prepare_tool_input,
)
from onyx.agents.agent_search.orchestration.nodes.tool_call import tool_call
from onyx.agents.agent_search.orchestration.states import ToolChoiceUpdate
from onyx.configs.agent_configs import AGENT_MAX_TOOL_CALLS
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -22,7 +23,7 @@ def basic_graph_builder() -> StateGraph:
graph = StateGraph(
state_schema=BasicState,
input=BasicInput,
output=BasicOutput,
output=ToolChoiceUpdate,
)
### Add nodes ###
@@ -60,11 +61,15 @@ def basic_graph_builder() -> StateGraph:
end_key="basic_use_tool_response",
)
graph.add_edge(
start_key="basic_use_tool_response",
end_key=END,
graph.add_conditional_edges(
"basic_use_tool_response", should_continue, ["tool_call", END]
)
# graph.add_edge(
# start_key="basic_use_tool_response",
# end_key=END,
# )
return graph
@@ -72,7 +77,8 @@ def should_continue(state: BasicState) -> str:
return (
# If there are no tool calls, basic graph already streamed the answer
END
if state.tool_choice is None
if state.tool_choices[-1] is None
or len(state.tool_choices) > AGENT_MAX_TOOL_CALLS
else "tool_call"
)

View File

@@ -12,8 +12,9 @@ from onyx.agents.agent_search.deep_search.initial.generate_initial_answer.states
from onyx.agents.agent_search.deep_search.main.models import (
AgentRefinedMetrics,
)
from onyx.agents.agent_search.deep_search.main.operations import dispatch_subquestion
from onyx.agents.agent_search.deep_search.main.operations import (
dispatch_subquestion,
dispatch_subquestion_sep,
)
from onyx.agents.agent_search.deep_search.main.states import (
InitialQuestionDecompositionUpdate,
@@ -109,9 +110,12 @@ def decompose_orig_question(
),
writer,
)
# dispatches custom events for subquestion tokens, adding in subquestion ids.
streamed_tokens = dispatch_separated(
model.stream(msg), dispatch_subquestion(0, writer)
model.stream(msg),
dispatch_subquestion(0, writer),
sep_callback=dispatch_subquestion_sep(0, writer),
)
stop_event = StreamStopInfo(

View File

@@ -30,11 +30,12 @@ def route_initial_tool_choice(
LangGraph edge to route to agent search.
"""
agent_config = cast(GraphConfig, config["metadata"]["config"])
if state.tool_choice is not None:
if state.tool_choices[-1] is not None:
if (
agent_config.behavior.use_agentic_search
and agent_config.tooling.search_tool is not None
and state.tool_choice.tool.name == agent_config.tooling.search_tool.name
and state.tool_choices[-1].tool.name
== agent_config.tooling.search_tool.name
):
return "start_agent_search"
else:

View File

@@ -9,8 +9,9 @@ from langgraph.types import StreamWriter
from onyx.agents.agent_search.deep_search.main.models import (
RefinementSubQuestion,
)
from onyx.agents.agent_search.deep_search.main.operations import dispatch_subquestion
from onyx.agents.agent_search.deep_search.main.operations import (
dispatch_subquestion,
dispatch_subquestion_sep,
)
from onyx.agents.agent_search.deep_search.main.states import MainState
from onyx.agents.agent_search.deep_search.main.states import (
@@ -96,7 +97,9 @@ def create_refined_sub_questions(
model = graph_config.tooling.fast_llm
streamed_tokens = dispatch_separated(
model.stream(msg), dispatch_subquestion(1, writer)
model.stream(msg),
dispatch_subquestion(1, writer),
sep_callback=dispatch_subquestion_sep(1, writer),
)
response = merge_content(*streamed_tokens)

View File

@@ -9,6 +9,9 @@ from onyx.agents.agent_search.shared_graph_utils.models import (
SubQuestionAnswerResults,
)
from onyx.agents.agent_search.shared_graph_utils.utils import write_custom_event
from onyx.chat.models import StreamStopInfo
from onyx.chat.models import StreamStopReason
from onyx.chat.models import StreamType
from onyx.chat.models import SubQuestionPiece
from onyx.context.search.models import IndexFilters
from onyx.tools.models import SearchQueryInfo
@@ -34,6 +37,22 @@ def dispatch_subquestion(
return _helper
def dispatch_subquestion_sep(level: int, writer: StreamWriter) -> Callable[[int], None]:
def _helper(sep_num: int) -> None:
write_custom_event(
"stream_finished",
StreamStopInfo(
stop_reason=StreamStopReason.FINISHED,
stream_type=StreamType.SUB_QUESTIONS,
level=level,
level_question_num=sep_num,
),
writer,
)
return _helper
def calculate_initial_agent_stats(
decomp_answer_results: list[SubQuestionAnswerResults],
original_question_stats: AgentChunkRetrievalStats,

View File

@@ -4,10 +4,11 @@ from langchain_core.messages import AIMessageChunk
from langchain_core.runnables.config import RunnableConfig
from langgraph.types import StreamWriter
from onyx.agents.agent_search.basic.states import BasicOutput
from onyx.agents.agent_search.basic.states import BasicState
from onyx.agents.agent_search.basic.utils import process_llm_stream
from onyx.agents.agent_search.models import GraphConfig
from onyx.agents.agent_search.orchestration.states import ToolChoiceUpdate
from onyx.agents.agent_search.orchestration.utils import get_tool_choice_update
from onyx.chat.models import LlmDoc
from onyx.chat.models import OnyxContexts
from onyx.tools.tool_implementations.search.search_tool import (
@@ -23,11 +24,15 @@ logger = setup_logger()
def basic_use_tool_response(
state: BasicState, config: RunnableConfig, writer: StreamWriter = lambda _: None
) -> BasicOutput:
) -> ToolChoiceUpdate:
agent_config = cast(GraphConfig, config["metadata"]["config"])
structured_response_format = agent_config.inputs.structured_response_format
llm = agent_config.tooling.primary_llm
tool_choice = state.tool_choice
assert (
len(state.tool_choices) > 0
), "Tool choice node must have at least one tool choice"
tool_choice = state.tool_choices[-1]
if tool_choice is None:
raise ValueError("Tool choice is None")
tool = tool_choice.tool
@@ -61,6 +66,8 @@ def basic_use_tool_response(
stream = llm.stream(
prompt=new_prompt_builder.build(),
structured_response_format=structured_response_format,
tools=[_tool.tool_definition() for _tool in agent_config.tooling.tools],
tool_choice=None,
)
# For now, we don't do multiple tool calls, so we ignore the tool_message
@@ -74,4 +81,4 @@ def basic_use_tool_response(
displayed_search_results=initial_search_results or final_search_results,
)
return BasicOutput(tool_call_chunk=new_tool_call_chunk)
return get_tool_choice_update(new_tool_call_chunk, agent_config.tooling.tools)

View File

@@ -1,21 +1,21 @@
from typing import cast
from uuid import uuid4
from langchain_core.messages import ToolCall
from langchain_core.runnables.config import RunnableConfig
from langgraph.types import StreamWriter
from onyx.agents.agent_search.basic.states import BasicState
from onyx.agents.agent_search.basic.utils import process_llm_stream
from onyx.agents.agent_search.models import GraphConfig
from onyx.agents.agent_search.orchestration.states import ToolChoice
from onyx.agents.agent_search.orchestration.states import ToolChoiceState
from onyx.agents.agent_search.orchestration.states import ToolChoiceUpdate
from onyx.agents.agent_search.orchestration.utils import get_tool_choice_update
from onyx.chat.prompt_builder.answer_prompt_builder import AnswerPromptBuilder
from onyx.chat.tool_handling.tool_response_handler import get_tool_by_name
from onyx.chat.tool_handling.tool_response_handler import (
get_tool_call_for_non_tool_calling_llm_impl,
)
from onyx.tools.tool import Tool
from onyx.llm.interfaces import ToolChoiceOptions
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -26,7 +26,7 @@ logger = setup_logger()
# from the state and config
# TODO: fan-out to multiple tool call nodes? Make this configurable?
def llm_tool_choice(
state: ToolChoiceState,
state: BasicState,
config: RunnableConfig,
writer: StreamWriter = lambda _: None,
) -> ToolChoiceUpdate:
@@ -72,11 +72,13 @@ def llm_tool_choice(
# This only happens if the tool call was forced or we are using a non-tool calling LLM.
if tool and tool_args:
return ToolChoiceUpdate(
tool_choice=ToolChoice(
tool=tool,
tool_args=tool_args,
id=str(uuid4()),
),
tool_choices=[
ToolChoice(
tool=tool,
tool_args=tool_args,
id=str(uuid4()),
)
],
)
# if we're skipping gen ai answer generation, we should only
@@ -84,7 +86,7 @@ def llm_tool_choice(
# the tool calling llm in the stream() below)
if skip_gen_ai_answer_generation and not force_use_tool.force_use:
return ToolChoiceUpdate(
tool_choice=None,
tool_choices=[None],
)
built_prompt = (
@@ -99,7 +101,9 @@ def llm_tool_choice(
# may choose to not call any tools and just generate the answer, in which case the task prompt is needed.
prompt=built_prompt,
tools=[tool.tool_definition() for tool in tools] or None,
tool_choice=("required" if tools and force_use_tool.force_use else None),
tool_choice=(
ToolChoiceOptions.REQUIRED if tools and force_use_tool.force_use else None
),
structured_response_format=structured_response_format,
)
@@ -110,45 +114,4 @@ def llm_tool_choice(
writer,
)
# If no tool calls are emitted by the LLM, we should not choose a tool
if len(tool_message.tool_calls) == 0:
logger.debug("No tool calls emitted by LLM")
return ToolChoiceUpdate(
tool_choice=None,
)
# TODO: here we could handle parallel tool calls. Right now
# we just pick the first one that matches.
selected_tool: Tool | None = None
selected_tool_call_request: ToolCall | None = None
for tool_call_request in tool_message.tool_calls:
known_tools_by_name = [
tool for tool in tools if tool.name == tool_call_request["name"]
]
if known_tools_by_name:
selected_tool = known_tools_by_name[0]
selected_tool_call_request = tool_call_request
break
logger.error(
"Tool call requested with unknown name field. \n"
f"tools: {tools}"
f"tool_call_request: {tool_call_request}"
)
if not selected_tool or not selected_tool_call_request:
raise ValueError(
f"Tool call attempted with tool {selected_tool}, request {selected_tool_call_request}"
)
logger.debug(f"Selected tool: {selected_tool.name}")
logger.debug(f"Selected tool call request: {selected_tool_call_request}")
return ToolChoiceUpdate(
tool_choice=ToolChoice(
tool=selected_tool,
tool_args=selected_tool_call_request["args"],
id=selected_tool_call_request["id"],
),
)
return get_tool_choice_update(tool_message, tools)

View File

@@ -37,7 +37,10 @@ def tool_call(
cast(GraphConfig, config["metadata"]["config"])
tool_choice = state.tool_choice
assert (
len(state.tool_choices) > 0
), "Tool call node must have at least one tool choice"
tool_choice = state.tool_choices[-1]
if tool_choice is None:
raise ValueError("Cannot invoke tool call node without a tool choice")

View File

@@ -1,3 +1,6 @@
from operator import add
from typing import Annotated
from pydantic import BaseModel
from onyx.chat.prompt_builder.answer_prompt_builder import PromptSnapshot
@@ -41,7 +44,7 @@ class ToolChoice(BaseModel):
class ToolChoiceUpdate(BaseModel):
tool_choice: ToolChoice | None = None
tool_choices: Annotated[list[ToolChoice | None], add] = []
class ToolChoiceState(ToolChoiceUpdate, ToolChoiceInput):

View File

@@ -0,0 +1,58 @@
from langchain_core.messages import AIMessageChunk
from langchain_core.messages import ToolCall
from onyx.agents.agent_search.orchestration.states import ToolChoice
from onyx.agents.agent_search.orchestration.states import ToolChoiceUpdate
from onyx.tools.tool import Tool
from onyx.utils.logger import setup_logger
logger = setup_logger()
def get_tool_choice_update(
tool_message: AIMessageChunk, tools: list[Tool]
) -> ToolChoiceUpdate:
# If no tool calls are emitted by the LLM, we should not choose a tool
if len(tool_message.tool_calls) == 0:
logger.debug("No tool calls emitted by LLM")
return ToolChoiceUpdate(
tool_choices=[None],
)
# TODO: here we could handle parallel tool calls. Right now
# we just pick the first one that matches.
selected_tool: Tool | None = None
selected_tool_call_request: ToolCall | None = None
for tool_call_request in tool_message.tool_calls:
known_tools_by_name = [
tool for tool in tools if tool.name == tool_call_request["name"]
]
if known_tools_by_name:
selected_tool = known_tools_by_name[0]
selected_tool_call_request = tool_call_request
break
logger.error(
"Tool call requested with unknown name field. \n"
f"tools: {tools}"
f"tool_call_request: {tool_call_request}"
)
if not selected_tool or not selected_tool_call_request:
raise ValueError(
f"Tool call attempted with tool {selected_tool}, request {selected_tool_call_request}"
)
logger.debug(f"Selected tool: {selected_tool.name}")
logger.debug(f"Selected tool call request: {selected_tool_call_request}")
return ToolChoiceUpdate(
tool_choices=[
ToolChoice(
tool=selected_tool,
tool_args=selected_tool_call_request["args"],
id=selected_tool_call_request["id"],
)
],
)

View File

@@ -295,6 +295,7 @@ def _dispatch_nonempty(
def dispatch_separated(
tokens: Iterator[BaseMessage],
dispatch_event: Callable[[str, int], None],
sep_callback: Callable[[int], None] | None = None,
sep: str = DISPATCH_SEP_CHAR,
) -> list[BaseMessage_Content]:
num = 1
@@ -304,6 +305,10 @@ def dispatch_separated(
if sep in content:
sub_question_parts = content.split(sep)
_dispatch_nonempty(sub_question_parts[0], dispatch_event, num)
if sep_callback:
sep_callback(num)
num += 1
_dispatch_nonempty(
"".join(sub_question_parts[1:]).strip(), dispatch_event, num
@@ -312,6 +317,9 @@ def dispatch_separated(
_dispatch_nonempty(content, dispatch_event, num)
streamed_tokens.append(content)
if sep_callback:
sep_callback(num)
return streamed_tokens

View File

@@ -179,11 +179,14 @@ def try_generate_document_cc_pair_cleanup_tasks(
if tasks_generated is None:
raise ValueError("RedisConnectorDeletion.generate_tasks returned None")
insert_sync_record(
db_session=db_session,
entity_id=cc_pair_id,
sync_type=SyncType.CONNECTOR_DELETION,
)
try:
insert_sync_record(
db_session=db_session,
entity_id=cc_pair_id,
sync_type=SyncType.CONNECTOR_DELETION,
)
except Exception:
pass
except TaskDependencyError:
redis_connector.delete.set_fence(None)

View File

@@ -19,6 +19,7 @@ from onyx.llm.utils import message_to_prompt_and_imgs
from onyx.llm.utils import model_supports_image_input
from onyx.natural_language_processing.utils import get_tokenizer
from onyx.prompts.chat_prompts import CHAT_USER_CONTEXT_FREE_PROMPT
from onyx.prompts.chat_prompts import NO_TOOL_CALL_PREAMBLE
from onyx.prompts.direct_qa_prompts import HISTORY_BLOCK
from onyx.prompts.prompt_utils import drop_messages_history_overflow
from onyx.prompts.prompt_utils import handle_onyx_date_awareness
@@ -27,6 +28,7 @@ from onyx.tools.models import ToolCallFinalResult
from onyx.tools.models import ToolCallKickoff
from onyx.tools.models import ToolResponse
from onyx.tools.tool import Tool
from onyx.tools.utils import is_anthropic_tool_calling_model
def default_build_system_message(
@@ -138,6 +140,14 @@ class AnswerPromptBuilder:
self.system_message_and_token_cnt = None
return
if is_anthropic_tool_calling_model(
self.llm_config.model_provider, self.llm_config.model_name
):
if isinstance(system_message.content, str):
system_message.content += NO_TOOL_CALL_PREAMBLE
else:
system_message.content.append(NO_TOOL_CALL_PREAMBLE)
self.system_message_and_token_cnt = (
system_message,
check_message_tokens(system_message, self.llm_tokenizer_encode_func),

View File

@@ -12,7 +12,7 @@ AGENT_DEFAULT_EXPLORATORY_SEARCH_RESULTS = 5
AGENT_DEFAULT_MIN_ORIG_QUESTION_DOCS = 3
AGENT_DEFAULT_MAX_ANSWER_CONTEXT_DOCS = 10
AGENT_DEFAULT_MAX_STATIC_HISTORY_WORD_LENGTH = 2000
AGENT_DEFAULT_MAX_TOOL_CALLS = 3
#####
# Agent Configs
#####
@@ -77,4 +77,8 @@ AGENT_MAX_STATIC_HISTORY_WORD_LENGTH = int(
or AGENT_DEFAULT_MAX_STATIC_HISTORY_WORD_LENGTH
) # 2000
AGENT_MAX_TOOL_CALLS = int(
os.environ.get("AGENT_MAX_TOOL_CALLS") or AGENT_DEFAULT_MAX_TOOL_CALLS
) # 1
GRAPH_VERSION_NAME: str = "a"

View File

@@ -91,6 +91,7 @@ class LinearConnector(LoadConnector, PollConnector, OAuthConnector):
f"&response_type=code"
f"&scope=read"
f"&state={state}"
f"&prompt=consent" # prompts user for access; allows choosing workspace
)
@classmethod

View File

@@ -105,6 +105,32 @@ def construct_document_select_for_connector_credential_pair_by_needs_sync(
return stmt
def construct_document_id_select_for_connector_credential_pair_by_needs_sync(
connector_id: int, credential_id: int
) -> Select:
initial_doc_ids_stmt = select(DocumentByConnectorCredentialPair.id).where(
and_(
DocumentByConnectorCredentialPair.connector_id == connector_id,
DocumentByConnectorCredentialPair.credential_id == credential_id,
)
)
stmt = (
select(DbDocument.id)
.where(
DbDocument.id.in_(initial_doc_ids_stmt),
or_(
DbDocument.last_modified
> DbDocument.last_synced, # last_modified is newer than last_synced
DbDocument.last_synced.is_(None), # never synced
),
)
.distinct()
)
return stmt
def get_all_documents_needing_vespa_sync_for_cc_pair(
db_session: Session, cc_pair_id: int
) -> list[DbDocument]:

View File

@@ -545,7 +545,7 @@ def fetch_documents_for_document_set_paginated(
return documents, documents[-1].id if documents else None
def construct_document_select_by_docset(
def construct_document_id_select_by_docset(
document_set_id: int,
current_only: bool = True,
) -> Select:
@@ -554,7 +554,7 @@ def construct_document_select_by_docset(
are background processing task generators."""
stmt = (
select(Document)
select(Document.id)
.join(
DocumentByConnectorCredentialPair,
DocumentByConnectorCredentialPair.id == Document.id,

View File

@@ -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
@@ -19,6 +20,7 @@ from onyx.configs.app_configs import DISABLE_AUTH
from onyx.configs.chat_configs import BING_API_KEY
from onyx.configs.chat_configs import CONTEXT_CHUNKS_ABOVE
from onyx.configs.chat_configs import CONTEXT_CHUNKS_BELOW
from onyx.configs.constants import NotificationType
from onyx.context.search.enums import RecencyBiasSetting
from onyx.db.constants import SLACK_BOT_PERSONA_PREFIX
from onyx.db.models import DocumentSet
@@ -32,6 +34,8 @@ from onyx.db.models import Tool
from onyx.db.models import User
from onyx.db.models import User__UserGroup
from onyx.db.models import UserGroup
from onyx.db.notification import create_notification
from onyx.server.features.persona.models import PersonaSharedNotificationData
from onyx.server.features.persona.models import PersonaSnapshot
from onyx.server.features.persona.models import PersonaUpsertRequest
from onyx.utils.logger import setup_logger
@@ -169,6 +173,15 @@ def make_persona_private(
for user_uuid in user_ids:
db_session.add(Persona__User(persona_id=persona_id, user_id=user_uuid))
create_notification(
user_id=user_uuid,
notif_type=NotificationType.PERSONA_SHARED,
db_session=db_session,
additional_data=PersonaSharedNotificationData(
persona_id=persona_id,
).model_dump(),
)
db_session.commit()
# May cause error if someone switches down to MIT from EE
@@ -708,3 +721,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)

View File

@@ -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,

View File

@@ -1,6 +1,6 @@
import abc
from collections.abc import Iterator
from typing import Literal
from enum import Enum
from langchain.schema.language_model import LanguageModelInput
from langchain_core.messages import AIMessageChunk
@@ -15,7 +15,11 @@ from onyx.utils.logger import setup_logger
logger = setup_logger()
ToolChoiceOptions = Literal["required"] | Literal["auto"] | Literal["none"]
class ToolChoiceOptions(Enum):
REQUIRED = "required"
AUTO = "auto"
NONE = "none"
class LLMConfig(BaseModel):

View File

@@ -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"):

View File

@@ -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:

View File

@@ -801,18 +801,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")

View File

@@ -41,6 +41,13 @@ CHAT_USER_CONTEXT_FREE_PROMPT = f"""
{{user_query}}
""".strip()
# we tried telling anthropic to not make repeated tool calls, but it didn't work very well.
# when anthropic models don't follow this convention, it leads to the user seeing "the model
# decided not to search" for a second, which isn't great UX.
NO_TOOL_CALL_PREAMBLE = (
"\nThe first time you call a tool, call it IMMEDIATELY without a textual preamble."
)
# Design considerations for the below:
# - In case of uncertainty, favor yes search so place the "yes" sections near the start of the

View File

@@ -16,9 +16,8 @@ from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisConstants
from onyx.db.connector_credential_pair import get_connector_credential_pair_from_id
from onyx.db.document import (
construct_document_select_for_connector_credential_pair_by_needs_sync,
construct_document_id_select_for_connector_credential_pair_by_needs_sync,
)
from onyx.db.models import Document
from onyx.redis.redis_object_helper import RedisObjectHelper
@@ -72,7 +71,8 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
last_lock_time = time.monotonic()
async_results = []
num_tasks_sent = 0
cc_pair = get_connector_credential_pair_from_id(
db_session=db_session,
cc_pair_id=int(self._id),
@@ -80,14 +80,14 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
if not cc_pair:
return None
stmt = construct_document_select_for_connector_credential_pair_by_needs_sync(
stmt = construct_document_id_select_for_connector_credential_pair_by_needs_sync(
cc_pair.connector_id, cc_pair.credential_id
)
num_docs = 0
for doc in db_session.scalars(stmt).yield_per(DB_YIELD_PER_DEFAULT):
doc = cast(Document, doc)
for doc_id in db_session.scalars(stmt).yield_per(DB_YIELD_PER_DEFAULT):
doc_id = cast(str, doc_id)
current_time = time.monotonic()
if current_time - last_lock_time >= (
CELERY_VESPA_SYNC_BEAT_LOCK_TIMEOUT / 4
@@ -98,7 +98,7 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
num_docs += 1
# check if we should skip the document (typically because it's already syncing)
if doc.id in self.skip_docs:
if doc_id in self.skip_docs:
continue
# celery's default task id format is "dd32ded3-00aa-4884-8b21-42f8332e7fac"
@@ -114,21 +114,21 @@ class RedisConnectorCredentialPair(RedisObjectHelper):
)
# Priority on sync's triggered by new indexing should be medium
result = celery_app.send_task(
celery_app.send_task(
OnyxCeleryTask.VESPA_METADATA_SYNC_TASK,
kwargs=dict(document_id=doc.id, tenant_id=tenant_id),
kwargs=dict(document_id=doc_id, tenant_id=tenant_id),
queue=OnyxCeleryQueues.VESPA_METADATA_SYNC,
task_id=custom_task_id,
priority=OnyxCeleryPriority.MEDIUM,
)
async_results.append(result)
self.skip_docs.add(doc.id)
num_tasks_sent += 1
self.skip_docs.add(doc_id)
if len(async_results) >= max_tasks:
if num_tasks_sent >= max_tasks:
break
return len(async_results), num_docs
return num_tasks_sent, num_docs
class RedisGlobalConnectorCredentialPair:

View File

@@ -14,8 +14,7 @@ from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisConstants
from onyx.db.document_set import construct_document_select_by_docset
from onyx.db.models import Document
from onyx.db.document_set import construct_document_id_select_by_docset
from onyx.redis.redis_object_helper import RedisObjectHelper
@@ -66,10 +65,11 @@ class RedisDocumentSet(RedisObjectHelper):
"""
last_lock_time = time.monotonic()
async_results = []
stmt = construct_document_select_by_docset(int(self._id), current_only=False)
for doc in db_session.scalars(stmt).yield_per(DB_YIELD_PER_DEFAULT):
doc = cast(Document, doc)
num_tasks_sent = 0
stmt = construct_document_id_select_by_docset(int(self._id), current_only=False)
for doc_id in db_session.scalars(stmt).yield_per(DB_YIELD_PER_DEFAULT):
doc_id = cast(str, doc_id)
current_time = time.monotonic()
if current_time - last_lock_time >= (
CELERY_VESPA_SYNC_BEAT_LOCK_TIMEOUT / 4
@@ -86,17 +86,17 @@ class RedisDocumentSet(RedisObjectHelper):
# add to the set BEFORE creating the task.
redis_client.sadd(self.taskset_key, custom_task_id)
result = celery_app.send_task(
celery_app.send_task(
OnyxCeleryTask.VESPA_METADATA_SYNC_TASK,
kwargs=dict(document_id=doc.id, tenant_id=tenant_id),
kwargs=dict(document_id=doc_id, tenant_id=tenant_id),
queue=OnyxCeleryQueues.VESPA_METADATA_SYNC,
task_id=custom_task_id,
priority=OnyxCeleryPriority.LOW,
)
async_results.append(result)
num_tasks_sent += 1
return len(async_results), len(async_results)
return num_tasks_sent, num_tasks_sent
def reset(self) -> None:
self.redis.srem(OnyxRedisConstants.ACTIVE_FENCES, self.fence_key)

View File

@@ -14,7 +14,6 @@ from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisConstants
from onyx.db.models import Document
from onyx.redis.redis_object_helper import RedisObjectHelper
from onyx.utils.variable_functionality import fetch_versioned_implementation
from onyx.utils.variable_functionality import global_version
@@ -66,23 +65,22 @@ class RedisUserGroup(RedisObjectHelper):
user group up to date over multiple batches.
"""
last_lock_time = time.monotonic()
async_results = []
num_tasks_sent = 0
if not global_version.is_ee_version():
return 0, 0
try:
construct_document_select_by_usergroup = fetch_versioned_implementation(
construct_document_id_select_by_usergroup = fetch_versioned_implementation(
"onyx.db.user_group",
"construct_document_select_by_usergroup",
"construct_document_id_select_by_usergroup",
)
except ModuleNotFoundError:
return 0, 0
stmt = construct_document_select_by_usergroup(int(self._id))
for doc in db_session.scalars(stmt).yield_per(DB_YIELD_PER_DEFAULT):
doc = cast(Document, doc)
stmt = construct_document_id_select_by_usergroup(int(self._id))
for doc_id in db_session.scalars(stmt).yield_per(DB_YIELD_PER_DEFAULT):
doc_id = cast(str, doc_id)
current_time = time.monotonic()
if current_time - last_lock_time >= (
CELERY_VESPA_SYNC_BEAT_LOCK_TIMEOUT / 4
@@ -99,17 +97,17 @@ class RedisUserGroup(RedisObjectHelper):
# add to the set BEFORE creating the task.
redis_client.sadd(self.taskset_key, custom_task_id)
result = celery_app.send_task(
celery_app.send_task(
OnyxCeleryTask.VESPA_METADATA_SYNC_TASK,
kwargs=dict(document_id=doc.id, tenant_id=tenant_id),
kwargs=dict(document_id=doc_id, tenant_id=tenant_id),
queue=OnyxCeleryQueues.VESPA_METADATA_SYNC,
task_id=custom_task_id,
priority=OnyxCeleryPriority.LOW,
)
async_results.append(result)
num_tasks_sent += 1
return len(async_results), len(async_results)
return num_tasks_sent, num_tasks_sent
def reset(self) -> None:
self.redis.srem(OnyxRedisConstants.ACTIVE_FENCES, self.fence_key)

View File

@@ -179,12 +179,10 @@ def oauth_callback(
db_session=db_session,
)
# TODO: use a library for url handling
sep = "&" if "?" in desired_return_url else "?"
return CallbackResponse(
redirect_url=(
f"{desired_return_url}?credentialId={credential.id}"
if "?" not in desired_return_url
else f"{desired_return_url}&credentialId={credential.id}"
)
redirect_url=f"{desired_return_url}{sep}credentialId={credential.id}"
)

View File

@@ -247,6 +247,7 @@ def create_bot(
respond_member_group_list=[],
answer_filters=[],
follow_up_tags=[],
respond_tag_only=True,
)
insert_slack_channel_config(
db_session=db_session,

View File

@@ -34,6 +34,7 @@ from onyx.auth.users import current_curator_or_admin_user
from onyx.auth.users import current_user
from onyx.auth.users import optional_user
from onyx.configs.app_configs import AUTH_TYPE
from onyx.configs.app_configs import DEV_MODE
from onyx.configs.app_configs import ENABLE_EMAIL_INVITES
from onyx.configs.app_configs import SESSION_EXPIRE_TIME_SECONDS
from onyx.configs.app_configs import VALID_EMAIL_DOMAINS
@@ -286,7 +287,7 @@ def bulk_invite_users(
detail=f"Invalid email address: {email} - {str(e)}",
)
if MULTI_TENANT:
if MULTI_TENANT and not DEV_MODE:
try:
fetch_ee_implementation_or_noop(
"onyx.server.tenants.provisioning", "add_users_to_tenant", None

View File

@@ -717,15 +717,14 @@ def upload_files_for_chat(
else ChatFileType.PLAIN_TEXT
)
if file_type == ChatFileType.IMAGE:
file_content_io = file.file
# NOTE: Image conversion to JPEG used to be enforced here.
# This was removed to:
# 1. Preserve original file content for downloads
# 2. Maintain transparency in formats like PNG
# 3. Ameliorate issue with file conversion
else:
file_content_io = io.BytesIO(file.file.read())
file_content = file.file.read() # Read the file content
# NOTE: Image conversion to JPEG used to be enforced here.
# This was removed to:
# 1. Preserve original file content for downloads
# 2. Maintain transparency in formats like PNG
# 3. Ameliorate issue with file conversion
file_content_io = io.BytesIO(file_content)
new_content_type = file.content_type
@@ -747,6 +746,7 @@ def upload_files_for_chat(
file_name=file.filename or "",
)
text_file_id = str(uuid.uuid4())
file_store.save_file(
file_name=text_file_id,
content=io.BytesIO(extracted_text.encode()),

View File

@@ -6,6 +6,8 @@ from onyx.configs.app_configs import AZURE_DALLE_API_KEY
from onyx.db.connector import check_connectors_exist
from onyx.db.document import check_docs_exist
from onyx.db.models import LLMProvider
from onyx.llm.llm_provider_options import ANTHROPIC_PROVIDER_NAME
from onyx.llm.llm_provider_options import OPENAI_PROVIDER_NAME
from onyx.natural_language_processing.utils import BaseTokenizer
from onyx.tools.tool import Tool
@@ -18,9 +20,20 @@ OPEN_AI_TOOL_CALLING_MODELS = {
"gpt-4o-mini",
}
ANTHROPIC_TOOL_CALLING_PREFIX = "claude-3-5-sonnet"
def is_anthropic_tool_calling_model(model_provider: str, model_name: str) -> bool:
return model_provider == ANTHROPIC_PROVIDER_NAME and model_name.startswith(
ANTHROPIC_TOOL_CALLING_PREFIX
)
def explicit_tool_calling_supported(model_provider: str, model_name: str) -> bool:
return model_provider == "openai" and model_name in OPEN_AI_TOOL_CALLING_MODELS
return (
model_provider == OPENAI_PROVIDER_NAME
and model_name in OPEN_AI_TOOL_CALLING_MODELS
) or is_anthropic_tool_calling_model(model_provider, model_name)
def compute_tool_tokens(tool: Tool, llm_tokenizer: BaseTokenizer) -> int:

View File

@@ -10,6 +10,8 @@ from uuid import UUID
from redis import Redis
from ee.onyx.server.tenants.user_mapping import get_tenant_id_for_email
from onyx.auth.invited_users import get_invited_users
from onyx.auth.invited_users import write_invited_users
from onyx.configs.app_configs import REDIS_AUTH_KEY_PREFIX
from onyx.configs.app_configs import REDIS_DB_NUMBER
from onyx.configs.app_configs import REDIS_HOST
@@ -21,6 +23,7 @@ from onyx.db.users import get_user_by_email
from onyx.redis.redis_pool import RedisPool
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
from shared_configs.contextvars import CURRENT_TENANT_ID_CONTEXTVAR
# Tool to run helpful operations on Redis in production
# This is targeted for internal usage and may not have all the necessary parameters
@@ -310,6 +313,13 @@ if __name__ == "__main__":
required=False,
)
parser.add_argument(
"--tenant-id",
type=str,
help="Tenant ID for get, delete user token, or add to invited users",
required=False,
)
parser.add_argument(
"--batch",
type=int,
@@ -328,11 +338,32 @@ if __name__ == "__main__":
parser.add_argument(
"--user-email",
type=str,
help="User email for get or delete user token",
help="User email for get, delete user token, or add to invited users",
required=False,
)
args = parser.parse_args()
if args.tenant_id:
CURRENT_TENANT_ID_CONTEXTVAR.set(args.tenant_id)
if args.command == "add_invited_user":
if not args.user_email:
print("Error: --user-email is required for add_invited_user command")
sys.exit(1)
current_invited_users = get_invited_users()
if args.user_email not in current_invited_users:
current_invited_users.append(args.user_email)
if args.dry_run:
print(f"(DRY-RUN) Would add {args.user_email} to invited users")
else:
write_invited_users(current_invited_users)
print(f"Added {args.user_email} to invited users")
else:
print(f"{args.user_email} is already in the invited users list")
sys.exit(0)
exitcode = onyx_redis(
command=args.command,
batch=args.batch,

View File

@@ -255,6 +255,24 @@ def get_documents_for_tenant_connector(
print_documents(documents)
def search_for_document(
index_name: str, document_id: str, max_hits: int | None = 10
) -> List[Dict[str, Any]]:
yql_query = (
f'select * from sources {index_name} where document_id contains "{document_id}"'
)
params: dict[str, Any] = {"yql": yql_query}
if max_hits is not None:
params["hits"] = max_hits
with get_vespa_http_client() as client:
response = client.get(f"{SEARCH_ENDPOINT}/search/", params=params)
response.raise_for_status()
result = response.json()
documents = result.get("root", {}).get("children", [])
logger.info(f"Found {len(documents)} documents from query.")
return documents
def search_documents(
tenant_id: str, connector_id: int, query: str, n: int = 10
) -> None:
@@ -440,10 +458,98 @@ def get_document_acls(
print("-" * 80)
def get_current_chunk_count(
document_id: str, index_name: str, tenant_id: str
) -> int | None:
with get_session_with_tenant(tenant_id=tenant_id) as session:
return (
session.query(Document.chunk_count)
.filter(Document.id == document_id)
.scalar()
)
def get_number_of_chunks_we_think_exist(
document_id: str, index_name: str, tenant_id: str
) -> int:
current_chunk_count = get_current_chunk_count(document_id, index_name, tenant_id)
print(f"Current chunk count: {current_chunk_count}")
doc_info = VespaIndex.enrich_basic_chunk_info(
index_name=index_name,
http_client=get_vespa_http_client(),
document_id=document_id,
previous_chunk_count=current_chunk_count,
new_chunk_count=0,
)
chunk_ids = get_document_chunk_ids(
enriched_document_info_list=[doc_info],
tenant_id=tenant_id,
large_chunks_enabled=False,
)
return len(chunk_ids)
class VespaDebugging:
# Class for managing Vespa debugging actions.
def __init__(self, tenant_id: str | None = None):
self.tenant_id = POSTGRES_DEFAULT_SCHEMA if not tenant_id else tenant_id
self.index_name = get_index_name(self.tenant_id)
def sample_document_counts(self) -> None:
# Sample random documents and compare chunk counts
mismatches = []
no_chunks = []
with get_session_with_tenant(tenant_id=self.tenant_id) as session:
# Get a sample of random documents
from sqlalchemy import func
sample_docs = (
session.query(Document.id, Document.link, Document.semantic_id)
.order_by(func.random())
.limit(1000)
.all()
)
for doc in sample_docs:
document_id, link, semantic_id = doc
(
number_of_chunks_in_vespa,
number_of_chunks_we_think_exist,
) = self.compare_chunk_count(document_id)
if number_of_chunks_in_vespa != number_of_chunks_we_think_exist:
mismatches.append(
(
document_id,
link,
semantic_id,
number_of_chunks_in_vespa,
number_of_chunks_we_think_exist,
)
)
elif number_of_chunks_in_vespa == 0:
no_chunks.append((document_id, link, semantic_id))
# Print results
print("\nDocuments with mismatched chunk counts:")
for doc_id, link, semantic_id, vespa_count, expected_count in mismatches:
print(f"Document ID: {doc_id}")
print(f"Link: {link}")
print(f"Semantic ID: {semantic_id}")
print(f"Chunks in Vespa: {vespa_count}")
print(f"Expected chunks: {expected_count}")
print("-" * 80)
print("\nDocuments with no chunks in Vespa:")
for doc_id, link, semantic_id in no_chunks:
print(f"Document ID: {doc_id}")
print(f"Link: {link}")
print(f"Semantic ID: {semantic_id}")
print("-" * 80)
print(f"\nTotal mismatches: {len(mismatches)}")
print(f"Total documents with no chunks: {len(no_chunks)}")
def print_config(self) -> None:
# Print Vespa config.
@@ -457,6 +563,16 @@ class VespaDebugging:
# List documents for a tenant.
list_documents(n, self.tenant_id)
def compare_chunk_count(self, document_id: str) -> tuple[int, int]:
docs = search_for_document(self.index_name, document_id, max_hits=None)
number_of_chunks_we_think_exist = get_number_of_chunks_we_think_exist(
document_id, self.index_name, self.tenant_id
)
print(
f"Number of chunks in Vespa: {len(docs)}, Number of chunks we think exist: {number_of_chunks_we_think_exist}"
)
return len(docs), number_of_chunks_we_think_exist
def search_documents(self, connector_id: int, query: str, n: int = 10) -> None:
# Search documents for a tenant and connector.
search_documents(self.tenant_id, connector_id, query, n)
@@ -464,9 +580,11 @@ class VespaDebugging:
def update_document(
self, connector_id: int, doc_id: str, fields: Dict[str, Any]
) -> None:
# Update a document.
update_document(self.tenant_id, connector_id, doc_id, fields)
def search_for_document(self, document_id: str) -> List[Dict[str, Any]]:
return search_for_document(self.index_name, document_id)
def delete_document(self, connector_id: int, doc_id: str) -> None:
# Delete a document.
delete_document(self.tenant_id, connector_id, doc_id)
@@ -483,7 +601,6 @@ class VespaDebugging:
def main() -> None:
# Main CLI entry point.
parser = argparse.ArgumentParser(description="Vespa debugging tool")
parser.add_argument(
"--action",

View File

@@ -70,6 +70,7 @@ COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
# Set up application files
COPY ./onyx /app/onyx
COPY ./shared_configs /app/shared_configs
COPY ./alembic_tenants /app/alembic_tenants
COPY ./alembic /app/alembic
COPY ./alembic.ini /app/alembic.ini
COPY ./pytest.ini /app/pytest.ini

View File

@@ -24,35 +24,6 @@ def generate_auth_token() -> str:
class TenantManager:
@staticmethod
def create(
tenant_id: str | None = None,
initial_admin_email: str | None = None,
referral_source: str | None = None,
) -> dict[str, str]:
body = {
"tenant_id": tenant_id,
"initial_admin_email": initial_admin_email,
"referral_source": referral_source,
}
token = generate_auth_token()
headers = {
"Authorization": f"Bearer {token}",
"X-API-KEY": "",
"Content-Type": "application/json",
}
response = requests.post(
url=f"{API_SERVER_URL}/tenants/create",
json=body,
headers=headers,
)
response.raise_for_status()
return response.json()
@staticmethod
def get_all_users(
user_performing_action: DATestUser | None = None,

View File

@@ -92,6 +92,7 @@ class UserManager:
# Set cookies in the headers
test_user.headers["Cookie"] = f"fastapiusersauth={session_cookie}; "
test_user.cookies = {"fastapiusersauth": session_cookie}
return test_user
@staticmethod
@@ -102,6 +103,7 @@ class UserManager:
response = requests.get(
url=f"{API_SERVER_URL}/me",
headers=user_to_verify.headers,
cookies=user_to_verify.cookies,
)
if user_to_verify.is_active is False:

View File

@@ -242,6 +242,18 @@ def reset_postgres_multitenant() -> None:
schema_name = schema[0]
cur.execute(f'DROP SCHEMA "{schema_name}" CASCADE')
# Drop tables in the public schema
cur.execute(
"""
SELECT tablename FROM pg_tables
WHERE schemaname = 'public'
"""
)
public_tables = cur.fetchall()
for table in public_tables:
table_name = table[0]
cur.execute(f'DROP TABLE IF EXISTS public."{table_name}" CASCADE')
cur.close()
conn.close()

View File

@@ -44,6 +44,7 @@ class DATestUser(BaseModel):
headers: dict
role: UserRole
is_active: bool
cookies: dict = {}
class DATestPersonaLabel(BaseModel):

View File

@@ -4,7 +4,6 @@ from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.chat import ChatSessionManager
from tests.integration.common_utils.managers.document import DocumentManager
from tests.integration.common_utils.managers.llm_provider import LLMProviderManager
from tests.integration.common_utils.managers.tenant import TenantManager
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestAPIKey
from tests.integration.common_utils.test_models import DATestCCPair
@@ -13,25 +12,28 @@ from tests.integration.common_utils.test_models import DATestUser
def test_multi_tenant_access_control(reset_multitenant: None) -> None:
# Create Tenant 1 and its Admin User
TenantManager.create("tenant_dev1", "test1@test.com", "Data Plane Registration")
test_user1: DATestUser = UserManager.create(name="test1", email="test1@test.com")
assert UserManager.is_role(test_user1, UserRole.ADMIN)
# Creating an admin user (first user created is automatically an admin and also proviions the tenant
admin_user1: DATestUser = UserManager.create(
email="admin@onyx-test.com",
)
assert UserManager.is_role(admin_user1, UserRole.ADMIN)
# Create Tenant 2 and its Admin User
TenantManager.create("tenant_dev2", "test2@test.com", "Data Plane Registration")
test_user2: DATestUser = UserManager.create(name="test2", email="test2@test.com")
assert UserManager.is_role(test_user2, UserRole.ADMIN)
admin_user2: DATestUser = UserManager.create(
email="admin2@onyx-test.com",
)
assert UserManager.is_role(admin_user2, UserRole.ADMIN)
# Create connectors for Tenant 1
cc_pair_1: DATestCCPair = CCPairManager.create_from_scratch(
user_performing_action=test_user1,
user_performing_action=admin_user1,
)
api_key_1: DATestAPIKey = APIKeyManager.create(
user_performing_action=test_user1,
user_performing_action=admin_user1,
)
api_key_1.headers.update(test_user1.headers)
LLMProviderManager.create(user_performing_action=test_user1)
api_key_1.headers.update(admin_user1.headers)
LLMProviderManager.create(user_performing_action=admin_user1)
# Seed documents for Tenant 1
cc_pair_1.documents = []
@@ -49,13 +51,13 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
# Create connectors for Tenant 2
cc_pair_2: DATestCCPair = CCPairManager.create_from_scratch(
user_performing_action=test_user2,
user_performing_action=admin_user2,
)
api_key_2: DATestAPIKey = APIKeyManager.create(
user_performing_action=test_user2,
user_performing_action=admin_user2,
)
api_key_2.headers.update(test_user2.headers)
LLMProviderManager.create(user_performing_action=test_user2)
api_key_2.headers.update(admin_user2.headers)
LLMProviderManager.create(user_performing_action=admin_user2)
# Seed documents for Tenant 2
cc_pair_2.documents = []
@@ -76,17 +78,17 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
# Create chat sessions for each user
chat_session1: DATestChatSession = ChatSessionManager.create(
user_performing_action=test_user1
user_performing_action=admin_user1
)
chat_session2: DATestChatSession = ChatSessionManager.create(
user_performing_action=test_user2
user_performing_action=admin_user2
)
# User 1 sends a message and gets a response
response1 = ChatSessionManager.send_message(
chat_session_id=chat_session1.id,
message="What is in Tenant 1's documents?",
user_performing_action=test_user1,
user_performing_action=admin_user1,
)
# Assert that the search tool was used
assert response1.tool_name == "run_search"
@@ -100,14 +102,16 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
), "Tenant 2 document IDs should not be in the response"
# Assert that the contents are correct
for doc in response1.tool_result or []:
assert doc["content"] == "Tenant 1 Document Content"
assert any(
doc["content"] == "Tenant 1 Document Content"
for doc in response1.tool_result or []
), "Tenant 1 Document Content not found in any document"
# User 2 sends a message and gets a response
response2 = ChatSessionManager.send_message(
chat_session_id=chat_session2.id,
message="What is in Tenant 2's documents?",
user_performing_action=test_user2,
user_performing_action=admin_user2,
)
# Assert that the search tool was used
assert response2.tool_name == "run_search"
@@ -119,15 +123,18 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
assert not response_doc_ids.intersection(
tenant1_doc_ids
), "Tenant 1 document IDs should not be in the response"
# Assert that the contents are correct
for doc in response2.tool_result or []:
assert doc["content"] == "Tenant 2 Document Content"
assert any(
doc["content"] == "Tenant 2 Document Content"
for doc in response2.tool_result or []
), "Tenant 2 Document Content not found in any document"
# User 1 tries to access Tenant 2's documents
response_cross = ChatSessionManager.send_message(
chat_session_id=chat_session1.id,
message="What is in Tenant 2's documents?",
user_performing_action=test_user1,
user_performing_action=admin_user1,
)
# Assert that the search tool was used
assert response_cross.tool_name == "run_search"
@@ -140,7 +147,7 @@ def test_multi_tenant_access_control(reset_multitenant: None) -> None:
response_cross2 = ChatSessionManager.send_message(
chat_session_id=chat_session2.id,
message="What is in Tenant 1's documents?",
user_performing_action=test_user2,
user_performing_action=admin_user2,
)
# Assert that the search tool was used
assert response_cross2.tool_name == "run_search"

View File

@@ -4,14 +4,12 @@ from onyx.db.models import UserRole
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.connector import ConnectorManager
from tests.integration.common_utils.managers.credential import CredentialManager
from tests.integration.common_utils.managers.tenant import TenantManager
from tests.integration.common_utils.managers.user import UserManager
from tests.integration.common_utils.test_models import DATestUser
# Test flow from creating tenant to registering as a user
def test_tenant_creation(reset_multitenant: None) -> None:
TenantManager.create("tenant_dev", "test@test.com", "Data Plane Registration")
test_user: DATestUser = UserManager.create(name="test", email="test@test.com")
assert UserManager.is_role(test_user, UserRole.ADMIN)

View File

@@ -229,7 +229,7 @@ def test_answer_with_search_call(
)
# Second call should not include tools (as we're just generating the final answer)
assert "tools" not in second_call.kwargs or not second_call.kwargs["tools"]
# assert "tools" not in second_call.kwargs or not second_call.kwargs["tools"]
# Second call should use the returned prompt from build_next_prompt
assert (
second_call.kwargs["prompt"]
@@ -237,7 +237,7 @@ def test_answer_with_search_call(
)
# Verify that tool_definition was called on the mock_search_tool
mock_search_tool.tool_definition.assert_called_once()
assert mock_search_tool.tool_definition.call_count == 2
else:
assert mock_llm.stream.call_count == 1
@@ -310,7 +310,7 @@ def test_answer_with_search_no_tool_calling(
call_args = mock_llm.stream.call_args
# Verify that no tools were passed to the LLM
assert "tools" not in call_args.kwargs or not call_args.kwargs["tools"]
# assert "tools" not in call_args.kwargs or not call_args.kwargs["tools"]
# Verify that the prompt was built correctly
assert (

View File

@@ -0,0 +1,423 @@
services:
api_server:
image: onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}
build:
context: ../../backend
dockerfile: Dockerfile
command: >
/bin/sh -c "
alembic -n schema_private upgrade head &&
echo \"Starting Onyx Api Server\" &&
uvicorn onyx.main:app --host 0.0.0.0 --port 8080"
depends_on:
- relational_db
- index
- cache
- inference_model_server
restart: always
ports:
- "8080:8080"
environment:
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
- MULTI_TENANT=true
- LOG_LEVEL=DEBUG
- AUTH_TYPE=cloud
- REQUIRE_EMAIL_VERIFICATION=false
- DISABLE_TELEMETRY=true
- IMAGE_TAG=test
- DEV_MODE=true
# Auth Settings
- SESSION_EXPIRE_TIME_SECONDS=${SESSION_EXPIRE_TIME_SECONDS:-}
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
- VALID_EMAIL_DOMAINS=${VALID_EMAIL_DOMAINS:-}
- GOOGLE_OAUTH_CLIENT_ID=${GOOGLE_OAUTH_CLIENT_ID:-}
- GOOGLE_OAUTH_CLIENT_SECRET=${GOOGLE_OAUTH_CLIENT_SECRET:-}
- SMTP_SERVER=${SMTP_SERVER:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- ENABLE_EMAIL_INVITES=${ENABLE_EMAIL_INVITES:-}
- EMAIL_FROM=${EMAIL_FROM:-}
- OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID:-}
- OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET:-}
- OPENID_CONFIG_URL=${OPENID_CONFIG_URL:-}
- TRACK_EXTERNAL_IDP_EXPIRY=${TRACK_EXTERNAL_IDP_EXPIRY:-}
- CORS_ALLOWED_ORIGIN=${CORS_ALLOWED_ORIGIN:-}
# Gen AI Settings
- GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-}
- QA_TIMEOUT=${QA_TIMEOUT:-}
- MAX_CHUNKS_FED_TO_CHAT=${MAX_CHUNKS_FED_TO_CHAT:-}
- DISABLE_LLM_CHOOSE_SEARCH=${DISABLE_LLM_CHOOSE_SEARCH:-}
- DISABLE_LLM_QUERY_REPHRASE=${DISABLE_LLM_QUERY_REPHRASE:-}
- DISABLE_GENERATIVE_AI=${DISABLE_GENERATIVE_AI:-}
- DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-}
- LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-}
- BING_API_KEY=${BING_API_KEY:-}
- DISABLE_LLM_DOC_RELEVANCE=${DISABLE_LLM_DOC_RELEVANCE:-}
- GEN_AI_API_KEY=${GEN_AI_API_KEY:-}
- TOKEN_BUDGET_GLOBALLY_ENABLED=${TOKEN_BUDGET_GLOBALLY_ENABLED:-}
# Query Options
- DOC_TIME_DECAY=${DOC_TIME_DECAY:-}
- HYBRID_ALPHA=${HYBRID_ALPHA:-}
- EDIT_KEYWORD_QUERY=${EDIT_KEYWORD_QUERY:-}
- MULTILINGUAL_QUERY_EXPANSION=${MULTILINGUAL_QUERY_EXPANSION:-}
- LANGUAGE_HINT=${LANGUAGE_HINT:-}
- LANGUAGE_CHAT_NAMING_HINT=${LANGUAGE_CHAT_NAMING_HINT:-}
- QA_PROMPT_OVERRIDE=${QA_PROMPT_OVERRIDE:-}
# Other services
- POSTGRES_HOST=relational_db
- POSTGRES_DEFAULT_SCHEMA=${POSTGRES_DEFAULT_SCHEMA:-}
- VESPA_HOST=index
- REDIS_HOST=cache
- WEB_DOMAIN=${WEB_DOMAIN:-}
# Don't change the NLP model configs unless you know what you're doing
- EMBEDDING_BATCH_SIZE=${EMBEDDING_BATCH_SIZE:-}
- DOCUMENT_ENCODER_MODEL=${DOCUMENT_ENCODER_MODEL:-}
- DOC_EMBEDDING_DIM=${DOC_EMBEDDING_DIM:-}
- NORMALIZE_EMBEDDINGS=${NORMALIZE_EMBEDDINGS:-}
- ASYM_QUERY_PREFIX=${ASYM_QUERY_PREFIX:-}
- DISABLE_RERANK_FOR_STREAMING=${DISABLE_RERANK_FOR_STREAMING:-}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-}
- LOG_ALL_MODEL_INTERACTIONS=${LOG_ALL_MODEL_INTERACTIONS:-}
- LOG_DANSWER_MODEL_INTERACTIONS=${LOG_DANSWER_MODEL_INTERACTIONS:-}
- LOG_INDIVIDUAL_MODEL_TOKENS=${LOG_INDIVIDUAL_MODEL_TOKENS:-}
- LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-}
- LOG_ENDPOINT_LATENCY=${LOG_ENDPOINT_LATENCY:-}
- LOG_POSTGRES_LATENCY=${LOG_POSTGRES_LATENCY:-}
- LOG_POSTGRES_CONN_COUNTS=${LOG_POSTGRES_CONN_COUNTS:-}
- CELERY_BROKER_POOL_LIMIT=${CELERY_BROKER_POOL_LIMIT:-}
- LITELLM_CUSTOM_ERROR_MESSAGE_MAPPINGS=${LITELLM_CUSTOM_ERROR_MESSAGE_MAPPINGS:-}
# Egnyte OAuth Configs
- EGNYTE_CLIENT_ID=${EGNYTE_CLIENT_ID:-}
- EGNYTE_CLIENT_SECRET=${EGNYTE_CLIENT_SECRET:-}
- EGNYTE_LOCALHOST_OVERRIDE=${EGNYTE_LOCALHOST_OVERRIDE:-}
# Linear OAuth Configs
- LINEAR_CLIENT_ID=${LINEAR_CLIENT_ID:-}
- LINEAR_CLIENT_SECRET=${LINEAR_CLIENT_SECRET:-}
# Analytics Configs
- SENTRY_DSN=${SENTRY_DSN:-}
# Chat Configs
- HARD_DELETE_CHATS=${HARD_DELETE_CHATS:-}
# Enables the use of bedrock models or IAM Auth
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID:-}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY:-}
- AWS_REGION_NAME=${AWS_REGION_NAME:-}
- API_KEY_HASH_ROUNDS=${API_KEY_HASH_ROUNDS:-}
# Seeding configuration
- USE_IAM_AUTH=${USE_IAM_AUTH:-}
extra_hosts:
- "host.docker.internal:host-gateway"
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
background:
image: onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}
build:
context: ../../backend
dockerfile: Dockerfile
command: >
/bin/sh -c "
if [ -f /etc/ssl/certs/custom-ca.crt ]; then
update-ca-certificates;
fi &&
/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf"
depends_on:
- relational_db
- index
- cache
- inference_model_server
- indexing_model_server
restart: always
environment:
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=true
- MULTI_TENANT=true
- LOG_LEVEL=DEBUG
- AUTH_TYPE=cloud
- REQUIRE_EMAIL_VERIFICATION=false
- DISABLE_TELEMETRY=true
- IMAGE_TAG=test
- ENCRYPTION_KEY_SECRET=${ENCRYPTION_KEY_SECRET:-}
- JWT_PUBLIC_KEY_URL=${JWT_PUBLIC_KEY_URL:-}
# Gen AI Settings (Needed by OnyxBot)
- GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-}
- QA_TIMEOUT=${QA_TIMEOUT:-}
- MAX_CHUNKS_FED_TO_CHAT=${MAX_CHUNKS_FED_TO_CHAT:-}
- DISABLE_LLM_CHOOSE_SEARCH=${DISABLE_LLM_CHOOSE_SEARCH:-}
- DISABLE_LLM_QUERY_REPHRASE=${DISABLE_LLM_QUERY_REPHRASE:-}
- DISABLE_GENERATIVE_AI=${DISABLE_GENERATIVE_AI:-}
- GENERATIVE_MODEL_ACCESS_CHECK_FREQ=${GENERATIVE_MODEL_ACCESS_CHECK_FREQ:-}
- DISABLE_LITELLM_STREAMING=${DISABLE_LITELLM_STREAMING:-}
- LITELLM_EXTRA_HEADERS=${LITELLM_EXTRA_HEADERS:-}
- GEN_AI_API_KEY=${GEN_AI_API_KEY:-}
- BING_API_KEY=${BING_API_KEY:-}
# Query Options
- DOC_TIME_DECAY=${DOC_TIME_DECAY:-}
- HYBRID_ALPHA=${HYBRID_ALPHA:-}
- EDIT_KEYWORD_QUERY=${EDIT_KEYWORD_QUERY:-}
- MULTILINGUAL_QUERY_EXPANSION=${MULTILINGUAL_QUERY_EXPANSION:-}
- LANGUAGE_HINT=${LANGUAGE_HINT:-}
- LANGUAGE_CHAT_NAMING_HINT=${LANGUAGE_CHAT_NAMING_HINT:-}
- QA_PROMPT_OVERRIDE=${QA_PROMPT_OVERRIDE:-}
# Other Services
- POSTGRES_HOST=relational_db
- POSTGRES_USER=${POSTGRES_USER:-}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-}
- POSTGRES_DB=${POSTGRES_DB:-}
- POSTGRES_DEFAULT_SCHEMA=${POSTGRES_DEFAULT_SCHEMA:-}
- VESPA_HOST=index
- REDIS_HOST=cache
- WEB_DOMAIN=${WEB_DOMAIN:-}
# Don't change the NLP model configs unless you know what you're doing
- DOCUMENT_ENCODER_MODEL=${DOCUMENT_ENCODER_MODEL:-}
- DOC_EMBEDDING_DIM=${DOC_EMBEDDING_DIM:-}
- NORMALIZE_EMBEDDINGS=${NORMALIZE_EMBEDDINGS:-}
- ASYM_QUERY_PREFIX=${ASYM_QUERY_PREFIX:-}
- ASYM_PASSAGE_PREFIX=${ASYM_PASSAGE_PREFIX:-}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-}
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
# Indexing Configs
- VESPA_SEARCHER_THREADS=${VESPA_SEARCHER_THREADS:-}
- NUM_INDEXING_WORKERS=${NUM_INDEXING_WORKERS:-}
- ENABLED_CONNECTOR_TYPES=${ENABLED_CONNECTOR_TYPES:-}
- DISABLE_INDEX_UPDATE_ON_SWAP=${DISABLE_INDEX_UPDATE_ON_SWAP:-}
- DASK_JOB_CLIENT_ENABLED=${DASK_JOB_CLIENT_ENABLED:-}
- CONTINUE_ON_CONNECTOR_FAILURE=${CONTINUE_ON_CONNECTOR_FAILURE:-}
- EXPERIMENTAL_CHECKPOINTING_ENABLED=${EXPERIMENTAL_CHECKPOINTING_ENABLED:-}
- CONFLUENCE_CONNECTOR_LABELS_TO_SKIP=${CONFLUENCE_CONNECTOR_LABELS_TO_SKIP:-}
- JIRA_CONNECTOR_LABELS_TO_SKIP=${JIRA_CONNECTOR_LABELS_TO_SKIP:-}
- WEB_CONNECTOR_VALIDATE_URLS=${WEB_CONNECTOR_VALIDATE_URLS:-}
- JIRA_API_VERSION=${JIRA_API_VERSION:-}
- GONG_CONNECTOR_START_TIME=${GONG_CONNECTOR_START_TIME:-}
- NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP=${NOTION_CONNECTOR_ENABLE_RECURSIVE_PAGE_LOOKUP:-}
- GITHUB_CONNECTOR_BASE_URL=${GITHUB_CONNECTOR_BASE_URL:-}
- MAX_DOCUMENT_CHARS=${MAX_DOCUMENT_CHARS:-}
- MAX_FILE_SIZE_BYTES=${MAX_FILE_SIZE_BYTES:-}
# Egnyte OAuth Configs
- EGNYTE_CLIENT_ID=${EGNYTE_CLIENT_ID:-}
- EGNYTE_CLIENT_SECRET=${EGNYTE_CLIENT_SECRET:-}
- EGNYTE_LOCALHOST_OVERRIDE=${EGNYTE_LOCALHOST_OVERRIDE:-}
# Lienar OAuth Configs
- LINEAR_CLIENT_ID=${LINEAR_CLIENT_ID:-}
- LINEAR_CLIENT_SECRET=${LINEAR_CLIENT_SECRET:-}
# Celery Configs (defaults are set in the supervisord.conf file.
# prefer doing that to have one source of defaults)
- CELERY_WORKER_INDEXING_CONCURRENCY=${CELERY_WORKER_INDEXING_CONCURRENCY:-}
- CELERY_WORKER_LIGHT_CONCURRENCY=${CELERY_WORKER_LIGHT_CONCURRENCY:-}
- CELERY_WORKER_LIGHT_PREFETCH_MULTIPLIER=${CELERY_WORKER_LIGHT_PREFETCH_MULTIPLIER:-}
# Onyx SlackBot Configs
- DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER=${DANSWER_BOT_DISABLE_DOCS_ONLY_ANSWER:-}
- DANSWER_BOT_FEEDBACK_VISIBILITY=${DANSWER_BOT_FEEDBACK_VISIBILITY:-}
- DANSWER_BOT_DISPLAY_ERROR_MSGS=${DANSWER_BOT_DISPLAY_ERROR_MSGS:-}
- DANSWER_BOT_RESPOND_EVERY_CHANNEL=${DANSWER_BOT_RESPOND_EVERY_CHANNEL:-}
- DANSWER_BOT_DISABLE_COT=${DANSWER_BOT_DISABLE_COT:-} # Currently unused
- NOTIFY_SLACKBOT_NO_ANSWER=${NOTIFY_SLACKBOT_NO_ANSWER:-}
- DANSWER_BOT_MAX_QPM=${DANSWER_BOT_MAX_QPM:-}
- DANSWER_BOT_MAX_WAIT_TIME=${DANSWER_BOT_MAX_WAIT_TIME:-}
# Logging
# Leave this on pretty please? Nothing sensitive is collected!
# https://docs.onyx.app/more/telemetry
- DISABLE_TELEMETRY=${DISABLE_TELEMETRY:-}
- LOG_LEVEL=${LOG_LEVEL:-info} # Set to debug to get more fine-grained logs
- LOG_ALL_MODEL_INTERACTIONS=${LOG_ALL_MODEL_INTERACTIONS:-} # LiteLLM Verbose Logging
# Log all of Onyx prompts and interactions with the LLM
- LOG_DANSWER_MODEL_INTERACTIONS=${LOG_DANSWER_MODEL_INTERACTIONS:-}
- LOG_INDIVIDUAL_MODEL_TOKENS=${LOG_INDIVIDUAL_MODEL_TOKENS:-}
- LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-}
# Analytics Configs
- SENTRY_DSN=${SENTRY_DSN:-}
# Enterprise Edition stuff
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false}
- USE_IAM_AUTH=${USE_IAM_AUTH:-}
- AWS_REGION_NAME=${AWS_REGION_NAME:-}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID-}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY-}
# Uncomment the line below to use if IAM_AUTH is true and you are using iam auth for postgres
# volumes:
# - ./bundle.pem:/app/bundle.pem:ro
extra_hosts:
- "host.docker.internal:host-gateway"
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# Uncomment the following lines if you need to include a custom CA certificate
# This section enables the use of a custom CA certificate
# If present, the custom CA certificate is mounted as a volume
# The container checks for its existence and updates the system's CA certificates
# This allows for secure communication with services using custom SSL certificates
# Optional volume mount for CA certificate
# volumes:
# # Maps to the CA_CERT_PATH environment variable in the Dockerfile
# - ${CA_CERT_PATH:-./custom-ca.crt}:/etc/ssl/certs/custom-ca.crt:ro
web_server:
image: onyxdotapp/onyx-web-server:${IMAGE_TAG:-latest}
build:
context: ../../web
dockerfile: Dockerfile
args:
- NEXT_PUBLIC_DISABLE_STREAMING=${NEXT_PUBLIC_DISABLE_STREAMING:-false}
- NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA=${NEXT_PUBLIC_NEW_CHAT_DIRECTS_TO_SAME_PERSONA:-false}
- NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_POSITIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS=${NEXT_PUBLIC_NEGATIVE_PREDEFINED_FEEDBACK_OPTIONS:-}
- NEXT_PUBLIC_DISABLE_LOGOUT=${NEXT_PUBLIC_DISABLE_LOGOUT:-}
- NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN=${NEXT_PUBLIC_DEFAULT_SIDEBAR_OPEN:-}
- NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED:-}
# Enterprise Edition only
- NEXT_PUBLIC_THEME=${NEXT_PUBLIC_THEME:-}
# DO NOT TURN ON unless you have EXPLICIT PERMISSION from Onyx.
- NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED=${NEXT_PUBLIC_DO_NOT_USE_TOGGLE_OFF_DANSWER_POWERED:-false}
depends_on:
- api_server
restart: always
environment:
- INTERNAL_URL=http://api_server:8080
- WEB_DOMAIN=${WEB_DOMAIN:-}
- THEME_IS_DARK=${THEME_IS_DARK:-}
- DISABLE_LLM_DOC_RELEVANCE=${DISABLE_LLM_DOC_RELEVANCE:-}
# Enterprise Edition only
- ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${ENABLE_PAID_ENTERPRISE_EDITION_FEATURES:-false}
- NEXT_PUBLIC_CUSTOM_REFRESH_URL=${NEXT_PUBLIC_CUSTOM_REFRESH_URL:-}
inference_model_server:
image: onyxdotapp/onyx-model-server:${IMAGE_TAG:-latest}
build:
context: ../../backend
dockerfile: Dockerfile.model_server
command: >
/bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then
echo 'Skipping service...';
exit 0;
else
exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000;
fi"
restart: on-failure
environment:
- MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-}
# Set to debug to get more fine-grained logs
- LOG_LEVEL=${LOG_LEVEL:-info}
# Analytics Configs
- SENTRY_DSN=${SENTRY_DSN:-}
volumes:
# Not necessary, this is just to reduce download time during startup
- model_cache_huggingface:/root/.cache/huggingface/
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
indexing_model_server:
image: onyxdotapp/onyx-model-server:${IMAGE_TAG:-latest}
build:
context: ../../backend
dockerfile: Dockerfile.model_server
command: >
/bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-false}\" = \"True\" ]; then
echo 'Skipping service...';
exit 0;
else
exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000;
fi"
restart: on-failure
environment:
- INDEX_BATCH_SIZE=${INDEX_BATCH_SIZE:-}
- MIN_THREADS_ML_MODELS=${MIN_THREADS_ML_MODELS:-}
- INDEXING_ONLY=True
# Set to debug to get more fine-grained logs
- LOG_LEVEL=${LOG_LEVEL:-info}
- CLIENT_EMBEDDING_TIMEOUT=${CLIENT_EMBEDDING_TIMEOUT:-}
# Analytics Configs
- SENTRY_DSN=${SENTRY_DSN:-}
volumes:
# Not necessary, this is just to reduce download time during startup
- indexing_huggingface_model_cache:/root/.cache/huggingface/
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
relational_db:
image: postgres:15.2-alpine
command: -c 'max_connections=250'
restart: always
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}
ports:
- "5432:5432"
volumes:
- db_volume:/var/lib/postgresql/data
# This container name cannot have an underscore in it due to Vespa expectations of the URL
index:
image: vespaengine/vespa:8.277.17
restart: always
ports:
- "19071:19071"
- "8081:8081"
volumes:
- vespa_volume:/opt/vespa/var
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
nginx:
image: nginx:1.23.4-alpine
restart: always
# nginx will immediately crash with `nginx: [emerg] host not found in upstream`
# if api_server / web_server are not up
depends_on:
- api_server
- web_server
environment:
- DOMAIN=localhost
ports:
- "80:80"
- "3000:80" # allow for localhost:3000 usage, since that is the norm
volumes:
- ../data/nginx:/etc/nginx/conf.d
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# The specified script waits for the api_server to start up.
# Without this we've seen issues where nginx shows no error logs but
# does not recieve any traffic
# NOTE: we have to use dos2unix to remove Carriage Return chars from the file
# in order to make this work on both Unix-like systems and windows
command: >
/bin/sh -c "dos2unix /etc/nginx/conf.d/run-nginx.sh
&& /etc/nginx/conf.d/run-nginx.sh app.conf.template.dev"
cache:
image: redis:7.4-alpine
restart: always
ports:
- "6379:6379"
# docker silently mounts /data even without an explicit volume mount, which enables
# persistence. explicitly setting save and appendonly forces ephemeral behavior.
command: redis-server --save "" --appendonly no
volumes:
db_volume:
vespa_volume: # Created by the container itself
model_cache_huggingface:
indexing_huggingface_model_cache:

View File

@@ -4,12 +4,12 @@ dependencies:
version: 14.3.1
- name: vespa
repository: https://onyx-dot-app.github.io/vespa-helm-charts
version: 0.2.18
version: 0.2.20
- name: nginx
repository: oci://registry-1.docker.io/bitnamicharts
version: 15.14.0
- name: redis
repository: https://charts.bitnami.com/bitnami
version: 20.1.0
digest: sha256:5c9eb3d55d5f8e3beb64f26d26f686c8d62755daa10e2e6d87530bdf2fbbf957
generated: "2024-12-10T10:47:35.812483-08:00"
digest: sha256:4615c033064a987e3f66a48f4744d2e88bd1cc932c79453c4928455695a72778
generated: "2025-02-04T11:45:05.39228-08:00"

View File

@@ -23,7 +23,7 @@ dependencies:
repository: https://charts.bitnami.com/bitnami
condition: postgresql.enabled
- name: vespa
version: 0.2.18
version: 0.2.20
repository: https://onyx-dot-app.github.io/vespa-helm-charts
condition: vespa.enabled
- name: nginx

View File

@@ -7,10 +7,10 @@ metadata:
data:
INTERNAL_URL: "http://{{ include "onyx-stack.fullname" . }}-api-service:{{ .Values.api.service.port | default 8080 }}"
POSTGRES_HOST: {{ .Release.Name }}-postgresql
VESPA_HOST: da-vespa-0.vespa-service
VESPA_HOST: {{ .Values.vespa.name }}.{{ .Values.vespa.service.name }}.{{ .Release.Namespace }}.svc.cluster.local
REDIS_HOST: {{ .Release.Name }}-redis-master
MODEL_SERVER_HOST: "{{ include "onyx-stack.fullname" . }}-inference-model-service"
INDEXING_MODEL_SERVER_HOST: "{{ include "onyx-stack.fullname" . }}-indexing-model-service"
{{- range $key, $value := .Values.configMap }}
{{ $key }}: "{{ $value }}"
{{- end }}
{{- end }}

View File

@@ -5,6 +5,7 @@
postgresql:
primary:
persistence:
storageClass: ""
size: 5Gi
enabled: true
auth:
@@ -12,13 +13,52 @@ postgresql:
secretKeys:
# overwriting as postgres typically expects 'postgres-password'
adminPasswordKey: postgres_password
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
vespa:
name: da-vespa-0
service:
name: vespa-service
volumeClaimTemplates:
- metadata:
name: vespa-storage
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 1Gi
storageClassName: ""
enabled: true
replicaCount: 1
image:
repository: vespa
pullPolicy: IfNotPresent
tag: "8.277.17"
podAnnotations: {}
podLabels:
app: vespa
app.kubernetes.io/instance: onyx
app.kubernetes.io/name: vespa
securityContext:
privileged: true
runAsUser: 0
resources:
# The Vespa Helm chart specifies default resources, which are quite modest. We override
# them here to increase chances of the chart running successfully.
requests:
cpu: 1500m
memory: 4000Mi
limits:
cpu: 1500m
memory: 4000Mi
persistent:
storageClassName: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
inferenceCapability:
service:
portName: modelserver
@@ -272,15 +312,9 @@ background:
podSecurityContext:
{}
# fsGroup: 2000
securityContext:
{}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
privileged: true
runAsUser: 0
enableMiniChunk: "true"
resources: {}
# We usually recommend not to specify default resources and to leave this as a conscious
@@ -316,50 +350,6 @@ background:
nodeSelector: {}
tolerations: []
vespa:
volumeClaimTemplates:
- metadata:
name: vespa-storage
spec:
accessModes:
- ReadWriteOnce
storageClassName: ""
resources:
requests:
storage: 1Gi
enabled: true
replicaCount: 1
image:
repository: vespa
pullPolicy: IfNotPresent
tag: "8.277.17"
podAnnotations: {}
podLabels:
app: vespa
app.kubernetes.io/instance: onyx
app.kubernetes.io/name: vespa
podSecurityContext:
{}
# fsGroup: 2000
securityContext:
privileged: true
runAsUser: 0
resources:
# The Vespa Helm chart specifies default resources, which are quite modest. We override
# them here to increase chances of the chart running successfully.
requests:
cpu: 1500m
memory: 4000Mi
limits:
cpu: 1500m
memory: 4000Mi
nodeSelector: {}
tolerations: []
affinity: {}
redis:

4
web/.gitignore vendored
View File

@@ -35,6 +35,8 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
# playwright testing temp files
/admin_auth.json
/user_auth.json
/build-archive.log
/test-results

View File

@@ -81,6 +81,9 @@ ENV NEXT_PUBLIC_GTM_ENABLED=${NEXT_PUBLIC_GTM_ENABLED}
ARG NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED
ENV NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED}
ARG NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK
ENV NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=${NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK}
# Use NODE_OPTIONS in the build command
RUN NODE_OPTIONS="${NODE_OPTIONS}" npx next build
@@ -160,6 +163,9 @@ ENV NEXT_PUBLIC_GTM_ENABLED=${NEXT_PUBLIC_GTM_ENABLED}
ARG NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED
ENV NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED=${NEXT_PUBLIC_FORGOT_PASSWORD_ENABLED}
ARG NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK
ENV NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK=${NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK}
# Note: Don't expose ports here, Compose will handle that for us if necessary.
# If you want to run this without compose, specify the ports to
# expose via cli

View File

@@ -21,3 +21,42 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
_Note:_ if you are having problems accessing the ^, try setting the `WEB_DOMAIN` env variable to
`http://127.0.0.1:3000` and accessing it there.
## Testing
This testing process will reset your application into a clean state.
Don't run these tests if you don't want to do this!
Bring up the entire application.
1. Reset the instance
```cd backend
export PYTEST_IGNORE_SKIP=true
pytest -s tests/integration/tests/playwright/test_playwright.py
```
2. Run playwright
```
cd web
npx playwright test
```
3. Inspect results
By default, playwright.config.ts is configured to output the results to:
```
web/test-results
```
4. Upload results to Chromatic (Optional)
This step would normally not be run by third party developers, but first party devs
may use this for local troubleshooting and testing.
```
cd web
npx chromatic --playwright --project-token={your token here}
```

151
web/package-lock.json generated
View File

@@ -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",
@@ -83,11 +84,11 @@
"yup": "^1.4.0"
},
"devDependencies": {
"@chromatic-com/playwright": "^0.10.0",
"@chromatic-com/playwright": "^0.10.2",
"@tailwindcss/typography": "^0.5.10",
"@types/chrome": "^0.0.287",
"@types/jest": "^29.5.14",
"chromatic": "^11.18.1",
"chromatic": "^11.25.2",
"eslint": "^8.48.0",
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",
@@ -756,9 +757,9 @@
"license": "MIT"
},
"node_modules/@chromatic-com/playwright": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@chromatic-com/playwright/-/playwright-0.10.0.tgz",
"integrity": "sha512-QjKnOfuIcq9Y97QwA3MMVzOceXn1ikelUeC8gy60d2PbsQ2NNxH2n/PrAJ8Sllr225mXD1ts9xBH+Hq3+Blo5A==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@chromatic-com/playwright/-/playwright-0.10.2.tgz",
"integrity": "sha512-SfP4I0rWPeSNW5VtV7eiuNSsZYK9IdVPTBT1SnUFJd3lACS1YJJd5s8pTisJvgh5Q8u9VNGWXfeuV3ddGJyRtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -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",
@@ -8442,9 +8577,9 @@
}
},
"node_modules/chromatic": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.18.1.tgz",
"integrity": "sha512-hkNT9vA6K9+PnE/khhZYBnRCOm8NonaQDs7RZ8YHFo7/lh1b/x/uFMkTjWjaj/mkM6QOR/evu5VcZMtcaauSlw==",
"version": "11.25.2",
"resolved": "https://registry.npmjs.org/chromatic/-/chromatic-11.25.2.tgz",
"integrity": "sha512-/9eQWn6BU1iFsop86t8Au21IksTRxwXAl7if8YHD05L2AbuMjClLWZo5cZojqrJHGKDhTqfrC2X2xE4uSm0iKw==",
"dev": true,
"license": "MIT",
"bin": {

View File

@@ -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",
@@ -86,11 +87,11 @@
"yup": "^1.4.0"
},
"devDependencies": {
"@chromatic-com/playwright": "^0.10.0",
"@chromatic-com/playwright": "^0.10.2",
"@tailwindcss/typography": "^0.5.10",
"@types/chrome": "^0.0.287",
"@types/jest": "^29.5.14",
"chromatic": "^11.18.1",
"chromatic": "^11.25.2",
"eslint": "^8.48.0",
"eslint-config-next": "^14.1.0",
"jest": "^29.7.0",

View File

@@ -2,7 +2,19 @@ import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
globalSetup: require.resolve("./tests/e2e/global-setup"),
timeout: 30000, // 30 seconds timeout
timeout: 60000, // 60 seconds timeout
reporter: [
["list"],
// Warning: uncommenting the html reporter may cause the chromatic-archives
// directory to be deleted after the test run, which will break CI.
// [
// 'html',
// {
// outputFolder: 'test-results', // or whatever directory you want
// open: 'never', // can be 'always' | 'on-failure' | 'never'
// },
// ],
],
projects: [
{
name: "admin",

View File

@@ -28,6 +28,7 @@ import { Spinner } from "@/components/Spinner";
import { deleteApiKey, regenerateApiKey } from "./lib";
import { OnyxApiKeyForm } from "./OnyxApiKeyForm";
import { APIKey } from "./types";
import CreateButton from "@/components/ui/createButton";
const API_KEY_TEXT = `API Keys allow you to access Onyx APIs programmatically. Click the button below to generate a new API Key.`;
@@ -111,14 +112,7 @@ function Main() {
}
const newApiKeyButton = (
<Button
variant="navigate"
size="sm"
className="mt-3"
onClick={() => setShowCreateUpdateForm(true)}
>
Create API Key
</Button>
<CreateButton href="/admin/api-key/new" text="Create API Key" />
);
if (apiKeys.length === 0) {

View File

@@ -40,7 +40,12 @@ import * as Yup from "yup";
import CollapsibleSection from "./CollapsibleSection";
import { SuccessfulPersonaUpdateRedirectType } from "./enums";
import { Persona, PersonaLabel, StarterMessage } from "./interfaces";
import { PersonaUpsertParameters, createPersona, updatePersona } from "./lib";
import {
PersonaUpsertParameters,
createPersona,
updatePersona,
deletePersona,
} from "./lib";
import {
CameraIcon,
GroupsIconSkeleton,
@@ -71,7 +76,6 @@ import { LLMSelector } from "@/components/llm/LLMSelector";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
import { DeletePersonaButton } from "./[id]/DeletePersonaButton";
import Title from "@/components/ui/title";
import { SEARCH_TOOL_ID } from "@/app/chat/tools/constants";
@@ -322,10 +326,39 @@ export function AssistantEditor({
}));
};
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
if (!labels) {
return <></>;
}
const openDeleteModal = () => {
setDeleteModalOpen(true);
};
const closeDeleteModal = () => {
setDeleteModalOpen(false);
};
const handleDeletePersona = async () => {
if (existingPersona) {
const response = await deletePersona(existingPersona.id);
if (response.ok) {
await refreshAssistants();
router.push(
redirectType === SuccessfulPersonaUpdateRedirectType.ADMIN
? `/admin/assistants?u=${Date.now()}`
: `/chat`
);
} else {
setPopup({
type: "error",
message: `Failed to delete persona - ${await response.text()}`,
});
}
}
};
return (
<div className="mx-auto max-w-4xl">
<style>
@@ -364,6 +397,14 @@ export function AssistantEditor({
}}
/>
)}
{deleteModalOpen && existingPersona && (
<DeleteEntityModal
entityType="Persona"
entityName={existingPersona.name}
onClose={closeDeleteModal}
onSubmit={handleDeletePersona}
/>
)}
{popup}
<Formik
enableReinitialize={true}
@@ -1312,14 +1353,6 @@ export function AssistantEditor({
explanationLink="https://docs.onyx.app/guides/assistants"
className="[&_textarea]:placeholder:text-text-muted/50"
/>
<div className="flex justify-end">
{existingPersona && (
<DeletePersonaButton
personaId={existingPersona!.id}
redirectType={SuccessfulPersonaUpdateRedirectType.ADMIN}
/>
)}
</div>
</>
)}
@@ -1338,6 +1371,18 @@ export function AssistantEditor({
Cancel
</Button>
</div>
<div className="flex justify-end">
{existingPersona && (
<Button
variant="destructive"
onClick={openDeleteModal}
type="button"
>
Delete
</Button>
)}
</div>
</Form>
);
}}

View File

@@ -17,6 +17,7 @@ import { FiEdit2 } from "react-icons/fi";
import { TrashIcon } from "@/components/icons/icons";
import { useUser } from "@/components/user/UserProvider";
import { useAssistants } from "@/components/context/AssistantsContext";
import { DeleteEntityModal } from "@/components/modals/DeleteEntityModal";
function PersonaTypeDisplay({ persona }: { persona: Persona }) {
if (persona.builtin_persona) {
@@ -53,6 +54,8 @@ export function PersonasTable() {
}, [editablePersonas]);
const [finalPersonas, setFinalPersonas] = useState<Persona[]>([]);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [personaToDelete, setPersonaToDelete] = useState<Persona | null>(null);
useEffect(() => {
const editable = editablePersonas.sort(personaComparator);
@@ -98,9 +101,42 @@ export function PersonasTable() {
await refreshUser();
};
const openDeleteModal = (persona: Persona) => {
setPersonaToDelete(persona);
setDeleteModalOpen(true);
};
const closeDeleteModal = () => {
setDeleteModalOpen(false);
setPersonaToDelete(null);
};
const handleDeletePersona = async () => {
if (personaToDelete) {
const response = await deletePersona(personaToDelete.id);
if (response.ok) {
await refreshAssistants();
closeDeleteModal();
} else {
setPopup({
type: "error",
message: `Failed to delete persona - ${await response.text()}`,
});
}
}
};
return (
<div>
{popup}
{deleteModalOpen && personaToDelete && (
<DeleteEntityModal
entityType="Persona"
entityName={personaToDelete.name}
onClose={closeDeleteModal}
onSubmit={handleDeletePersona}
/>
)}
<DraggableTable
headers={["Name", "Description", "Type", "Is Visible", "Delete"]}
@@ -170,16 +206,7 @@ export function PersonasTable() {
{!persona.builtin_persona && isEditable ? (
<div
className="hover:bg-hover rounded p-1 cursor-pointer"
onClick={async () => {
const response = await deletePersona(persona.id);
if (response.ok) {
await refreshAssistants();
} else {
alert(
`Failed to delete persona - ${await response.text()}`
);
}
}}
onClick={() => openDeleteModal(persona)}
>
<TrashIcon />
</div>

View File

@@ -1,15 +1,12 @@
"use client";
import { PersonasTable } from "./PersonaTable";
import { FiPlusSquare } from "react-icons/fi";
import Link from "next/link";
import Text from "@/components/ui/text";
import Title from "@/components/ui/title";
import { Separator } from "@/components/ui/separator";
import { AssistantsIcon } from "@/components/icons/icons";
import { AdminPageTitle } from "@/components/admin/Title";
import LabelManagement from "./LabelManagement";
import { SubLabel } from "@/components/admin/connectors/Field";
import CreateButton from "@/components/ui/createButton";
export default async function Page() {
return (
<div className="mx-auto container">
@@ -33,15 +30,7 @@ export default async function Page() {
<Separator />
<Title>Create an Assistant</Title>
<Link
href="/admin/assistants/new"
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 Assistant
</div>
</Link>
<CreateButton href="/admin/assistants/new" text="New Assistant" />
<Separator />

View File

@@ -49,7 +49,7 @@ export function SlackChannelConfigsTable({
}}
>
<FiSettings />
Edit Default Config
Edit Default Configuration
</Button>
<Link href={`/admin/bots/${slackBotId}/channels/new`}>
<Button variant="outline">

View File

@@ -45,13 +45,26 @@ export const SlackChannelConfigCreationForm = ({
const existingSlackBotUsesPersona = existingSlackChannelConfig?.persona
? !isPersonaASlackBotPersona(existingSlackChannelConfig.persona)
: false;
const existingPersonaHasSearchTool = existingSlackChannelConfig?.persona
? existingSlackChannelConfig.persona.tools.some(
(tool) => tool.in_code_tool_id === SEARCH_TOOL_ID
)
: false;
const searchEnabledAssistants = useMemo(() => {
return personas.filter((persona) => {
return persona.tools.some(
(tool) => tool.in_code_tool_id == SEARCH_TOOL_ID
);
});
const [searchEnabledAssistants, nonSearchAssistants] = useMemo(() => {
return personas.reduce(
(acc, persona) => {
if (
persona.tools.some((tool) => tool.in_code_tool_id === SEARCH_TOOL_ID)
) {
acc[0].push(persona);
} else {
acc[1].push(persona);
}
return acc;
},
[[], []] as [Persona[], Persona[]]
);
}, [personas]);
return (
@@ -105,7 +118,9 @@ export const SlackChannelConfigCreationForm = ({
standard_answer_categories:
existingSlackChannelConfig?.standard_answer_categories || [],
knowledge_source: existingSlackBotUsesPersona
? "assistant"
? existingPersonaHasSearchTool
? "assistant"
: "non_search_assistant"
: existingSlackChannelConfig?.persona
? "document_sets"
: "all_public",
@@ -148,7 +163,12 @@ export const SlackChannelConfigCreationForm = ({
}),
standard_answer_categories: Yup.array(),
knowledge_source: Yup.string()
.oneOf(["all_public", "document_sets", "assistant"])
.oneOf([
"all_public",
"document_sets",
"assistant",
"non_search_assistant",
])
.required(),
})}
onSubmit={async (values, formikHelpers) => {
@@ -159,13 +179,16 @@ export const SlackChannelConfigCreationForm = ({
slack_bot_id,
channel_name: values.channel_name,
respond_member_group_list: values.respond_member_group_list,
usePersona: values.knowledge_source === "assistant",
usePersona:
values.knowledge_source === "assistant" ||
values.knowledge_source === "non_search_assistant",
document_sets:
values.knowledge_source === "document_sets"
? values.document_sets
: [],
persona_id:
values.knowledge_source === "assistant"
values.knowledge_source === "assistant" ||
values.knowledge_source === "non_search_assistant"
? values.persona_id
: null,
standard_answer_categories: values.standard_answer_categories.map(
@@ -204,7 +227,7 @@ export const SlackChannelConfigCreationForm = ({
}
}}
>
{({ isSubmitting, values, setFieldValue }) => (
{({ isSubmitting, values, setFieldValue, ...formikProps }) => (
<Form>
<div className="pb-6 w-full">
<SlackChannelConfigFormFields
@@ -213,9 +236,11 @@ export const SlackChannelConfigCreationForm = ({
isDefault={isDefault}
documentSets={documentSets}
searchEnabledAssistants={searchEnabledAssistants}
nonSearchAssistants={nonSearchAssistants}
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
setPopup={setPopup}
slack_bot_id={slack_bot_id}
formikProps={formikProps}
/>
</div>
</Form>

View File

@@ -10,7 +10,6 @@ import {
} from "formik";
import { CCPairDescriptor, DocumentSet } from "@/lib/types";
import {
BooleanFormField,
Label,
SelectorFormField,
SubLabel,
@@ -42,18 +41,29 @@ import { fetchSlackChannels } from "../lib";
import { Badge } from "@/components/ui/badge";
import useSWR from "swr";
import { ThreeDotsLoader } from "@/components/Loading";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Separator } from "@/components/ui/separator";
import { CheckFormField } from "@/components/ui/CheckField";
export interface SlackChannelConfigFormFieldsProps {
isUpdate: boolean;
isDefault: boolean;
documentSets: DocumentSet[];
searchEnabledAssistants: Persona[];
nonSearchAssistants: Persona[];
standardAnswerCategoryResponse: StandardAnswerCategoryResponse;
setPopup: (popup: {
message: string;
type: "error" | "success" | "warning";
}) => void;
slack_bot_id: number;
formikProps: any;
}
export function SlackChannelConfigFormFields({
@@ -61,15 +71,15 @@ export function SlackChannelConfigFormFields({
isDefault,
documentSets,
searchEnabledAssistants,
nonSearchAssistants,
standardAnswerCategoryResponse,
setPopup,
slack_bot_id,
formikProps,
}: SlackChannelConfigFormFieldsProps) {
const router = useRouter();
const { values, setFieldValue } = useFormikContext<any>();
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [viewUnselectableSets, setViewUnselectableSets] = useState(false);
const [currentSearchTerm, setCurrentSearchTerm] = useState("");
const [viewSyncEnabledAssistants, setViewSyncEnabledAssistants] =
useState(false);
@@ -178,6 +188,7 @@ export function SlackChannelConfigFormFields({
}));
}
);
if (isLoading) {
return <ThreeDotsLoader />;
}
@@ -194,7 +205,7 @@ export function SlackChannelConfigFormFields({
<>
<label
htmlFor="channel_name"
className="block font-medium text-base mb-2"
className="block text-text font-medium text-base mb-2"
>
Select A Slack Channel:
</label>{" "}
@@ -204,11 +215,9 @@ export function SlackChannelConfigFormFields({
options={channelOptions || []}
onSelect={(selected) => {
form.setFieldValue("channel_name", selected.name);
setCurrentSearchTerm(selected.name);
}}
initialSearchTerm={field.value}
onSearchTermChange={(term) => {
setCurrentSearchTerm(term);
form.setFieldValue("channel_name", term);
}}
/>
@@ -242,9 +251,15 @@ export function SlackChannelConfigFormFields({
<RadioGroupItemField
value="assistant"
id="assistant"
label="Specific Assistant"
label="Search Assistant"
sublabel="Control both the documents and the prompt to use for answering questions"
/>
<RadioGroupItemField
value="non_search_assistant"
id="non_search_assistant"
label="Non-Search Assistant"
sublabel="Chat with an assistant that does not use documents"
/>
</RadioGroup>
</div>
{values.knowledge_source === "document_sets" &&
@@ -408,118 +423,165 @@ export function SlackChannelConfigFormFields({
)}
</div>
)}
</div>
{values.knowledge_source === "non_search_assistant" && (
<div className="mt-4">
<SubLabel>
<>
Select the non-search assistant OnyxBot will use while answering
questions in Slack.
{syncEnabledAssistants.length > 0 && (
<>
<br />
<span className="text-sm text-text-dark/80">
Note: Some of your assistants have auto-synced connectors
in their document sets. You cannot select these assistants
as they will not be able to answer questions in Slack.{" "}
<button
type="button"
onClick={() =>
setViewSyncEnabledAssistants(
(viewSyncEnabledAssistants) =>
!viewSyncEnabledAssistants
)
}
className="text-sm text-link"
>
{viewSyncEnabledAssistants
? "Hide un-selectable "
: "View all "}
assistants
</button>
</span>
</>
)}
</>
</SubLabel>
<div className="mt-6">
<AdvancedOptionsToggle
showAdvancedOptions={showAdvancedOptions}
setShowAdvancedOptions={setShowAdvancedOptions}
/>
</div>
{showAdvancedOptions && (
<div className="mt-2 space-y-4">
<div className="w-64">
<SelectorFormField
name="response_type"
label="Answer Type"
tooltip="Controls the format of OnyxBot's responses."
options={[
{ name: "Standard", value: "citations" },
{ name: "Detailed", value: "quotes" },
]}
name="persona_id"
options={nonSearchAssistants.map((persona) => ({
name: persona.name,
value: persona.id,
}))}
/>
</div>
)}
</div>
<Separator className="my-4" />
<Accordion type="multiple" className=" gap-y-2 w-full">
{values.knowledge_source !== "non_search_assistant" && (
<AccordionItem value="search-options">
<AccordionTrigger className="text-text">
Search Configuration
</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<div className="w-64">
<SelectorFormField
name="response_type"
label="Answer Type"
tooltip="Controls the format of OnyxBot's responses."
options={[
{ name: "Standard", value: "citations" },
{ name: "Detailed", value: "quotes" },
]}
/>
</div>
<CheckFormField
name="enable_auto_filters"
label="Enable LLM Autofiltering"
tooltip="If set, the LLM will generate source and time filters based on the user's query"
/>
<BooleanFormField
name="show_continue_in_web_ui"
removeIndent
label="Show Continue in Web UI button"
tooltip="If set, will show a button at the bottom of the response that allows the user to continue the conversation in the Onyx Web UI"
/>
<CheckFormField
name="answer_validity_check_enabled"
label="Only respond if citations found"
tooltip="If set, will only answer questions where the model successfully produces citations"
/>
</div>
</AccordionContent>
</AccordionItem>
)}
<AccordionItem className="mt-4" value="general-options">
<AccordionTrigger>General Configuration</AccordionTrigger>
<AccordionContent>
<div className="space-y-4">
<CheckFormField
name="show_continue_in_web_ui"
label="Show Continue in Web UI button"
tooltip="If set, will show a button at the bottom of the response that allows the user to continue the conversation in the Onyx Web UI"
/>
<CheckFormField
name="still_need_help_enabled"
onChange={(checked: boolean) => {
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 && (
<CollapsibleSection prompt="Configure Still Need Help Button">
<TextArrayField
name="follow_up_tags"
label="(Optional) Users / Groups to Tag"
values={values}
subtext={
<div>
The Slack users / groups we should tag if the user
clicks the &quot;Still need help?&quot; button. If no
emails are provided, we will not tag anyone and will
just react with a 🆘 emoji to the original message.
</div>
}
placeholder="User email or user group name..."
/>
</CollapsibleSection>
)}
<CheckFormField
name="questionmark_prefilter_enabled"
label="Only respond to questions"
tooltip="If set, OnyxBot will only respond to messages that contain a question mark"
/>
<CheckFormField
name="respond_tag_only"
label="Respond to @OnyxBot Only"
tooltip="If set, OnyxBot will only respond when directly tagged"
/>
<CheckFormField
name="respond_to_bots"
label="Respond to Bot messages"
tooltip="If not set, OnyxBot will always ignore messages from Bots"
/>
<BooleanFormField
name="still_need_help_enabled"
removeIndent
onChange={(checked: boolean) => {
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 && (
<CollapsibleSection prompt="Configure Still Need Help Button">
<TextArrayField
name="follow_up_tags"
label="(Optional) Users / Groups to Tag"
values={values}
name="respond_member_group_list"
label="(Optional) Respond to Certain Users / Groups"
subtext={
<div>
The Slack users / groups we should tag if the user clicks
the &quot;Still need help?&quot; button. If no emails are
provided, we will not tag anyone and will just react with a
🆘 emoji to the original message.
</div>
"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..."
/>
</CollapsibleSection>
)}
<BooleanFormField
name="answer_validity_check_enabled"
removeIndent
label="Only respond if citations found"
tooltip="If set, will only answer questions where the model successfully produces citations"
/>
<BooleanFormField
name="questionmark_prefilter_enabled"
removeIndent
label="Only respond to questions"
tooltip="If set, OnyxBot will only respond to messages that contain a question mark"
/>
<BooleanFormField
name="respond_tag_only"
removeIndent
label="Respond to @OnyxBot Only"
tooltip="If set, OnyxBot will only respond when directly tagged"
/>
<BooleanFormField
name="respond_to_bots"
removeIndent
label="Respond to Bot messages"
tooltip="If not set, OnyxBot will always ignore messages from Bots"
/>
<BooleanFormField
name="enable_auto_filters"
removeIndent
label="Enable LLM Autofiltering"
tooltip="If set, the LLM will generate source and time filters based on the user's query"
/>
<TextArrayField
name="respond_member_group_list"
label="(Optional) Respond to Certain Users / Groups"
subtext={
"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..."
/>
<StandardAnswerCategoryDropdownField
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
categories={values.standard_answer_categories}
setCategories={(categories: any) =>
setFieldValue("standard_answer_categories", categories)
}
/>
</div>
)}
<StandardAnswerCategoryDropdownField
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
categories={values.standard_answer_categories}
setCategories={(categories: any) =>
setFieldValue("standard_answer_categories", categories)
}
/>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<div className="flex mt-8 gap-x-2 w-full justify-end">
{shouldShowPrivacyAlert && (

View File

@@ -11,6 +11,7 @@ import { SourceIcon } from "@/components/SourceIcon";
import { SlackBotTable } from "./SlackBotTable";
import { useSlackBots } from "./[bot-id]/hooks";
import { ValidSources } from "@/lib/types";
import CreateButton from "@/components/ui/createButton";
const Main = () => {
const {
@@ -71,27 +72,7 @@ const Main = () => {
found in the Onyx documentation to get started!
</p>
<Link
className="
flex
py-2
px-4
mt-2
border
border-border
h-fit
cursor-pointer
hover:bg-hover
text-sm
w-40
"
href="/admin/bots/new"
>
<div className="mx-auto flex">
<FiPlusSquare className="my-auto mr-2" />
New Slack Bot
</div>
</Link>
<CreateButton href="/admin/bots/new" text="New Slack Bot" />
<SlackBotTable slackBots={slackBots} />
</div>

View File

@@ -11,6 +11,7 @@ import {
GeminiIcon,
OpenSourceIcon,
AnthropicSVG,
IconProps,
} from "@/components/icons/icons";
import { FaRobot } from "react-icons/fa";
@@ -74,29 +75,36 @@ export interface LLMProviderDescriptor {
}
export const getProviderIcon = (providerName: string, modelName?: string) => {
const modelNameToIcon = (
modelName: string,
fallbackIcon: ({ size, className }: IconProps) => JSX.Element
): (({ size, className }: IconProps) => JSX.Element) => {
if (modelName?.toLowerCase().includes("amazon")) {
return AmazonIcon;
}
if (modelName?.toLowerCase().includes("phi")) {
return MicrosoftIconSVG;
}
if (modelName?.toLowerCase().includes("mistral")) {
return MistralIcon;
}
if (modelName?.toLowerCase().includes("llama")) {
return MetaIcon;
}
if (modelName?.toLowerCase().includes("gemini")) {
return GeminiIcon;
}
if (modelName?.toLowerCase().includes("claude")) {
return AnthropicIcon;
} else {
return fallbackIcon;
}
};
switch (providerName) {
case "openai":
// Special cases for openai based on modelName
if (modelName?.toLowerCase().includes("amazon")) {
return AmazonIcon;
}
if (modelName?.toLowerCase().includes("phi")) {
return MicrosoftIconSVG;
}
if (modelName?.toLowerCase().includes("mistral")) {
return MistralIcon;
}
if (modelName?.toLowerCase().includes("llama")) {
return MetaIcon;
}
if (modelName?.toLowerCase().includes("gemini")) {
return GeminiIcon;
}
if (modelName?.toLowerCase().includes("claude")) {
return AnthropicIcon;
}
return OpenAIIcon; // Default for openai
return modelNameToIcon(modelName || "", OpenAIIcon);
case "anthropic":
return AnthropicSVG;
case "bedrock":
@@ -104,7 +112,7 @@ export const getProviderIcon = (providerName: string, modelName?: string) => {
case "azure":
return AzureIcon;
default:
return CPUIcon;
return modelNameToIcon(modelName || "", CPUIcon);
}
};

View File

@@ -18,7 +18,11 @@ import AdvancedFormPage from "./pages/Advanced";
import DynamicConnectionForm from "./pages/DynamicConnectorCreationForm";
import CreateCredential from "@/components/credentials/actions/CreateCredential";
import ModifyCredential from "@/components/credentials/actions/ModifyCredential";
import { ConfigurableSources, oauthSupportedSources } from "@/lib/types";
import {
ConfigurableSources,
oauthSupportedSources,
ValidSources,
} from "@/lib/types";
import {
Credential,
credentialTemplates,
@@ -444,7 +448,7 @@ export default function AddConnector({
<CardSection>
<Title className="mb-2 text-lg">Select a credential</Title>
{connector == "gmail" ? (
{connector == ValidSources.Gmail ? (
<GmailMain />
) : (
<>

View File

@@ -40,6 +40,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import CreateButton from "@/components/ui/createButton";
const numToDisplay = 50;
@@ -305,9 +306,13 @@ const Main = () => {
<div className="mb-3"></div>
<div className="flex mb-6">
<Link href="/admin/documents/sets/new">
<CreateButton
href="/admin/documents/sets/new"
text="New Document Set"
/>
{/* <Link href="/admin/documents/sets/new">
<Button variant="navigate">New Document Set</Button>
</Link>
</Link> */}
</div>
{documentSets.length > 0 && (

View File

@@ -231,7 +231,7 @@ export function SettingsForm() {
<Checkbox
label="Pro Search Disabled"
sublabel="If set, users will not be able to use Pro Search."
checked={settings.pro_search_disabled}
checked={settings.pro_search_disabled ?? false}
onChange={(e) =>
handleToggleSettingsField("pro_search_disabled", e.target.checked)
}

View File

@@ -10,7 +10,7 @@ export interface Settings {
notifications: Notification[];
needs_reindexing: boolean;
gpu_enabled: boolean;
pro_search_disabled: boolean;
pro_search_disabled: boolean | null;
product_gating: GatingType;
auto_scroll: boolean;
}

View File

@@ -18,6 +18,7 @@ import { usePopup } from "@/components/admin/connectors/Popup";
import { CreateRateLimitModal } from "./CreateRateLimitModal";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { ShieldIcon } from "@/components/icons/icons";
import CreateButton from "@/components/ui/createButton";
const BASE_URL = "/api/admin/token-rate-limits";
const GLOBAL_TOKEN_FETCH_URL = `${BASE_URL}/global`;
@@ -138,15 +139,10 @@ function Main() {
</li>
</ul>
<Button
variant="navigate"
size="sm"
className="my-4"
onClick={() => setModalIsOpen(true)}
>
Create a Token Rate Limit
</Button>
<CreateButton
href="/admin/token-rate-limits/new"
text="Create a Token Rate Limit"
/>
{isPaidEnterpriseFeaturesEnabled && (
<Tabs
value={tabIndex.toString()}

View File

@@ -9,6 +9,7 @@ import { fetchSS } from "@/lib/utilsSS";
import { ErrorCallout } from "@/components/ErrorCallout";
import { AdminPageTitle } from "@/components/admin/Title";
import { ToolIcon } from "@/components/icons/icons";
import CreateButton from "@/components/ui/createButton";
export default async function Page() {
const toolResponse = await fetchSS("/tool");
@@ -39,27 +40,7 @@ export default async function Page() {
<Separator />
<Title>Create a Tool</Title>
<Link
href="/admin/tools/new"
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 Tool
</div>
</Link>
<CreateButton href="/admin/tools/new" text="New Tool" />
<Separator />

View File

@@ -471,9 +471,6 @@ export function ChatPage({
}
return;
}
const shouldScrollToBottom =
visibleRange.get(existingChatSessionId) === undefined ||
visibleRange.get(existingChatSessionId)?.end == 0;
clearSelectedDocuments();
setIsFetchingChatMessages(true);
@@ -511,16 +508,13 @@ export function ChatPage({
// go to bottom. If initial load, then do a scroll,
// otherwise just appear at the bottom
if (shouldScrollToBottom) {
scrollInitialized.current = false;
}
if (shouldScrollToBottom) {
if (!hasPerformedInitialScroll && autoScrollEnabled) {
clientScrollToBottom();
} else if (isChatSessionSwitch && autoScrollEnabled) {
clientScrollToBottom(true);
}
scrollInitialized.current = false;
if (!hasPerformedInitialScroll) {
clientScrollToBottom();
} else if (isChatSessionSwitch) {
clientScrollToBottom(true);
}
setIsFetchingChatMessages(false);
@@ -1034,6 +1028,7 @@ export function ChatPage({
) {
setDocumentSidebarToggled(false);
}
clientScrollToBottom();
}, [chatSessionIdRef.current]);
const loadNewPageLogic = (event: MessageEvent) => {
@@ -1068,7 +1063,6 @@ export function ChatPage({
if (!documentSidebarInitialWidth && maxDocumentSidebarWidth) {
documentSidebarInitialWidth = Math.min(700, maxDocumentSidebarWidth);
}
class CurrentMessageFIFO {
private stack: PacketType[] = [];
isComplete: boolean = false;
@@ -1332,7 +1326,9 @@ export function ChatPage({
searchParams.get(SEARCH_PARAM_NAMES.SYSTEM_PROMPT) || undefined,
useExistingUserMessage: isSeededChat,
useLanggraph:
!settings?.settings.pro_search_disabled && proSearchEnabled,
!settings?.settings.pro_search_disabled &&
proSearchEnabled &&
retrievalEnabled,
});
const delay = (ms: number) => {
@@ -1440,21 +1436,22 @@ export function ChatPage({
}
}
// Continuously refine the sub_questions based on the packets that we receive
// // Continuously refine the sub_questions based on the packets that we receive
if (
Object.hasOwn(packet, "stop_reason") &&
Object.hasOwn(packet, "level_question_num")
) {
// sub_questions = constructSubQuestions(
// sub_questions,
// packet as StreamStopInfo
// );
sub_questions = constructSubQuestions(
sub_questions,
packet as StreamStopInfo
);
} else if (Object.hasOwn(packet, "sub_question")) {
is_generating = true;
sub_questions = constructSubQuestions(
sub_questions,
packet as SubQuestionPiece
);
setAgenticGenerating(true);
} else if (Object.hasOwn(packet, "sub_query")) {
sub_questions = constructSubQuestions(
sub_questions,
@@ -1663,6 +1660,7 @@ export function ChatPage({
completeMessageMapOverride: currentMessageMap(completeMessageDetail),
});
}
setAgenticGenerating(false);
resetRegenerationState(currentSessionId());
updateChatState("input");
@@ -1790,6 +1788,7 @@ export function ChatPage({
// Used to maintain a "time out" for history sidebar so our existing refs can have time to process change
const [untoggled, setUntoggled] = useState(false);
const [loadingError, setLoadingError] = useState<string | null>(null);
const [agenticGenerating, setAgenticGenerating] = useState(false);
const explicitlyUntoggle = () => {
setShowHistorySidebar(false);
@@ -1834,17 +1833,17 @@ export function ChatPage({
const autoScrollEnabled =
user?.preferences?.auto_scroll == null
? settings?.enterpriseSettings?.auto_scroll || false
: user?.preferences?.auto_scroll!;
: user?.preferences?.auto_scroll! && !agenticGenerating;
// useScrollonStream({
// chatState: currentSessionChatState,
// scrollableDivRef,
// scrollDist,
// endDivRef,
// debounceNumber,
// mobile: settings?.isMobile,
// enableAutoScroll: autoScrollEnabled,
// });
useScrollonStream({
chatState: currentSessionChatState,
scrollableDivRef,
scrollDist,
endDivRef,
debounceNumber,
mobile: settings?.isMobile,
enableAutoScroll: autoScrollEnabled,
});
// Virtualization + Scrolling related effects and functions
const scrollInitialized = useRef(false);
@@ -3058,20 +3057,19 @@ export function ChatPage({
</div>
<div
ref={inputRef}
className="absolute bottom-0 z-10 w-full"
className="absolute pointer-events-none bottom-0 z-10 w-full"
>
<div className="w-[95%] mx-auto relative mb-8">
{aboveHorizon && (
<div className="pointer-events-none w-full bg-transparent flex sticky justify-center">
<button
onClick={() => clientScrollToBottom()}
className="p-1 pointer-events-auto rounded-2xl bg-background-strong border border-border mb-2 mx-auto "
>
<FiArrowDown size={18} />
</button>
</div>
)}
{aboveHorizon && (
<div className="mx-auto w-fit !pointer-events-none flex sticky justify-center">
<button
onClick={() => clientScrollToBottom()}
className="p-1 pointer-events-auto rounded-2xl bg-background-strong border border-border mx-auto "
>
<FiArrowDown size={18} />
</button>
</div>
)}
<div className="pointer-events-auto w-[95%] mx-auto relative mb-8">
<ChatInputBar
proSearchEnabled={proSearchEnabled}
setProSearchEnabled={() => toggleProSearch()}

View File

@@ -1,5 +1,6 @@
import React, { useContext, useEffect, useRef, useState } from "react";
import { FiPlusCircle, FiPlus, FiInfo, FiX, FiFilter } from "react-icons/fi";
import { FiLoader } from "react-icons/fi";
import { ChatInputOption } from "./ChatInputOption";
import { Persona } from "@/app/admin/assistants/interfaces";
import LLMPopover from "./LLMPopover";
@@ -36,6 +37,9 @@ import { buildImgUrl } from "../files/images/utils";
import { useUser } from "@/components/user/UserProvider";
import { AgenticToggle } from "./AgenticToggle";
import { SettingsContext } from "@/components/settings/SettingsProvider";
import { LoadingIndicator } from "react-select/dist/declarations/src/components/indicators";
import { FidgetSpinner } from "react-loader-spinner";
import { LoadingAnimation } from "@/components/Loading";
const MAX_INPUT_HEIGHT = 200;
export const SourceChip2 = ({
@@ -709,12 +713,16 @@ export function ChatInputBar({
<SourceChip
key={`file-${index}`}
icon={
<img
className="h-full py-.5 object-cover rounded-lg bg-background cursor-pointer"
src={buildImgUrl(file.id)}
/>
file.isUploading ? (
<FiLoader className="animate-spin" />
) : (
<img
className="h-full py-.5 object-cover rounded-lg bg-background cursor-pointer"
src={buildImgUrl(file.id)}
/>
)
}
title={file.name || "File"}
title={file.name || "File" + file.id}
onRemove={() => {
setFiles(
files.filter(

View File

@@ -5,7 +5,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ChatInputOption } from "./ChatInputOption";
import { getDisplayNameForModel } from "@/lib/hooks";
import { defaultModelsByProvider, getDisplayNameForModel } from "@/lib/hooks";
import {
checkLLMSupportsImageInput,
destructureValue,
@@ -61,22 +61,23 @@ export default function LLMPopover({
llmOptionsByProvider[llmProvider.provider] = [];
}
(llmProvider.display_model_names || llmProvider.model_names).forEach(
(modelName) => {
if (!uniqueModelNames.has(modelName)) {
uniqueModelNames.add(modelName);
llmOptionsByProvider[llmProvider.provider].push({
name: modelName,
value: structureValue(
llmProvider.name,
llmProvider.provider,
modelName
),
icon: getProviderIcon(llmProvider.provider, modelName),
});
}
(
llmProvider.display_model_names ||
defaultModelsByProvider[llmProvider.provider]
).forEach((modelName) => {
if (!uniqueModelNames.has(modelName)) {
uniqueModelNames.add(modelName);
llmOptionsByProvider[llmProvider.provider].push({
name: modelName,
value: structureValue(
llmProvider.name,
llmProvider.provider,
modelName
),
icon: getProviderIcon(llmProvider.provider, modelName),
});
}
);
});
});
const llmOptions = Object.entries(llmOptionsByProvider).flatMap(

View File

@@ -218,6 +218,7 @@ export interface SubQuestionDetail extends BaseQuestionIdentifier {
sub_queries?: SubQueryDetail[] | null;
context_docs?: { top_documents: OnyxDocument[] } | null;
is_complete?: boolean;
is_stopped?: boolean;
}
export interface SubQueryDetail {
@@ -249,14 +250,13 @@ export const constructSubQuestions = (
// );
if ("stop_reason" in newDetail) {
console.log("STOP REASON");
console.log(newDetail);
const { level, level_question_num } = newDetail;
let subQuestion = updatedSubQuestions.find(
(sq) => sq.level === level && sq.level_question_num === level_question_num
);
if (subQuestion) {
// subQuestion.is_complete = true;
subQuestion.is_complete = true;
subQuestion.is_stopped = true;
}
} else if ("top_documents" in newDetail) {
const { level, level_question_num, top_documents } = newDetail;

View File

@@ -322,10 +322,6 @@ export const AIMessage = ({
? otherMessagesCanSwitchTo?.indexOf(messageId)
: undefined;
const uniqueSources: ValidSources[] = Array.from(
new Set((docs || []).map((doc) => doc.source_type))
).slice(0, 3);
const webSourceDomains: string[] = Array.from(
new Set(
docs
@@ -506,7 +502,7 @@ export const AIMessage = ({
<SeeMoreBlock
toggled={toggledDocumentSidebar!}
toggleDocumentSelection={toggleDocumentSelection!}
uniqueSources={uniqueSources}
docs={docs}
webSourceDomains={webSourceDomains}
/>
</div>

View File

@@ -53,7 +53,7 @@ const SourceCard: React.FC<{
</div>
<div className="flex items-center gap-1 mt-1">
<ResultIcon doc={document} size={14} />
<ResultIcon doc={document} size={18} />
<div className="text-[#4a4a4a] text-xs leading-tight truncate flex-1 min-w-0">
{truncatedIdentifier}
</div>
@@ -105,13 +105,10 @@ export const SourcesDisplay: React.FC<SourcesDisplayProps> = ({
{hasMoreDocuments && (
<SeeMoreBlock
fullWidth
toggled={docSidebarToggled}
toggleDocumentSelection={toggleDocumentSelection}
uniqueSources={
Array.from(
new Set(documents.map((doc) => doc.source_type))
) as ValidSources[]
}
docs={documents}
webSourceDomains={documents.map((doc) => doc.link)}
/>
)}

View File

@@ -55,7 +55,8 @@ const DOC_DELAY_MS = 100;
export const useStreamingMessages = (
subQuestions: SubQuestionDetail[],
allowStreaming: () => void
allowStreaming: () => void,
onComplete: () => void
) => {
const [dynamicSubQuestions, setDynamicSubQuestions] = useState<
SubQuestionDetail[]
@@ -117,24 +118,39 @@ export const useStreamingMessages = (
return;
}
// 1) Stream high-level questions in parallel
// Stream high-level questions sequentially
let didStreamQuestion = false;
let allQuestionsComplete = true;
for (let i = 0; i < actualSubQs.length; i++) {
const sq = actualSubQs[i];
const p = progressRef.current[i];
const dynSQ = dynamicSubQuestionsRef.current[i];
if (sq.question) {
const nextIndex = p.questionCharIndex + 1;
if (nextIndex <= sq.question.length) {
dynSQ.question = sq.question.slice(0, nextIndex);
p.questionCharIndex = nextIndex;
if (nextIndex >= sq.question.length) {
p.questionDone = true;
// Always stream the first subquestion (index 0)
// For others, only stream if the previous question is complete
if (i === 0 || (i > 0 && progressRef.current[i - 1].questionDone)) {
if (sq.question) {
const nextIndex = p.questionCharIndex + 1;
if (nextIndex <= sq.question.length) {
dynSQ.question = sq.question.slice(0, nextIndex);
p.questionCharIndex = nextIndex;
if (nextIndex >= sq.question.length && sq.is_stopped) {
p.questionDone = true;
}
didStreamQuestion = true;
// Break after streaming one question to ensure sequential behavior
break;
}
didStreamQuestion = true;
}
}
if (!p.questionDone) {
allQuestionsComplete = false;
}
}
if (allQuestionsComplete && !didStreamQuestion) {
onComplete();
}
if (didStreamQuestion) {

View File

@@ -317,7 +317,7 @@ const SubQuestionDisplay: React.FC<{
<div
className={`absolute left-[5px] ${
isFirst ? "top-[15px]" : "top-0"
} bottom-0 w-[2px] bg-neutral-200
} bottom-0 w-[2px] bg-neutral-200
${isLast && !toggled ? "h-4" : "h-full"}`}
/>
@@ -331,7 +331,7 @@ const SubQuestionDisplay: React.FC<{
</div>
<div className="ml-8 w-full">
<div
className="flex -mx-2 rounded-md px-2 hover:bg-[#F5F3ED] items-start py-1.5 my-.5 cursor-pointer"
className="flex -mx-2 rounded-md px-2 hover:bg-[#F5F3ED] items-start py-1.5 my-.5 cursor-pointer"
onClick={() => setToggled(!toggled)}
>
<div className="text-black text-base font-medium leading-normal flex-grow pr-2">
@@ -344,102 +344,108 @@ const SubQuestionDisplay: React.FC<{
size={20}
/>
</div>
<div
className={`transition-all duration-300 ease-in-out ${
toggled ? "max-h-[1000px]" : "max-h-0"
}`}
>
{isVisible && subQuestion && (
<div
className={`transform transition-all duration-300 ease-in-out origin-top ${
toggled ? "scale-y-100 opacity-100" : "scale-y-95 opacity-0"
}`}
>
<div className="pl-0 pb-2">
<div className="mb-4 flex flex-col gap-2">
<div className="text-[#4a4a4a] text-xs font-medium leading-normal">
Searching
</div>
<div className="flex flex-wrap gap-2">
{subQuestion?.sub_queries?.map((query, queryIndex) => (
<SourceChip2
key={queryIndex}
icon={<FiSearch size={10} />}
title={query.query}
includeTooltip
/>
))}
</div>
</div>
{(subQuestion?.is_complete || memoizedDocs?.length > 0) && (
{!temporaryDisplay && (
<div
className={`transition-all duration-300 ease-in-out ${
toggled ? "max-h-[1000px]" : "max-h-0"
}`}
>
{isVisible && subQuestion && (
<div
className={`transform transition-all duration-300 ease-in-out origin-top ${
toggled ? "scale-y-100 opacity-100" : "scale-y-95 opacity-0"
}`}
>
<div className="pl-0 pb-2">
<div className="mb-4 flex flex-col gap-2">
<div className="text-[#4a4a4a] text-xs font-medium leading-normal">
Reading
Searching
</div>
<div className="flex flex-wrap gap-2">
{memoizedDocs.length > 0 ? (
memoizedDocs.slice(0, 10).map((doc, docIndex) => {
const truncatedIdentifier =
doc.semantic_identifier?.slice(0, 20) || "";
return (
<SourceChip2
includeAnimation
onClick={() =>
openDocument(doc, setPresentingDocument)
}
key={docIndex}
icon={<ResultIcon doc={doc} size={10} />}
title={`${truncatedIdentifier}${
truncatedIdentifier.length === 20 ? "..." : ""
}`}
/>
);
})
) : (
<div className="text-black text-sm font-medium">
No sources found
{subQuestion?.sub_queries?.map((query, queryIndex) => (
<SourceChip2
key={queryIndex}
icon={<FiSearch size={10} />}
title={query.query}
includeTooltip
/>
))}
</div>
</div>
{(subQuestion?.is_complete || memoizedDocs?.length > 0) && (
<div className="mb-4 flex flex-col gap-2">
<div className="text-[#4a4a4a] text-xs font-medium leading-normal">
Reading
</div>
<div className="flex flex-wrap gap-2">
{memoizedDocs.length > 0 ? (
memoizedDocs.slice(0, 10).map((doc, docIndex) => {
const truncatedIdentifier =
doc.semantic_identifier?.slice(0, 20) || "";
return (
<SourceChip2
includeAnimation
onClick={() =>
openDocument(doc, setPresentingDocument)
}
key={docIndex}
icon={<ResultIcon doc={doc} size={10} />}
title={`${truncatedIdentifier}${
truncatedIdentifier.length === 20
? "..."
: ""
}`}
/>
);
})
) : (
<div className="text-black text-sm font-medium">
No sources found
</div>
)}
</div>
</div>
)}
{(subQuestion?.is_complete ||
subQuestion?.answer?.length > 0) && (
<div className="flex flex-col gap-2">
<div
className="text-[#4a4a4a] cursor-pointer items-center text-xs flex gap-x-1 font-medium leading-normal"
onClick={() => setAnalysisToggled(!analysisToggled)}
>
Analyzing
<ChevronDown
className={`transition-transform duration-200 ${
analysisToggled ? "" : "-rotate-90"
}`}
size={8}
/>
</div>
{analysisToggled && (
<div className="flex flex-wrap gap-2">
{renderedMarkdown}
</div>
)}
</div>
</div>
)}
{(subQuestion?.is_complete ||
subQuestion?.answer?.length > 0) && (
<div className="flex flex-col gap-2">
<div
className="text-[#4a4a4a] cursor-pointer items-center text-xs flex gap-x-1 font-medium leading-normal"
onClick={() => setAnalysisToggled(!analysisToggled)}
>
Analyzing
<ChevronDown
className={`transition-transform duration-200 ${
analysisToggled ? "" : "-rotate-90"
}`}
size={8}
/>
</div>
{analysisToggled && (
<div className="flex flex-wrap gap-2">
{renderedMarkdown}
</div>
)}
</div>
)}
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
{temporaryDisplay &&
(status === ToggleState.InProgress || toggled) && (
((status === ToggleState.InProgress &&
forcedStatus !== ToggleState.Done) ||
toggled) && (
<div
className={`transform transition-all duration-100 ease-in-out origin-top ${
toggled ? "scale-y-100 opacity-100" : "scale-y-95 opacity-0"
className={`transform ease-in-out origin-top ${
toggled ? "scale-y-100 opacity-100" : "scale-y-100 opacity-0"
}`}
>
<div className="bg-blaack pl-0">
<div className="pl-0">
<div className="flex flex-col gap-2">
<div className="leading-none text-[#4a4a4a] text-xs font-medium">
{temporaryDisplay?.tinyQuestion}
@@ -468,9 +474,22 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
overallAnswerGenerating,
allowDocuments,
}) => {
const { dynamicSubQuestions } = useStreamingMessages(subQuestions, () => {});
const [showSummarizing, setShowSummarizing] = useState(
finishedGenerating && !overallAnswerGenerating
);
const { dynamicSubQuestions } = useStreamingMessages(
subQuestions,
() => {},
() => {
setShowSummarizing(true);
}
);
const { dynamicSubQuestions: dynamicSecondLevelQuestions } =
useStreamingMessages(secondLevelQuestions || [], () => {});
useStreamingMessages(
secondLevelQuestions || [],
() => {},
() => {}
);
const memoizedSubQuestions = useMemo(() => {
return finishedGenerating ? subQuestions : dynamicSubQuestions;
}, [finishedGenerating, dynamicSubQuestions, subQuestions]);
@@ -497,10 +516,7 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
).length == memoizedSubQuestions.length;
const [streamedText, setStreamedText] = useState(
!overallAnswerGenerating ? "Summarize findings" : ""
);
const [showSummarizing, setShowSummarizing] = useState(
finishedGenerating && !overallAnswerGenerating
finishedGenerating ? "Summarize findings" : ""
);
const [canShowSummarizing, setCanShowSummarizing] =
useState(finishedGenerating);
@@ -520,7 +536,7 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
memoizedSubQuestions.length > 0 &&
memoizedSubQuestions.filter(
(subQuestion) => subQuestion?.answer.length > 2
).length == memoizedSubQuestions.length
).length == subQuestions.length
) {
setTimeout(() => {
setCanShowSummarizing(true);
@@ -531,20 +547,6 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
}
}, [memoizedSubQuestions]);
useEffect(() => {
const allSubQuestionsAnswered =
memoizedSubQuestions.length > 0 &&
memoizedSubQuestions.every(
(subQuestion) => subQuestion?.question.length > 5
);
if (allSubQuestionsAnswered) {
setTimeout(() => {
setShowSummarizing(true);
}, PHASE_MIN_MS * 0.75);
}
}, [memoizedSubQuestions, finishedGenerating]);
useEffect(() => {
if (showSummarizing && streamedText !== "Summarize findings") {
const fullText = "Summarize findings";
@@ -560,7 +562,7 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
} else {
clearInterval(streamInterval);
}
}, 8);
}, 10);
}
}, [showSummarizing]);
@@ -704,12 +706,6 @@ const SubQuestionsDisplay: React.FC<SubQuestionsDisplayProps> = ({
(subQuestion?.sub_queries?.length > 0 &&
(subQuestion.answer == undefined ||
subQuestion.answer.length > 3))
// subQuestion == undefined &&
// subQuestion.answer != undefined &&
// !(
// dynamicSubQuestions[index + 1] != undefined ||
// dynamicSubQuestions[index + 1]?.sub_queries?.length! > 0
// )
}
/>
))}

View File

@@ -11,6 +11,7 @@ import { AdminPageTitle } from "@/components/admin/Title";
import { Button } from "@/components/ui/button";
import { useUser } from "@/components/user/UserProvider";
import CreateButton from "@/components/ui/createButton";
const Main = () => {
const { popup, setPopup } = usePopup();
@@ -52,18 +53,10 @@ const Main = () => {
<>
{popup}
{isAdmin && (
<div className="my-3">
<Button
size="sm"
variant="navigate"
onClick={() => setShowForm(true)}
>
Create New User Group
</Button>
</div>
<CreateButton href="/admin/groups/new" text="Create New User Group" />
)}
{data.length > 0 && (
<div>
<div className="mt-2">
<UserGroupsTable
userGroups={data}
setPopup={setPopup}

View File

@@ -29,6 +29,7 @@ import { PageSelector } from "@/components/PageSelector";
import { CustomCheckbox } from "@/components/CustomCheckbox";
import Text from "@/components/ui/text";
import { TableHeader } from "@/components/ui/table";
import CreateButton from "@/components/ui/createButton";
const NUM_RESULTS_PER_PAGE = 10;
@@ -402,11 +403,10 @@ const Main = () => {
)}
<div className="mb-2"></div>
<Link className="flex mb-3 mt-2 w-fit" href="/admin/standard-answer/new">
<Button className="my-auto" variant="submit" size="sm">
New Standard Answer
</Button>
</Link>
<CreateButton
href="/admin/standard-answer/new"
text="New Standard Answer"
/>
<Separator />

View File

@@ -85,7 +85,7 @@ export function Modal({
ease-in-out
relative
${width ?? "w-11/12 max-w-4xl"}
${noPadding ? "" : removeBottomPadding ? "pt-10 px-10" : "p-10"}
${noPadding ? "" : removeBottomPadding ? "pt-8 px-8" : "p-8"}
${className || ""}
flex
flex-col

View File

@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import faviconFetch from "favicon-fetch";
import { SourceIcon } from "./SourceIcon";
import { ValidSources } from "@/lib/types";
import { OnyxIcon } from "./icons/icons";
const CACHE_DURATION = 24 * 60 * 60 * 1000;
@@ -48,6 +49,9 @@ export function SearchResultIcon({ url }: { url: string }) {
if (!faviconUrl) {
return <SourceIcon sourceType={ValidSources.Web} iconSize={18} />;
}
if (url.includes("docs.onyx.app")) {
return <OnyxIcon size={18} />;
}
return (
<div className="rounded-full w-[18px] h-[18px] overflow-hidden bg-gray-200">

View File

@@ -169,7 +169,7 @@ export function UserDropdown({
<div
className={`
p-2
w-[175px]
${page != "admin" && showNotifications ? "w-72" : "w-[175px]"}
text-strong
text-sm
border

View File

@@ -51,7 +51,7 @@ export function Label({
}) {
return (
<div
className={`block font-medium base ${className} ${
className={`block text-text-darker font-medium base ${className} ${
small ? "text-xs" : "text-sm"
}`}
>

View File

@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Check, CheckCircle, XCircle } from "lucide-react";
import { Warning } from "@phosphor-icons/react";
import { NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK } from "@/lib/constants";
const popupVariants = cva(
"fixed bottom-4 left-4 p-4 rounded-lg shadow-xl text-white z-[10000] flex items-center space-x-3 transition-all duration-300 ease-in-out",
{
@@ -59,7 +60,23 @@ export const Popup: React.FC<PopupSpec> = ({ message, type }) => (
/>
</svg>
)}
<span className="font-medium">{message}</span>
<div className="flex flex-col justify-center items-start">
<p className="font-medium">{message}</p>
{type === "error" && NEXT_PUBLIC_INCLUDE_ERROR_POPUP_SUPPORT_LINK && (
<p className="text-xs">
Need help?{" "}
<a
href="https://join.slack.com/t/onyx-dot-app/shared_invite/zt-2twesxdr6-5iQitKZQpgq~hYIZ~dv3KA"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-red-100"
>
Join our community
</a>{" "}
for support!
</p>
)}
</div>
</div>
);

View File

@@ -12,6 +12,7 @@ import { useAssistants } from "../context/AssistantsContext";
import { useUser } from "../user/UserProvider";
import { XIcon } from "../icons/icons";
import { Spinner } from "@phosphor-icons/react";
import { useRouter } from "next/navigation";
export const Notifications = ({
notifications,
@@ -23,7 +24,7 @@ export const Notifications = ({
navigateToDropdown: () => void;
}) => {
const [showDropdown, setShowDropdown] = useState(false);
const router = useRouter();
const { refreshAssistants } = useAssistants();
const { refreshUser } = useUser();
@@ -90,10 +91,10 @@ export const Notifications = ({
notification: Notification,
persona: Persona
) => {
addAssistantToList(persona.id);
await dismissNotification(notification.id);
await refreshUser();
await refreshAssistants();
router.push(`/chat?assistantId=${persona.id}`);
};
const sortedNotifications = notifications
@@ -204,7 +205,7 @@ export const Notifications = ({
}
className="px-3 py-1 text-sm font-medium text-blue-600 hover:text-blue-800 transition duration-150 ease-in-out"
>
Accept
Chat
</button>
<button
onClick={() => dismissNotification(notification.id)}

View File

@@ -4,6 +4,8 @@ import { OnyxDocument } from "@/lib/search/interfaces";
import { truncateString } from "@/lib/utils";
import { openDocument } from "@/lib/search/utils";
import { ValidSources } from "@/lib/types";
import React from "react";
import { SearchResultIcon } from "@/components/SearchResultIcon";
export const ResultIcon = ({
doc,
@@ -55,70 +57,107 @@ export default function SourceCard({
interface SeeMoreBlockProps {
toggleDocumentSelection: () => void;
uniqueSources: ValidSources[];
docs: OnyxDocument[];
webSourceDomains: string[];
toggled: boolean;
fullWidth?: boolean;
}
const getDomainFromUrl = (url: string) => {
try {
const parsedUrl = new URL(url);
return parsedUrl.hostname;
} catch (error) {
return null;
}
};
export function getUniqueIcons(docs: OnyxDocument[]): JSX.Element[] {
const uniqueIcons: JSX.Element[] = [];
const seenDomains = new Set<string>();
const seenSourceTypes = new Set<ValidSources>();
for (const doc of docs) {
// If it's a web source, we check domain uniqueness
if (doc.source_type === ValidSources.Web && doc.link) {
const domain = getDomainFromUrl(doc.link);
if (domain && !seenDomains.has(domain)) {
seenDomains.add(domain);
// Use your SearchResultIcon with the doc.url
uniqueIcons.push(
<SearchResultIcon url={doc.link} key={`web-${doc.document_id}`} />
);
}
} else {
// Otherwise, use sourceType uniqueness
if (!seenSourceTypes.has(doc.source_type)) {
seenSourceTypes.add(doc.source_type);
// Use your SourceIcon with the doc.sourceType
uniqueIcons.push(
<SourceIcon
sourceType={doc.source_type}
iconSize={18}
key={doc.document_id}
/>
);
}
}
}
// If we have zero icons, we might want a fallback (optional):
if (uniqueIcons.length === 0) {
// Fallback: just use a single SourceIcon, repeated 3 times
return [
<SourceIcon
sourceType={ValidSources.Web}
iconSize={18}
key="fallback-1"
/>,
<SourceIcon
sourceType={ValidSources.Web}
iconSize={18}
key="fallback-2"
/>,
<SourceIcon
sourceType={ValidSources.Web}
iconSize={18}
key="fallback-3"
/>,
];
}
// Duplicate last icon if fewer than 3 icons
while (uniqueIcons.length < 3) {
// The last icon in the array
const lastIcon = uniqueIcons[uniqueIcons.length - 1];
// Clone it with a new key
uniqueIcons.push(
React.cloneElement(lastIcon, {
key: `${lastIcon.key}-dup-${uniqueIcons.length}`,
})
);
}
// Slice to just the first 3 if there are more than 3
return uniqueIcons.slice(0, 3);
}
export function SeeMoreBlock({
toggleDocumentSelection,
webSourceDomains,
uniqueSources,
docs,
toggled,
fullWidth = false,
}: SeeMoreBlockProps) {
// Gather total sources (unique + web).
const totalSources = uniqueSources.length + webSourceDomains.length;
// Filter out "web" from unique sources if we have any webSourceDomains
// (preserves the original logic).
const filteredUniqueSources = uniqueSources.filter(
(source) => source !== "web" && webSourceDomains.length > 0
);
// Build a list of up to three icons from the filtered unique sources and web sources.
// If we don't reach three icons but have at least one, we'll duplicate the last one.
const iconsToRender: Array<{ type: "source" | "web"; data: string }> = [];
// Push from filtered unique sources (max 3).
for (
let i = 0;
i < filteredUniqueSources.length && iconsToRender.length < 3;
i++
) {
iconsToRender.push({ type: "source", data: filteredUniqueSources[i] });
}
// Then push from web source domains (until total of 3).
for (
let i = 0;
i < webSourceDomains.length && iconsToRender.length < 3;
i++
) {
iconsToRender.push({ type: "web", data: webSourceDomains[i] });
}
// If we have fewer than 3 but at least one icon, duplicate the last until we reach 3.
while (iconsToRender.length < 3 && iconsToRender.length > 0) {
iconsToRender.push(iconsToRender[iconsToRender.length - 1]);
}
const iconsToRender = getUniqueIcons(docs);
return (
<button
onClick={toggleDocumentSelection}
className="w-full max-w-[260px] h-[80px] p-3 bg-[#f1eee8] text-left hover:bg-[#ebe7de] cursor-pointer rounded-lg flex flex-col justify-between overflow-hidden"
className={`w-full ${fullWidth ? "w-full" : "max-w-[200px]"}
h-[80px] p-3 border border-[1.5px] border-[#D9D1c0] bg-[#f1eee8] text-left hover:bg-[#ebe7de] cursor-pointer rounded-lg flex flex-col justify-between overflow-hidden`}
>
<div className="flex items-center gap-1">
{iconsToRender.map((icon, index) =>
icon.type === "source" ? (
<SourceIcon
key={index}
sourceType={icon.data as ValidSources}
iconSize={14}
/>
) : (
<WebResultIcon key={index} url={icon.data} size={14} />
)
)}
{iconsToRender.map((icon, index) => icon)}
</div>
<div className="text-text-darker text-xs font-semibold">
{toggled ? "Hide Results" : "Show All"}

View File

@@ -1,6 +1,7 @@
import { FiTrash, FiX } from "react-icons/fi";
import { BasicClickable } from "@/components/BasicClickable";
import { Modal } from "../Modal";
import { Button } from "../ui/button";
export const DeleteEntityModal = ({
onClose,
@@ -20,7 +21,7 @@ export const DeleteEntityModal = ({
includeCancelButton?: boolean;
}) => {
return (
<Modal width="max-w-4xl" onOutsideClick={onClose}>
<Modal width="rounded max-w-sm w-full" onOutsideClick={onClose}>
<>
<div className="flex mb-4">
<h2 className="my-auto text-2xl font-bold">
@@ -28,26 +29,20 @@ export const DeleteEntityModal = ({
</h2>
</div>
<p className="mb-4">
Click below to confirm that you want to {deleteButtonText || "delete"}{" "}
<b>{entityName}</b>
Are you sure you want to {deleteButtonText || "delete"}{" "}
<b>{entityName}</b>?
</p>
{additionalDetails && <p className="mb-4">{additionalDetails}</p>}
<div className="flex">
<div className="mx-auto flex gap-x-2">
<div className="flex items-end justify-end">
<div className="flex gap-x-2">
{includeCancelButton && (
<BasicClickable onClick={onClose}>
<div className="flex mx-2">
<FiX className="my-auto mr-2" />
Cancel
</div>
</BasicClickable>
<Button variant="outline" onClick={onClose}>
<div className="flex mx-2">Cancel</div>
</Button>
)}
<BasicClickable onClick={onSubmit}>
<div className="flex mx-2">
<FiTrash className="my-auto mr-2" />
{deleteButtonText || "Delete"}
</div>
</BasicClickable>
<Button size="sm" variant="destructive" onClick={onSubmit}>
<div className="flex mx-2">{deleteButtonText || "Delete"}</div>
</Button>
</div>
</div>
</>

View File

@@ -334,7 +334,7 @@ export function FilterPopup({
/>
</div>
</div>
<ul className="space-y-1">
<ul className="space-y-1 default-scrollbar overflow-y-auto max-h-64">
{availableSources.map((source) => (
<SelectableDropdown
icon={

View File

@@ -63,7 +63,6 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
} else {
settings = await results[0].json();
}
console.log(JSON.stringify(settings));
let enterpriseSettings: EnterpriseSettings | null = null;
if (tasks.length > 1) {
@@ -95,6 +94,10 @@ export async function fetchSettingsSS(): Promise<CombinedSettings | null> {
}
}
if (enterpriseSettings && settings.pro_search_disabled == null) {
settings.pro_search_disabled = true;
}
const webVersion = getWebVersion();
const combinedSettings: CombinedSettings = {

View File

@@ -1,15 +1,24 @@
import React from "react";
import { MdDragIndicator } from "react-icons/md";
export const DragHandle = (props: any) => {
interface DragHandleProps extends React.HTMLAttributes<HTMLDivElement> {
isDragging?: boolean;
size?: number;
}
export const DragHandle: React.FC<DragHandleProps> = ({
isDragging,
size = 16,
...props
}) => {
return (
<div
className={
props.isDragging ? "hover:cursor-grabbing" : "hover:cursor-grab"
}
className={`flex items-center justify-center ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
{...props}
>
<MdDragIndicator />
<MdDragIndicator size={size} />
</div>
);
};

View File

@@ -6,12 +6,12 @@ import { Row } from "./interfaces";
export function DraggableRow({
row,
forceDragging,
isAdmin = true,
isDragOverlay = false,
}: {
row: Row;
forceDragging?: boolean;
isAdmin?: boolean;
isDragOverlay?: boolean;
}) {
const {
attributes,
@@ -22,29 +22,25 @@ export function DraggableRow({
isDragging,
} = useSortable({
id: row.id,
disabled: isDragOverlay,
});
const style = {
transform: CSS.Transform.toString(transform),
transition: transition,
transition,
};
return (
<TableRow
ref={setNodeRef}
style={style}
className={isDragging ? "invisible" : "bg-background"}
style={isDragOverlay ? undefined : style}
className={isDragging && !isDragOverlay ? "opacity-0" : ""}
>
<TableCell>
{isAdmin && (
<DragHandle
isDragging={isDragging || forceDragging}
{...attributes}
{...listeners}
/>
)}
{isAdmin && <DragHandle isDragging={isDragging} {...listeners} />}
</TableCell>
{row.cells.map((column, ind) => (
<TableCell key={ind}>{column}</TableCell>
{row.cells.map((cell, index) => (
<TableCell key={index}>{cell}</TableCell>
))}
</TableRow>
);

Some files were not shown because too many files have changed in this diff Show More