diff --git a/.gitignore b/.gitignore index be58436ef73..d8fe975a7bd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ venv/ # These contain prompt content (chat history, internal references, names). backend/requestdata.json requestdata.json + +# Playwright MCP session artifacts (console logs, page snapshots, ad-hoc +# screenshots) written during local UI debugging. Not source. +.playwright-mcp/ +model-picker-open.png diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000000..259a959104f --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index 077c1153846..cb86cb4cd8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,7 +261,14 @@ Process restart matrix after enum additions: | API server | `uvicorn danswer.main:app …` | Deserializes Vespa results. | | Slack listener | `python danswer/danswerbot/slack/listener.py` | Same as API. | | Celery worker / beat | spawned by the dev script | Imports `connectors/factory.py`. | -| Frontend (`npm run dev`) | Hot-reloads modules but `.next/cache` can lag — `rm -rf web/.next` if a tile/source rename doesn't show. | +| Frontend (`npm run dev`) | Hot-reloads modules but `.next/cache` can lag — `rm -rf web/.next` if a tile/source rename doesn't show. Also: `next.config.js` is only re-read on full restart. | + +Auth-specific bounces (orthogonal to enum changes): + +| Edit | Bounce | +|---|---| +| `AUTH_TYPE`, `OPENID_CONFIG_URL`, `OAUTH_CLIENT_*`, `USER_AUTH_SECRET`, `DEFAULT_ADMIN_EMAILS` | API server (`dapi` / `dapi_oidc`). Env is captured at fork time; `--reload` doesn't refresh it. Also: `--reload` only re-imports module code, not the module-level constants like `AUTH_TYPE = AuthType(os.environ.get(...))` that already evaluated. Hard restart. | +| `web/next.config.js` (rewrites / redirects) | Frontend (`dfe`). Next.js reads this file once at boot. | ### 4. The list endpoint serves both pages @@ -445,6 +452,41 @@ See `db/tasks.py::get_latest_tasks_by_names` and the corresponding refactor in `server/documents/connector.py::get_connector_indexing_status` for the pattern. +### Enable Microsoft / Entra ID OIDC (local dev) + +This fork hosts the OIDC plumbing in OSS — no `ee/` import required. + +1. **Env vars** (see CONTRIBUTING.md → "Microsoft / Entra ID OIDC" block): + `AUTH_TYPE=oidc`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, + `OPENID_CONFIG_URL`, `USER_AUTH_SECRET`, `WEB_DOMAIN`, and optionally + `DEFAULT_ADMIN_EMAILS`. +2. **Run `dapi_oidc`** in the API terminal (not `dapi` — that alias hard-codes + `AUTH_TYPE=disabled` inline, overriding any export). +3. **Entra Redirect URI**: the app registration must list + `http://localhost:3000/auth/oidc/callback`. The Dex callback (if your tenant + still has one registered) can stay alongside. + +Files involved (don't duplicate this logic into `ee/`): + +| File | What it does | +|---|---| +| `backend/danswer/main.py` (OIDC `elif` block) | Mounts `/auth/oidc/{authorize,callback}` via `httpx_oauth.clients.openid.OpenID`. | +| `backend/danswer/auth/users.py::verify_auth_setting` | Allowlist includes `AuthType.OIDC` (fork divergence vs upstream-here-only). | +| `backend/danswer/auth/users.py::oauth_callback` | After `super().oauth_callback(...)`, auto-promotes `is_verified=True` when the provider vouches for the email — works around fastapi-users not flipping the flag during the `associate_by_email` path. | +| `backend/danswer/server/auth_check.py::PUBLIC_ENDPOINT_SPECS` | `/auth/oidc/authorize` and `/auth/oidc/callback` are listed as public — `check_router_auth` raises on missing-auth routes at startup. | +| `backend/danswer/db/auth.py::SQLAlchemyUserAdminDB.create` | Role logic: if `DEFAULT_ADMIN_EMAILS` is set, only those emails become ADMIN; otherwise first-user fallback fires for bootstrap. | +| `backend/danswer/configs/app_configs.py` | `OPENID_CONFIG_URL` and `DEFAULT_ADMIN_EMAILS` parsed from env. | +| `web/src/app/auth/oidc/callback/route.ts` | Already wired; mirrors `/auth/oauth/callback/route.ts`. | +| `web/src/lib/userSS.ts` | `getAuthUrlSS` already handles `case "oidc"`. | + +Trigger a fresh login flow: + +```bash +# Browser is sticky on Microsoft session — incognito or sign out of Microsoft +# in another tab to force the login prompt. Otherwise Entra silent-SSO bounces +# you straight through without showing its UI. +``` + ### Edit credentials without re-creating the connector Backend `PATCH /api/manage/credential/{id}` already exists. Frontend @@ -531,6 +573,19 @@ with `disabled: bool` flipped — no special bulk endpoint needed. `dask.distributed.Client` honors it. The current code in `update.py::kickoff_indexing_jobs` checks `isinstance(client, Client)` before adding the kwarg; preserve that guard. +- **Don't reintroduce the 308 redirects in `web/next.config.js`** for + `/api/chat/send-message`, `/api/query/stream-answer-with-quote`, or + `/api/query/stream-query-validation`. They used to live there for stream + proxying in older Next.js. The browser's 308 hop strips the localhost- + scoped session cookie on the cross-origin jump to `127.0.0.1:8080`, and + cookie-based auth (OIDC, basic, anything) breaks. Use the generic + `/api/:path*` rewrite — Next.js 14's rewrite proxy handles streaming. +- **Don't unpin `bcrypt==4.0.1` in `backend/requirements/default.txt`** + without also bumping `passlib`. passlib 1.7.4 reads `bcrypt.__about__` + for version detection, which was removed in bcrypt 4.1+. Without the + pin, every OAuth user creation explodes with + `ValueError: password cannot be longer than 72 bytes` from passlib's + `detect_wrap_bug` probe during bcrypt backend init. --- @@ -558,6 +613,21 @@ historical fixes — useful so you don't accidentally undo them: - **`get_connector_indexing_status` is now O(1) queries** regardless of cc-pair count — per-row deletion-status lookups were bulk-fetched. Don't re-introduce per-row lookups in this endpoint. +- **OIDC sign-in stalled on 403 "User is not authenticated"** when chat + was sent. Root cause was a 308 redirect in `web/next.config.js` that + bounced `/api/chat/send-message` to `127.0.0.1:8080`, stripping the + localhost-scoped session cookie. The streaming endpoints now flow + through the generic `/api/:path*` rewrite — don't add per-endpoint + redirects back. See Footguns. +- **`is_verified=False` after OIDC sign-in 403'd chat send** until + `UserManager.oauth_callback` was patched to flip `is_verified=True` + after `super().oauth_callback(...)` returns. fastapi-users 12.x only + sets the flag for *newly-created* users, not for accounts associated + via `associate_by_email`. Keep the post-super promotion in place. +- **First-user bootstrap admin** in `SQLAlchemyUserAdminDB.create` only + fires when `DEFAULT_ADMIN_EMAILS` is empty. If the env var is set, the + allowlist is strict — no fallback. Don't restore the unconditional + `user_count == 0` path without the env-gate. --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4a467248f8..1e70f61b346 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -261,6 +261,16 @@ dapi() { cd backend && AUTH_TYPE=disabled uvicorn danswer.main:app --reload --port 8080 } +# API server with Microsoft / Entra ID OIDC auth (port 8080). Needs the +# OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET / OPENID_CONFIG_URL / USER_AUTH_SECRET +# vars from the "Microsoft / Entra ID OIDC" block below. Uses your shell's +# AUTH_TYPE rather than hard-coding `disabled`. +dapi_oidc() { + printf "\033]0;Danswer-APIServer-OIDC\007" + _danswer_activate || return 1 + cd backend && AUTH_TYPE=oidc uvicorn danswer.main:app --reload --port 8080 +} + # Background jobs (indexing loop + Celery worker + Celery beat) dbe() { printf "\033]0;Danswer-Backend\007" @@ -399,6 +409,31 @@ export RETENTION_MAX_BATCHES=200 # safety ceiling per policy per # --------------------------------------------------------------------------- export ANALYTICS_LATE_FEEDBACK_BUFFER_DAYS=2 # late-feedback grace period +# --------------------------------------------------------------------------- +# Microsoft / Entra ID OIDC (optional — only when running `dapi_oidc` instead +# of `dapi`). Skip this whole block for the default `AUTH_TYPE=disabled` flow. +# +# 1. In Entra portal → App registrations, register an app (or reuse the +# existing tenant one) and add `http://localhost:3000/auth/oidc/callback` +# to its Redirect URIs. Note the Application (client) ID, generate a +# client secret, and grab the Directory (tenant) ID. +# 2. Fill the values below and re-source. +# 3. `dapi_oidc` (instead of `dapi`) in the API terminal. `dfe` stays the +# same — the frontend reads AUTH_TYPE dynamically from /auth/type. +# 4. Hit http://localhost:3000/auth/login → bounces through Microsoft and +# issues a session cookie scoped to localhost:3000. +# +# DEFAULT_ADMIN_EMAILS: comma-separated emails that land as ADMIN on first +# sign-in. Leave empty to fall back to the "first user wins" bootstrap. +# Set in any environment where you don't want the first signer to be admin. +# --------------------------------------------------------------------------- +export OAUTH_CLIENT_ID='' +export OAUTH_CLIENT_SECRET='' +export OPENID_CONFIG_URL='https://login.microsoftonline.com//v2.0/.well-known/openid-configuration' +export USER_AUTH_SECRET="$(openssl rand -hex 32)" # any long random string; must stay stable across restarts +export WEB_DOMAIN='http://localhost:3000' +export DEFAULT_ADMIN_EMAILS='user1@uipath.com,user2@uipath.com' + # --------------------------------------------------------------------------- # GitHub PAT — used by the `gh` CLI and the GitHub / GitHub-Files connectors # --------------------------------------------------------------------------- diff --git a/backend/danswer/auth/users.py b/backend/danswer/auth/users.py index 06c0841d8ac..5605fdbde35 100644 --- a/backend/danswer/auth/users.py +++ b/backend/danswer/auth/users.py @@ -64,10 +64,15 @@ def verify_auth_setting() -> None: - if AUTH_TYPE not in [AuthType.DISABLED, AuthType.BASIC, AuthType.GOOGLE_OAUTH]: + if AUTH_TYPE not in [ + AuthType.DISABLED, + AuthType.BASIC, + AuthType.GOOGLE_OAUTH, + AuthType.OIDC, + ]: raise ValueError( "User must choose a valid user authentication method: " - "disabled, basic, or google_oauth" + "disabled, basic, google_oauth, or oidc" ) logger.info(f"Using Auth Type: {AUTH_TYPE.value}") @@ -173,7 +178,7 @@ async def oauth_callback( verify_email_in_whitelist(account_email) verify_email_domain(account_email) - return await super().oauth_callback( # type: ignore + user = await super().oauth_callback( # type: ignore oauth_name=oauth_name, access_token=access_token, account_id=account_id, @@ -185,6 +190,24 @@ async def oauth_callback( is_verified_by_default=is_verified_by_default, ) + # fastapi-users only sets is_verified for newly-created users; when + # associate_by_email matches an existing row, it leaves is_verified + # untouched. Since the OAuth provider already vouched for the email, + # promote it here so downstream `double_check_user` doesn't 403 the user. + logger.info( + "oauth_callback complete: oauth_name=%s email=%s " + "is_verified_by_default=%s user.is_verified=%s", + oauth_name, + account_email, + is_verified_by_default, + user.is_verified, + ) + if is_verified_by_default and not user.is_verified: + user = await self.user_db.update(user, {"is_verified": True}) + logger.info("Promoted %s to is_verified=True", account_email) + + return user + async def on_after_register( self, user: User, request: Optional[Request] = None ) -> None: diff --git a/backend/danswer/chat/personas.yaml b/backend/danswer/chat/personas.yaml index 5668663883a..ecb9d7cfe38 100644 --- a/backend/danswer/chat/personas.yaml +++ b/backend/danswer/chat/personas.yaml @@ -37,6 +37,21 @@ personas: # - "Engineer Onboarding" # - "Benefits" document_sets: [] + # Clickable cards shown on a fresh chat. Each card prefills the message + # input with `message` on click. Tuned for the default "Search mode." + starter_messages: + - name: "Recent changes" + description: "Catch up on what shipped this week." + message: "What has my team shipped in the last 7 days?" + - name: "Find a spec" + description: "Look up a design or technical doc." + message: "Find the latest design doc related to " + - name: "Summarize a topic" + description: "Pull together everything we know about something." + message: "Summarize what we know about " + - name: "Who owns this?" + description: "Find the right person to ask." + message: "Who is the owner / point of contact for " - id: 1 diff --git a/backend/danswer/chat/process_message.py b/backend/danswer/chat/process_message.py index 64f6bab822d..b94264abd06 100644 --- a/backend/danswer/chat/process_message.py +++ b/backend/danswer/chat/process_message.py @@ -50,6 +50,7 @@ from danswer.llm.exceptions import GenAIDisabledException from danswer.llm.factory import get_llms_for_persona from danswer.llm.factory import get_main_llm_from_tuple +from danswer.llm.interfaces import LLM from danswer.llm.interfaces import LLMConfig from danswer.llm.utils import get_default_llm_tokenizer from danswer.search.enums import OptionalSearchSetting @@ -215,6 +216,7 @@ def stream_chat_message_objects( 4. [always] Details on the final AI response message that is created """ + llm: LLM | None = None try: user_id = user.id if user is not None else None @@ -601,7 +603,8 @@ def stream_chat_message_objects( # Don't leak the API key error_msg = str(e) - if llm.config.api_key and llm.config.api_key.lower() in error_msg.lower(): + api_key = llm.config.api_key if llm is not None else None + if api_key and api_key.lower() in error_msg.lower(): error_msg = ( f"LLM failed to respond. Invalid API " f"key error from '{llm.config.model_provider}'." diff --git a/backend/danswer/configs/app_configs.py b/backend/danswer/configs/app_configs.py index e447c189a47..321a8bdca01 100644 --- a/backend/danswer/configs/app_configs.py +++ b/backend/danswer/configs/app_configs.py @@ -72,6 +72,14 @@ if _VALID_EMAIL_DOMAINS_STR else [] ) +# Comma-separated emails that are granted ADMIN role on first sign-in. +# Independent of which auth backend (basic / google_oauth / oidc) created the account. +_DEFAULT_ADMIN_EMAILS_STR = os.environ.get("DEFAULT_ADMIN_EMAILS", "") +DEFAULT_ADMIN_EMAILS = ( + [email.strip() for email in _DEFAULT_ADMIN_EMAILS_STR.split(",") if email.strip()] + if _DEFAULT_ADMIN_EMAILS_STR + else [] +) # OAuth Login Flow # Used for both Google OAuth2 and OIDC flows OAUTH_CLIENT_ID = ( @@ -81,6 +89,9 @@ os.environ.get("OAUTH_CLIENT_SECRET", os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET")) or "" ) +# OpenID Connect discovery URL (e.g. Entra ID: +# https://login.microsoftonline.com//v2.0/.well-known/openid-configuration) +OPENID_CONFIG_URL = os.environ.get("OPENID_CONFIG_URL", "") USER_AUTH_SECRET = os.environ.get("USER_AUTH_SECRET", "") # for basic auth diff --git a/backend/danswer/configs/model_configs.py b/backend/danswer/configs/model_configs.py index 1cc608939d7..aa0628de9ff 100644 --- a/backend/danswer/configs/model_configs.py +++ b/backend/danswer/configs/model_configs.py @@ -86,6 +86,8 @@ GEN_AI_CLIENT_SECRET = os.environ.get("GEN_AI_CLIENT_SECRET") or None GEN_AI_ACCOUNT_ID = os.environ.get("GEN_AI_ACCOUNT_ID") or None GEN_AI_TENANT_ID = os.environ.get("GEN_AI_TENANT_ID") or None +GEN_AI_VENDOR = os.environ.get("GEN_AI_VENDOR") or "openai" +GEN_AI_MODEL_NAME = os.environ.get("GEN_AI_MODEL_NAME") or "gpt-4o-2024-11-20" # Number of tokens from chat history to include at maximum # 3000 should be enough context regardless of use, no need to include as much as possible # as this drives up the cost unnecessarily diff --git a/backend/danswer/danswerbot/slack/blocks.py b/backend/danswer/danswerbot/slack/blocks.py index fffe9feb279..54828969579 100644 --- a/backend/danswer/danswerbot/slack/blocks.py +++ b/backend/danswer/danswerbot/slack/blocks.py @@ -78,6 +78,18 @@ def slack_link_format(match: Match) -> str: return re.sub(pattern, slack_link_format, text) +# Slack mrkdwn fenced code blocks (```) have no concept of a language/"info +# string": ```bash renders the literal word "bash" as the first line of the block +# (and Slack never syntax-highlights regardless). Strip the language token off +# opening fences so the block starts on the actual code. A bare closing ``` has no +# token after it and is left untouched. +_CODE_FENCE_LANGUAGE_RE = re.compile(r"```[ \t]*[A-Za-z][\w+#.\-]*[ \t]*(\r?\n)") + + +def strip_code_fence_languages(text: str) -> str: + return _CODE_FENCE_LANGUAGE_RE.sub(r"```\1", text) + + def _split_text(text: str, limit: int = 3000) -> list[str]: if len(text) <= limit: return [text] @@ -398,7 +410,9 @@ def build_qa_response_blocks( ) ] else: - answer_processed = decode_escapes(remove_slack_text_interactions(answer)) + answer_processed = strip_code_fence_languages( + decode_escapes(remove_slack_text_interactions(answer)) + ) if process_message_for_citations: answer_processed = _process_citations_for_slack(answer_processed) answer_blocks = [ diff --git a/backend/danswer/danswerbot/slack/handlers/handle_message.py b/backend/danswer/danswerbot/slack/handlers/handle_message.py index 088491adb72..d4b79ef0d2c 100644 --- a/backend/danswer/danswerbot/slack/handlers/handle_message.py +++ b/backend/danswer/danswerbot/slack/handlers/handle_message.py @@ -59,6 +59,7 @@ compute_max_document_tokens_for_persona, ) from danswer.llm.factory import get_llms_for_persona +from danswer.llm.override_models import LLMOverride from danswer.llm.utils import check_number_of_tokens from danswer.llm.utils import get_max_input_tokens from danswer.one_shot_answer.answer_question import get_search_answer @@ -572,7 +573,15 @@ def _get_answer(new_message_request: DirectQARequest) -> OneShotQAResponse | Non Persona, fetch_persona_by_id(db_session, new_message_request.persona_id), ) - llm, _ = get_llms_for_persona(persona) + llm_override = None + if channel_config and channel_config.channel_config: + ch_vendor = channel_config.channel_config.get("llm_vendor") + ch_model = channel_config.channel_config.get("llm_model_name") + if ch_vendor or ch_model: + llm_override = LLMOverride( + model_provider=ch_vendor, model_version=ch_model + ) + llm, _ = get_llms_for_persona(persona, llm_override=llm_override) # In cases of threads, split the available tokens between docs and thread context input_tokens = get_max_input_tokens( diff --git a/backend/danswer/db/auth.py b/backend/danswer/db/auth.py index 161fdc8f10b..4db5d23b207 100644 --- a/backend/danswer/db/auth.py +++ b/backend/danswer/db/auth.py @@ -12,6 +12,7 @@ from sqlalchemy.future import select from danswer.auth.schemas import UserRole +from danswer.configs.app_configs import DEFAULT_ADMIN_EMAILS from danswer.db.engine import get_async_session from danswer.db.engine import get_sqlalchemy_async_engine from danswer.db.models import AccessToken @@ -24,11 +25,14 @@ def get_default_admin_user_emails() -> list[str]: """Returns a list of emails who should default to Admin role. - Only used in the EE version. For MIT, just return empty list.""" + Sourced from the DEFAULT_ADMIN_EMAILS env var (comma-separated). + The EE version may override this via the versioned implementation.""" get_default_admin_user_emails_fn: Callable[ [], list[str] ] = fetch_versioned_implementation_with_fallback( - "danswer.auth.users", "get_default_admin_user_emails_", lambda: [] + "danswer.auth.users", + "get_default_admin_user_emails_", + lambda: DEFAULT_ADMIN_EMAILS, ) return get_default_admin_user_emails_fn() @@ -46,11 +50,15 @@ async def get_user_count() -> int: # Need to override this because FastAPI Users doesn't give flexibility for backend field creation logic in OAuth flow class SQLAlchemyUserAdminDB(SQLAlchemyUserDatabase): async def create(self, create_dict: Dict[str, Any]) -> UP: - user_count = await get_user_count() - if user_count == 0 or create_dict["email"] in get_default_admin_user_emails(): - create_dict["role"] = UserRole.ADMIN + admin_emails = get_default_admin_user_emails() + if admin_emails: + # Explicit allowlist wins — first user is NOT auto-admin. + is_admin = create_dict["email"] in admin_emails else: - create_dict["role"] = UserRole.BASIC + # No allowlist configured: bootstrap convenience — first user is admin. + user_count = await get_user_count() + is_admin = user_count == 0 + create_dict["role"] = UserRole.ADMIN if is_admin else UserRole.BASIC return await super().create(create_dict) diff --git a/backend/danswer/db/models.py b/backend/danswer/db/models.py index 58a8f32a8e9..4a07d4c2887 100644 --- a/backend/danswer/db/models.py +++ b/backend/danswer/db/models.py @@ -1104,6 +1104,9 @@ class ChannelConfig(TypedDict): jira_config: NotRequired[dict[str, Any]] # Contains all JIRA related settings # Curated response config if user asks for more help curated_response_config: NotRequired[dict[str, Any]] + # LLM configuration for this channel + llm_vendor: NotRequired[str] + llm_model_name: NotRequired[str] class SlackBotResponseType(str, PyEnum): diff --git a/backend/danswer/db/persona.py b/backend/danswer/db/persona.py index 26292fc9264..946a3f897e4 100644 --- a/backend/danswer/db/persona.py +++ b/backend/danswer/db/persona.py @@ -202,6 +202,20 @@ def mark_persona_as_deleted( db_session: Session, ) -> None: persona = get_persona_by_id(persona_id=persona_id, user=user, db_session=db_session) + # `get_persona_by_id` grants non-admins access to ownerless personas + # (user_id IS NULL), which includes the shared default/system assistants. Block + # non-admins from deleting those here, mirroring the frontend's `!default_persona` + # rule — otherwise a basic user could soft-delete a default assistant (removing it + # for everyone) via a direct API call. Non-admins may only delete personas they own. + if ( + user is not None + and user.role != UserRole.ADMIN + and (persona.default_persona or persona.user_id is None) + ): + raise HTTPException( + status_code=403, + detail="Only admins can delete default or shared assistants.", + ) persona.deleted = True db_session.commit() diff --git a/backend/danswer/llm/custom_llm.py b/backend/danswer/llm/custom_llm.py index 18c8a20b569..7af09d13f0c 100644 --- a/backend/danswer/llm/custom_llm.py +++ b/backend/danswer/llm/custom_llm.py @@ -14,7 +14,9 @@ from danswer.configs.model_configs import GEN_AI_CLIENT_SECRET from danswer.configs.model_configs import GEN_AI_IDENTITY_ENDPOINT from danswer.configs.model_configs import GEN_AI_MAX_OUTPUT_TOKENS +from danswer.configs.model_configs import GEN_AI_MODEL_NAME from danswer.configs.model_configs import GEN_AI_TENANT_ID +from danswer.configs.model_configs import GEN_AI_VENDOR from danswer.llm.interfaces import LLM from danswer.llm.interfaces import LLMConfig from danswer.llm.interfaces import ToolChoiceOptions @@ -58,7 +60,8 @@ def _get_token(self) -> str: raise ValueError("Failed to get access token from the model server") else: print( - f"Access token request failed with status code: {response.status_code}" + f"Access token request failed with status code: {response.status_code} " + f"body: {response.text[:500]!r}" ) raise ValueError("Failed to get access token from the model server") @@ -67,8 +70,7 @@ def __init__( # Not used here but you probably want a model server that isn't completely open api_key: str | None, timeout: int, - endpoint: str - | None = "https://alpha.uipath.com/{account_id}/{tenant_id}/llmgateway_/api/raw/vendor/openai/model/gpt-4o-2024-11-20/completions", + endpoint: str | None = None, identity_url: str | None = GEN_AI_IDENTITY_ENDPOINT, client_id: str | None = GEN_AI_CLIENT_ID, client_secret: str | None = GEN_AI_CLIENT_SECRET, @@ -76,11 +78,15 @@ def __init__( tenant_id: str | None = GEN_AI_TENANT_ID, max_output_tokens: int = int(GEN_AI_MAX_OUTPUT_TOKENS), api_version: str | None = GEN_AI_API_VERSION, + llm_vendor: str | None = None, + llm_model_name: str | None = None, ): + vendor = llm_vendor or GEN_AI_VENDOR + model = llm_model_name or GEN_AI_MODEL_NAME if not endpoint: - raise ValueError( - "Cannot point Danswer to a custom LLM server without providing the " - "endpoint for the model server." + endpoint = ( + "https://alpha.uipath.com/{account_id}/{tenant_id}" + f"/llmgateway_/api/raw/vendor/{vendor}/model/{model}/completions" ) if not identity_url: @@ -129,9 +135,8 @@ def __init__( self._max_output_tokens = max_output_tokens self._timeout = timeout self.token = self._get_token() - # TODO: Remove hard-coding - self._model_provider = "custom" - self._model_version = "gpt-4" + self._model_provider = vendor + self._model_version = model self._temperature = 0.0 self._api_key = api_key @@ -139,42 +144,62 @@ def __init__( self._max_output_tokens = 7000 def _execute(self, input: LanguageModelInput) -> AIMessage: + is_bedrock = self._model_provider == "awsbedrock" + api_flavor = "converse" if is_bedrock else "chat-completions" + headers = { "Content-Type": "application/json", "Authorization": "Bearer " + self.token, "X-UiPath-LlmGateway-RequestingProduct": "darwin", "X-UiPath-LlmGateway-RequestingFeature": "ChatWithAssistant", - "X-UiPath-LlmGateway-ApiFlavor": "chat-completions", + "X-UiPath-LlmGateway-ApiFlavor": api_flavor, "X-UiPath-LlmGateway-ApiVersion": "2024-10-21", "X-UiPath-LlmGateway-TimeoutSeconds": "60", "X-UIPATH-STREAMING-ENABLED": "false", } - # print(f"Input: {input}") chatPrompt = convert_lm_input_to_prompt(input) - - json_array = [] messages = chatPrompt.to_messages() - for msg in messages: - mapped_type = self._map_type(msg.type) - json_obj = { - "role": mapped_type, - "content": self._clean_json_string(msg.content), - } - json_array.append(json_obj) - data = {"max_tokens": self._max_output_tokens, "messages": json_array} + if is_bedrock: + # AWS Bedrock Converse API format + bedrock_messages = [] + for msg in messages: + mapped_type = self._map_type(msg.type) + if mapped_type == "system": + continue # system handled separately below + bedrock_messages.append( + { + "role": mapped_type, + "content": [{"text": self._clean_json_string(msg.content)}], + } + ) + data: dict = { + "messages": bedrock_messages, + "inferenceConfig": {"maxTokens": self._max_output_tokens}, + } + system_msgs = [ + {"text": self._clean_json_string(msg.content)} + for msg in messages + if self._map_type(msg.type) == "system" + ] + if system_msgs: + data["system"] = system_msgs + else: + # OpenAI chat-completions format + json_array = [] + for msg in messages: + mapped_type = self._map_type(msg.type) + json_array.append( + { + "role": mapped_type, + "content": self._clean_json_string(msg.content), + } + ) + data = {"max_tokens": self._max_output_tokens, "messages": json_array} try: - print(data) - with open("requestdata.json", "w") as fp: - json.dump(data, fp) - - # json_str = json.dumps(data, ensure_ascii=False, indent=4) - # print(f"Request Data: {json_str}") - # json_data = json.loads(json_str) response = requests.post( - # self._endpoint, headers=headers, data=json_str, timeout=self._timeout self._endpoint, headers=headers, json=data, @@ -183,18 +208,30 @@ def _execute(self, input: LanguageModelInput) -> AIMessage: except Timeout as error: raise Timeout(f"Model inference to {self._endpoint} timed out") from error + if not response.ok: + logger.error( + "LLM gateway returned %s for %s body=%r", + response.status_code, + self._endpoint, + response.text[:1000], + ) response.raise_for_status() try: - data = json.loads(response.content) - print(data) + response_data = json.loads(response.content) except json.decoder.JSONDecodeError as e: print("Failed to parse JSON:", response.content) raise e message_content = "No response from LLM server" - if data["choices"]: - message_content = data["choices"][0]["message"]["content"] - # print(message_content) + if is_bedrock: + output = ( + response_data.get("output", {}).get("message", {}).get("content", []) + ) + if output: + message_content = output[0].get("text", message_content) + else: + if response_data.get("choices"): + message_content = response_data["choices"][0]["message"]["content"] return AIMessage(content=message_content) def _clean_json_string(self, input_string): diff --git a/backend/danswer/llm/factory.py b/backend/danswer/llm/factory.py index 7e8a59c1d94..ab67e43939c 100644 --- a/backend/danswer/llm/factory.py +++ b/backend/danswer/llm/factory.py @@ -1,16 +1,13 @@ from danswer.configs.app_configs import DISABLE_GENERATIVE_AI from danswer.configs.chat_configs import QA_TIMEOUT +from danswer.configs.model_configs import GEN_AI_MODEL_NAME from danswer.configs.model_configs import GEN_AI_TEMPERATURE -from danswer.db.engine import get_session_context_manager -from danswer.db.llm import fetch_default_provider -from danswer.db.llm import fetch_provider +from danswer.configs.model_configs import GEN_AI_VENDOR from danswer.db.models import Persona -from danswer.llm.chat_llm import DefaultMultiLLM +from danswer.llm.custom_llm import CustomModelServer from danswer.llm.exceptions import GenAIDisabledException -from danswer.llm.headers import build_llm_extra_headers from danswer.llm.interfaces import LLM from danswer.llm.override_models import LLMOverride -from danswer.llm.custom_llm import CustomModelServer def get_main_llm_from_tuple( @@ -26,40 +23,13 @@ def get_llms_for_persona( ) -> tuple[LLM, LLM]: model_provider_override = llm_override.model_provider if llm_override else None model_version_override = llm_override.model_version if llm_override else None - temperature_override = llm_override.temperature if llm_override else None - - provider_name = model_provider_override or persona.llm_model_provider_override - if not provider_name: - return get_default_llms( - temperature=temperature_override or GEN_AI_TEMPERATURE, - additional_headers=additional_headers, - ) - - with get_session_context_manager() as db_session: - llm_provider = fetch_provider(db_session, provider_name) - - if not llm_provider: - raise ValueError("No LLM provider found") - - model = model_version_override or persona.llm_model_version_override - fast_model = llm_provider.fast_default_model_name or llm_provider.default_model_name - if not model: - raise ValueError("No model name found") - if not fast_model: - raise ValueError("No fast model name found") + llm_override.temperature if llm_override else None - def _create_llm(model: str) -> LLM: - return get_llm( - provider=llm_provider.provider, - model=model, - api_key=llm_provider.api_key, - api_base=llm_provider.api_base, - api_version=llm_provider.api_version, - custom_config=llm_provider.custom_config, - additional_headers=additional_headers, - ) + vendor = model_provider_override or GEN_AI_VENDOR + model = model_version_override or GEN_AI_MODEL_NAME - return _create_llm(model), _create_llm(fast_model) + llm = get_llm(provider=vendor, model=model) + return llm, llm def get_default_llms( @@ -70,35 +40,8 @@ def get_default_llms( if DISABLE_GENERATIVE_AI: raise GenAIDisabledException() - with get_session_context_manager() as db_session: - llm_provider = fetch_default_provider(db_session) - - if not llm_provider: - raise ValueError("No default LLM provider found") - - model_name = llm_provider.default_model_name - fast_model_name = ( - llm_provider.fast_default_model_name or llm_provider.default_model_name - ) - if not model_name: - raise ValueError("No default model name found") - if not fast_model_name: - raise ValueError("No fast default model name found") - - def _create_llm(model: str) -> LLM: - return get_llm( - provider=llm_provider.provider, - model=model, - api_key=llm_provider.api_key, - api_base=llm_provider.api_base, - api_version=llm_provider.api_version, - custom_config=llm_provider.custom_config, - timeout=timeout, - temperature=temperature, - additional_headers=additional_headers, - ) - - return _create_llm(model_name), _create_llm(fast_model_name) + llm = get_llm(provider=GEN_AI_VENDOR, model=GEN_AI_MODEL_NAME, timeout=timeout) + return llm, llm def get_llm( @@ -113,6 +56,8 @@ def get_llm( additional_headers: dict[str, str] | None = None, ) -> LLM: return CustomModelServer( - timeout=timeout, - api_key=api_key, - ) \ No newline at end of file + timeout=timeout, + api_key=api_key, + llm_vendor=provider, + llm_model_name=model, + ) diff --git a/backend/danswer/main.py b/backend/danswer/main.py index 54a9dbd45bb..879d1edb0dd 100644 --- a/backend/danswer/main.py +++ b/backend/danswer/main.py @@ -13,6 +13,7 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from httpx_oauth.clients.google import GoogleOAuth2 +from httpx_oauth.clients.openid import OpenID from sqlalchemy.orm import Session from danswer import __version__ @@ -31,6 +32,7 @@ from danswer.configs.app_configs import LOG_ENDPOINT_LATENCY from danswer.configs.app_configs import OAUTH_CLIENT_ID from danswer.configs.app_configs import OAUTH_CLIENT_SECRET +from danswer.configs.app_configs import OPENID_CONFIG_URL from danswer.configs.app_configs import USER_AUTH_SECRET from danswer.configs.app_configs import WEB_DOMAIN from danswer.configs.chat_configs import MULTILINGUAL_QUERY_EXPANSION @@ -353,6 +355,34 @@ def get_application() -> FastAPI: tags=["auth"], ) + elif AUTH_TYPE == AuthType.OIDC: + if not OPENID_CONFIG_URL: + raise ValueError( + "OPENID_CONFIG_URL must be set when AUTH_TYPE=oidc " + "(e.g. https://login.microsoftonline.com//v2.0/" + ".well-known/openid-configuration)" + ) + include_router_with_global_prefix_prepended( + application, + fastapi_users.get_oauth_router( + OpenID(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OPENID_CONFIG_URL), + auth_backend, + USER_AUTH_SECRET, + associate_by_email=True, + is_verified_by_default=True, + redirect_url=f"{WEB_DOMAIN}/auth/oidc/callback", + ), + prefix="/auth/oidc", + tags=["auth"], + ) + # Need basic auth router for `logout` endpoint + include_router_with_global_prefix_prepended( + application, + fastapi_users.get_logout_router(auth_backend), + prefix="/auth", + tags=["auth"], + ) + application.add_exception_handler( RequestValidationError, validation_exception_handler ) diff --git a/backend/danswer/search/preprocessing/preprocessing.py b/backend/danswer/search/preprocessing/preprocessing.py index 7d0ee8da593..e59ef37c95e 100644 --- a/backend/danswer/search/preprocessing/preprocessing.py +++ b/backend/danswer/search/preprocessing/preprocessing.py @@ -53,10 +53,21 @@ def retrieval_preprocessing( persona = search_request.persona preset_filters = search_request.human_selected_filters or BaseFilters() - if persona and persona.document_sets and preset_filters.document_set is None: - preset_filters.document_set = [ - document_set.name for document_set in persona.document_sets - ] + # Persona's document_sets act as an outer fence: they bound the maximum + # set the search can ever return. User-selected document_set filters + # refine *within* that fence (intersection), they don't replace it. + if persona and persona.document_sets: + persona_doc_set_names = [ds.name for ds in persona.document_sets] + if preset_filters.document_set: + # Intersect: only keep user picks that are also in the persona's fence. + preset_filters.document_set = [ + name + for name in preset_filters.document_set + if name in persona_doc_set_names + ] + else: + # No user pick → use the persona's fence as-is. + preset_filters.document_set = persona_doc_set_names time_filter = preset_filters.time_cutoff source_filter = preset_filters.source_type diff --git a/backend/danswer/server/auth_check.py b/backend/danswer/server/auth_check.py index 53ef572daa3..eeb1642e534 100644 --- a/backend/danswer/server/auth_check.py +++ b/backend/danswer/server/auth_check.py @@ -41,6 +41,9 @@ # oauth ("/auth/oauth/authorize", {"GET"}), ("/auth/oauth/callback", {"GET"}), + # oidc + ("/auth/oidc/authorize", {"GET"}), + ("/auth/oidc/callback", {"GET"}), ] diff --git a/backend/danswer/server/manage/models.py b/backend/danswer/server/manage/models.py index da7ca7bc4e1..a5a1268eaa4 100644 --- a/backend/danswer/server/manage/models.py +++ b/backend/danswer/server/manage/models.py @@ -116,6 +116,8 @@ class SlackBotConfigCreationRequest(BaseModel): jira_title_filter: list[str] | None = None curated_response_user_title_filter: list[str] | None = None response_type: SlackBotResponseType + llm_vendor: str | None = None + llm_model_name: str | None = None @validator("answer_filters", pre=True) def validate_filters(cls, value: list[str]) -> list[str]: diff --git a/backend/danswer/server/manage/slack_bot.py b/backend/danswer/server/manage/slack_bot.py index 718fb3f86a5..6bb6709be57 100644 --- a/backend/danswer/server/manage/slack_bot.py +++ b/backend/danswer/server/manage/slack_bot.py @@ -105,6 +105,12 @@ def _form_channel_config( ] = curated_response_user_title_filter if curated_response_config: channel_config["curated_response_config"] = curated_response_config + if slack_bot_config_creation_request.llm_vendor: + channel_config["llm_vendor"] = slack_bot_config_creation_request.llm_vendor + if slack_bot_config_creation_request.llm_model_name: + channel_config[ + "llm_model_name" + ] = slack_bot_config_creation_request.llm_model_name channel_config[ "respond_to_bots" diff --git a/backend/danswer/server/settings/models.py b/backend/danswer/server/settings/models.py index 9afacf5add2..3f00eb85794 100644 --- a/backend/danswer/server/settings/models.py +++ b/backend/danswer/server/settings/models.py @@ -13,7 +13,10 @@ class Settings(BaseModel): chat_page_enabled: bool = True search_page_enabled: bool = True - default_page: PageType = PageType.SEARCH + # Fresh installs land on the chat page by default. NOTE: this only seeds + # deployments with no stored settings yet — once settings are persisted, the + # stored value wins, so flip it in Admin → Settings on existing deployments. + default_page: PageType = PageType.CHAT maximum_chat_retention_days: int | None = None def check_validity(self) -> None: diff --git a/backend/requirements/default.txt b/backend/requirements/default.txt index 33e9de5ebdb..8391736906f 100644 --- a/backend/requirements/default.txt +++ b/backend/requirements/default.txt @@ -2,6 +2,7 @@ aiohttp==3.9.4 alembic==1.10.4 asyncpg==0.27.0 atlassian-python-api==3.37.0 +bcrypt==4.0.1 # pin: passlib 1.7.4 reads bcrypt.__about__, removed in bcrypt 4.1+ beautifulsoup4==4.12.2 boto3==1.34.84 celery==5.3.4 diff --git a/darwin-kubernetes/api_server-service-deployment.yaml b/darwin-kubernetes/api_server-service-deployment.yaml index 437aec0c3df..2959e1409a2 100644 --- a/darwin-kubernetes/api_server-service-deployment.yaml +++ b/darwin-kubernetes/api_server-service-deployment.yaml @@ -53,6 +53,22 @@ spec: secretKeyRef: name: danswer-secrets key: postgres_password + # --- Microsoft / Entra ID OIDC --- + - name: OAUTH_CLIENT_ID + valueFrom: + secretKeyRef: + name: danswer-secrets + key: oauth_client_id + - name: OAUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: danswer-secrets + key: oauth_client_secret + - name: USER_AUTH_SECRET + valueFrom: + secretKeyRef: + name: danswer-secrets + key: user_auth_secret envFrom: - configMapRef: name: env-configmap diff --git a/darwin-kubernetes/env-configmap.yaml b/darwin-kubernetes/env-configmap.yaml index 01b85e9c879..eb0cfb25312 100644 --- a/darwin-kubernetes/env-configmap.yaml +++ b/darwin-kubernetes/env-configmap.yaml @@ -4,7 +4,11 @@ metadata: name: env-configmap data: # Auth Setting, also check the secrets file - AUTH_TYPE: "disabled" # Change this for production uses unless Danswer is only accessible behind VPN + AUTH_TYPE: "oidc" # Microsoft / Entra ID OIDC (oauth_client_id/secret + user_auth_secret in danswer-secrets) + # Entra OIDC discovery doc; tenant id is the path segment. + OPENID_CONFIG_URL: "https://login.microsoftonline.com/d8353d2a-b153-4d17-8827-902c51f72357/v2.0/.well-known/openid-configuration" + # Comma-separated emails granted ADMIN on first sign-in (replaces the old Istio admin allowlist). + DEFAULT_ADMIN_EMAILS: "user1@uipath.com,user2@uipath.com" ENCRYPTION_KEY_SECRET: "" # This should not be specified directly in the yaml, this is just for reference SESSION_EXPIRE_TIME_SECONDS: "86400" # 1 Day Default VALID_EMAIL_DOMAINS: "" # Can be something like danswer.ai, as an extra double-check @@ -85,6 +89,8 @@ data: LOG_VESPA_TIMING_INFORMATION: "" # Shared or Non-backend Related INTERNAL_URL: "http://api-server-service:80" # for web server - WEB_DOMAIN: "http://localhost:3000" # for web server and api server - DOMAIN: "localhost" # for nginx + # MUST be the externally-reachable https origin — builds the OIDC redirect_uri + # and makes the session cookie Secure. Mismatch => AADSTS50011 redirect error. + WEB_DOMAIN: "https://darwin.westeurope.cloudapp.azure.com" # for web server and api server + DOMAIN: "darwin.westeurope.cloudapp.azure.com" # for nginx APPLY_MIGRATIONS: "true" diff --git a/darwin-kubernetes/secrets.yaml b/darwin-kubernetes/secrets.yaml index d063c27fb9d..352ffd16d6a 100644 --- a/darwin-kubernetes/secrets.yaml +++ b/darwin-kubernetes/secrets.yaml @@ -1,11 +1,22 @@ -# The values in this file should be changed +# Real secret values must NOT be committed. Fill the placeholders below and +# apply out-of-band (or move to a sealed-secret / external secret manager). apiVersion: v1 kind: Secret metadata: name: danswer-secrets type: Opaque -data: - postgres_user: XX - postgres_password: XX - google_oauth_client_id: # You will need to provide this, use echo -n "your-client-id" | base64 - google_oauth_client_secret: # You will need to provide this, use echo -n "your-client-id" | base64 +stringData: + # --- Postgres --- + postgres_user: "postgres" + postgres_password: "" + + # --- Microsoft / Entra ID OIDC --- + # Application (client) ID — an identifier, not a secret. + oauth_client_id: "xxxx" + # Entra client secret — ROTATE the one shared in chat and paste the new one. + oauth_client_secret: "" + # Signs the fastapi-users session + OAuth state JWT. Generate once with: + # openssl rand -hex 32 + # MUST be identical across all replicas and stable across restarts/rollouts, + # or in-flight logins fail and existing sessions are invalidated. + user_auth_secret: "" diff --git a/deployment/docker_compose/docker-compose.dev.yml b/deployment/docker_compose/docker-compose.dev.yml index 3272e716843..294b22deff1 100644 --- a/deployment/docker_compose/docker-compose.dev.yml +++ b/deployment/docker_compose/docker-compose.dev.yml @@ -44,6 +44,8 @@ services: - GEN_AI_CLIENT_SECRET=${GEN_AI_CLIENT_SECRET:-} - GEN_AI_ACCOUNT_ID=${GEN_AI_ACCOUNT_ID:-} - GEN_AI_TENANT_ID=${GEN_AI_TENANT_ID:-} + - GEN_AI_VENDOR=${GEN_AI_VENDOR:-openai} + - GEN_AI_MODEL_NAME=${GEN_AI_MODEL_NAME:-gpt-4o-2024-11-20} - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} @@ -133,6 +135,8 @@ services: - GEN_AI_CLIENT_SECRET=${GEN_AI_CLIENT_SECRET:-} - GEN_AI_ACCOUNT_ID=${GEN_AI_ACCOUNT_ID:-} - GEN_AI_TENANT_ID=${GEN_AI_TENANT_ID:-} + - GEN_AI_VENDOR=${GEN_AI_VENDOR:-openai} + - GEN_AI_MODEL_NAME=${GEN_AI_MODEL_NAME:-gpt-4o-2024-11-20} - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} diff --git a/deployment/docker_compose/docker-compose.local.yml b/deployment/docker_compose/docker-compose.local.yml index 85c206622e7..408af43fb29 100644 --- a/deployment/docker_compose/docker-compose.local.yml +++ b/deployment/docker_compose/docker-compose.local.yml @@ -45,6 +45,8 @@ services: - GEN_AI_CLIENT_SECRET=${GEN_AI_CLIENT_SECRET:-} - GEN_AI_ACCOUNT_ID=${GEN_AI_ACCOUNT_ID:-} - GEN_AI_TENANT_ID=${GEN_AI_TENANT_ID:-} + - GEN_AI_VENDOR=${GEN_AI_VENDOR:-openai} + - GEN_AI_MODEL_NAME=${GEN_AI_MODEL_NAME:-gpt-4o-2024-11-20} - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} @@ -136,6 +138,8 @@ services: - GEN_AI_CLIENT_SECRET=${GEN_AI_CLIENT_SECRET:-} - GEN_AI_ACCOUNT_ID=${GEN_AI_ACCOUNT_ID:-} - GEN_AI_TENANT_ID=${GEN_AI_TENANT_ID:-} + - GEN_AI_VENDOR=${GEN_AI_VENDOR:-openai} + - GEN_AI_MODEL_NAME=${GEN_AI_MODEL_NAME:-gpt-4o-2024-11-20} - GEN_AI_API_VERSION=${GEN_AI_API_VERSION:-} - GEN_AI_LLM_PROVIDER_TYPE=${GEN_AI_LLM_PROVIDER_TYPE:-} - GEN_AI_MAX_TOKENS=${GEN_AI_MAX_TOKENS:-} diff --git a/web/next.config.js b/web/next.config.js index 1586af8d178..b6469e77204 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -22,32 +22,11 @@ const nextConfig = { ]; }, redirects: async () => { - // In production, something else (nginx in the one box setup) should take - // care of this redirect. TODO (chris): better support setups where - // web_server and api_server are on different machines. - const defaultRedirects = []; - - if (process.env.NODE_ENV === "production") return defaultRedirects; - - return defaultRedirects.concat([ - { - source: "/api/chat/send-message:params*", - destination: "http://127.0.0.1:8080/chat/send-message:params*", // Proxy to Backend - permanent: true, - }, - { - source: "/api/query/stream-answer-with-quote:params*", - destination: - "http://127.0.0.1:8080/query/stream-answer-with-quote:params*", // Proxy to Backend - permanent: true, - }, - { - source: "/api/query/stream-query-validation:params*", - destination: - "http://127.0.0.1:8080/query/stream-query-validation:params*", // Proxy to Backend - permanent: true, - }, - ]); + // Streaming endpoints previously used 308 redirects to bypass the dev + // proxy, but that strips same-origin cookies on the cross-origin hop and + // breaks cookie-based auth. Rely on the `/api/:path*` rewrite above — + // Next.js 14's rewrite proxy handles streaming responses correctly. + return []; }, publicRuntimeConfig: { version, diff --git a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx index 032e47aad00..d38baedccdf 100644 --- a/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx +++ b/web/src/app/admin/bot/SlackBotConfigCreationForm.tsx @@ -31,6 +31,10 @@ import { import { useRouter } from "next/navigation"; import { Persona } from "../assistants/interfaces"; import { useState } from "react"; +import { + LLM_MODELS_BY_VENDOR, + LLM_VENDORS, +} from "@/lib/llm/models"; import { BookmarkIcon, RobotIcon } from "@/components/icons/icons"; import { SourceIcon } from "@/components/SourceIcon"; import { getSourceMetadata } from "@/lib/sources"; @@ -109,6 +113,10 @@ export const SlackBotCreationForm = ({ ? existingSlackBotConfig.persona.id : null, response_type: existingSlackBotConfig?.response_type || "citations", + llm_vendor: + existingSlackBotConfig?.channel_config?.llm_vendor || "", + llm_model_name: + existingSlackBotConfig?.channel_config?.llm_model_name || "", prioritized_sources: existingSlackBotConfig?.channel_config?.prioritized_sources || [], jira_config: existingSlackBotConfig?.channel_config @@ -144,6 +152,8 @@ export const SlackBotCreationForm = ({ opsgenie_schedule: Yup.string(), document_sets: Yup.array().of(Yup.number()), persona_id: Yup.number().nullable(), + llm_vendor: Yup.string(), + llm_model_name: Yup.string(), prioritized_sources: Yup.array().of(Yup.string()), jira_config: Yup.object().shape({ enable_jira_integration: Yup.boolean().required(), @@ -361,6 +371,30 @@ export const SlackBotCreationForm = ({ ]} /> + ({ + name: v.label, + value: v.key, + }))} + includeDefault={true} + /> + + {values.llm_vendor && + LLM_MODELS_BY_VENDOR[values.llm_vendor] && ( + v.key === values.llm_vendor) + ?.label ?? values.llm_vendor + } model to use.`} + options={LLM_MODELS_BY_VENDOR[values.llm_vendor]} + /> + )} + When should Darwin respond? diff --git a/web/src/app/admin/bot/lib.ts b/web/src/app/admin/bot/lib.ts index 35900b46cf3..a6bee4d815b 100644 --- a/web/src/app/admin/bot/lib.ts +++ b/web/src/app/admin/bot/lib.ts @@ -32,6 +32,8 @@ interface SlackBotConfigCreationRequest { curated_response_user_title_filter?: string[]; usePersona: boolean; response_type: SlackBotResponseType; + llm_vendor?: string; + llm_model_name?: string; } const buildFiltersFromCreationRequest = ( @@ -69,6 +71,8 @@ const buildRequestBodyFromCreationRequest = ( ? { persona_id: creationRequest.persona_id } : { document_sets: creationRequest.document_sets }), response_type: creationRequest.response_type, + llm_vendor: creationRequest.llm_vendor, + llm_model_name: creationRequest.llm_model_name, }); }; diff --git a/web/src/app/chat/ChatIntro.tsx b/web/src/app/chat/ChatIntro.tsx index 926656958e2..b4cba7d4dde 100644 --- a/web/src/app/chat/ChatIntro.tsx +++ b/web/src/app/chat/ChatIntro.tsx @@ -3,7 +3,17 @@ import { ValidSources } from "@/lib/types"; import Image from "next/image"; import { Persona } from "../admin/assistants/interfaces"; import { Divider } from "@tremor/react"; -import { FiBookmark, FiCpu, FiInfo, FiX, FiZoomIn } from "react-icons/fi"; +import { + FiBookmark, + FiChevronRight, + FiCpu, + FiFilter, + FiInfo, + FiUser, + FiX, + FiZoomIn, +} from "react-icons/fi"; +import { IconType } from "react-icons"; import { HoverPopup } from "@/components/HoverPopup"; import { Modal } from "@/components/Modal"; import { useState } from "react"; @@ -26,12 +36,78 @@ function HelperItemDisplay({ ); } +function StepCard({ + icon: Icon, + title, + subtitle, + onClick, +}: { + icon: IconType; + title: string; + subtitle: string; + onClick: () => void; +}) { + return ( + + ); +} + +function OnboardingSteps({ + setConfigModalActiveTab, +}: { + setConfigModalActiveTab: (tab: string | null) => void; +}) { + return ( +
+ setConfigModalActiveTab("assistants")} + /> + setConfigModalActiveTab("filters")} + /> + setConfigModalActiveTab("llms")} + /> +
+ ); +} + export function ChatIntro({ availableSources, selectedPersona, + setConfigModalActiveTab, }: { availableSources: ValidSources[]; selectedPersona: Persona; + setConfigModalActiveTab?: (tab: string | null) => void; }) { const availableSourceMetadata = getSourceMetadataForSources(availableSources); @@ -54,6 +130,12 @@ export function ChatIntro({ + {setConfigModalActiveTab && ( + + )} + {selectedPersona && selectedPersona.num_chunks !== 0 && ( <> diff --git a/web/src/app/chat/ChatPage.tsx b/web/src/app/chat/ChatPage.tsx index 6321fa9a3ea..c9deff70cbb 100644 --- a/web/src/app/chat/ChatPage.tsx +++ b/web/src/app/chat/ChatPage.tsx @@ -49,7 +49,6 @@ import { DocumentSidebar } from "./documentSidebar/DocumentSidebar"; import { DanswerInitializingLoader } from "@/components/DanswerInitializingLoader"; import { FeedbackModal } from "./modal/FeedbackModal"; import { ShareChatSessionModal } from "./modal/ShareChatSessionModal"; -import { ChatPersonaSelector } from "./ChatPersonaSelector"; import { FiArrowDown, FiShare2 } from "react-icons/fi"; import { ChatIntro } from "./ChatIntro"; import { AIMessage, HumanMessage } from "./message/Messages"; @@ -546,6 +545,18 @@ export function ChatPage({ updateScrollTracking(); }, [messageHistory]); + // Cmd/Ctrl+K — start a new chat (matches Linear / Slack / ChatGPT convention). + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + router.push("/chat"); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [router]); + // used for resizing of the document sidebar const masterFlexboxRef = useRef(null); const [maxDocumentSidebarWidth, setMaxDocumentSidebarWidth] = useState< @@ -775,7 +786,7 @@ export function ChatPage({ queryOverride, forceSearch, - modelProvider: llmOverrideManager.llmOverride.name || undefined, + modelProvider: llmOverrideManager.llmOverride.provider || undefined, modelVersion: llmOverrideManager.llmOverride.modelName || searchParams.get(SEARCH_PARAM_NAMES.MODEL_VERSION) || @@ -973,11 +984,21 @@ export function ChatPage({ const onPersonaChange = (persona: Persona | null) => { if (persona && persona.id !== livePersona.id) { + const hadFiles = currentMessageFiles.length > 0; // remove uploaded files setCurrentMessageFiles([]); setSelectedPersona(persona); textAreaRef.current?.focus(); router.push(buildChatUrl(searchParams, null, persona.id)); + // A chat session is bound to a single assistant, so switching starts a fresh + // chat. Surface that explicitly — otherwise the user is silently navigated to + // a blank session with no explanation of why. + setPopup({ + message: + `Started a new chat with "${persona.name}", as each chat is bound to a single assistant.` + + (hadFiles ? " Please re-upload any files you'd attached." : ""), + type: "success", + }); } }; @@ -1173,15 +1194,6 @@ export function ChatPage({ {livePersona && (
-
- -
-
{chatSessionIdRef.current !== null && (
)} diff --git a/web/src/app/chat/ChatPersonaSelector.tsx b/web/src/app/chat/ChatPersonaSelector.tsx index 5984ab36e47..41d8d0fae3c 100644 --- a/web/src/app/chat/ChatPersonaSelector.tsx +++ b/web/src/app/chat/ChatPersonaSelector.tsx @@ -1,10 +1,23 @@ import { Persona } from "@/app/admin/assistants/interfaces"; -import { FiCheck, FiChevronDown, FiPlusSquare, FiEdit2 } from "react-icons/fi"; +import { FiCheck, FiChevronDown, FiPlusSquare, FiEdit2, FiSearch } from "react-icons/fi"; import { CustomDropdown, DefaultDropdownElement } from "@/components/Dropdown"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { checkUserIdOwnsAssistant } from "@/lib/assistants/checkOwnership"; +// The default persona (id=0 in personas.yaml) has no document_set scope and +// behaves like a generic search-everything mode. Surface it as "Search" in +// the trigger so users don't have to think about it as an "assistant." +const DEFAULT_PERSONA_ID = 0; + +function isSearchModePersona(persona: Persona | undefined): boolean { + return ( + !!persona && + persona.id === DEFAULT_PERSONA_ID && + (persona.document_sets?.length ?? 0) === 0 + ); +} + function PersonaItem({ id, name, @@ -130,10 +143,15 @@ export function ChatPersonaSelector({
} > -
-
- {currentlySelectedPersona?.name || "Default"} -
+
+ {isSearchModePersona(currentlySelectedPersona) ? ( + <> + + Search + + ) : ( + {currentlySelectedPersona?.name || "Default"} + )}
diff --git a/web/src/app/chat/input/ChatInputBar.tsx b/web/src/app/chat/input/ChatInputBar.tsx index 5664fb76584..3d5ff9dd968 100644 --- a/web/src/app/chat/input/ChatInputBar.tsx +++ b/web/src/app/chat/input/ChatInputBar.tsx @@ -21,6 +21,7 @@ import { FilterManager, LlmOverrideManager } from "@/lib/hooks"; import { SelectedFilterDisplay } from "./SelectedFilterDisplay"; import { useChatContext } from "@/components/context/ChatContext"; import { getFinalLLM } from "@/lib/llm/utils"; +import { getModelDisplayName } from "@/lib/llm/models"; import { FileDescriptor } from "../interfaces"; import { InputBarPreview } from "../files/InputBarPreview"; import { RobotIcon } from "@/components/icons/icons"; @@ -342,8 +343,9 @@ export function ChatInputBar({ resize-none pl-4 pr-12 - py-4 - h-14 + py-5 + text-base + min-h-[88px] `} autoFocus style={{ scrollbarWidth: "thin" }} @@ -375,7 +377,10 @@ export function ChatInputBar({ (
{children} - +
); @@ -63,15 +70,12 @@ export function SelectedFilterDisplay({ setSelectedSources, selectedDocumentSets, setSelectedDocumentSets, - selectedTags, - setSelectedTags, } = filterManager; const anyFilters = timeRange !== null || selectedSources.length > 0 || - selectedDocumentSets.length > 0 || - selectedTags.length > 0; + selectedDocumentSets.length > 0; if (!anyFilters) { return null; @@ -79,7 +83,7 @@ export function SelectedFilterDisplay({ return (
-
+
{timeRange && (timeRange.selectValue || timeRange.from || timeRange.to) && ( setTimeRange(null)}> @@ -121,31 +125,6 @@ export function SelectedFilterDisplay({ ))} - {selectedTags.length > 0 && - selectedTags.map((tag) => ( - - setSelectedTags((prevTags) => - prevTags.filter( - (t) => - t.tag_key !== tag.tag_key || t.tag_value !== tag.tag_value - ) - ) - } - > - <> -
- -
- - {tag.tag_key} - = - {tag.tag_value} - - -
- ))}
); diff --git a/web/src/app/chat/message/Messages.tsx b/web/src/app/chat/message/Messages.tsx index 1ed1ffdd1de..84cbe2e2368 100644 --- a/web/src/app/chat/message/Messages.tsx +++ b/web/src/app/chat/message/Messages.tsx @@ -167,7 +167,7 @@ export const AIMessage = ({ ) : undefined; return ( -
+
diff --git a/web/src/app/chat/modal/configuration/AssistantsTab.tsx b/web/src/app/chat/modal/configuration/AssistantsTab.tsx index 220adc25040..08589144abd 100644 --- a/web/src/app/chat/modal/configuration/AssistantsTab.tsx +++ b/web/src/app/chat/modal/configuration/AssistantsTab.tsx @@ -1,26 +1,20 @@ import { Persona } from "@/app/admin/assistants/interfaces"; -import { LLMProviderDescriptor } from "@/app/admin/models/llm/interfaces"; import { Bubble } from "@/components/Bubble"; import { AssistantIcon } from "@/components/assistants/AssistantIcon"; -import { getFinalLLM } from "@/lib/llm/utils"; import React from "react"; import { FiBookmark, FiImage, FiSearch } from "react-icons/fi"; interface AssistantsTabProps { selectedAssistant: Persona; availableAssistants: Persona[]; - llmProviders: LLMProviderDescriptor[]; onSelect: (assistant: Persona) => void; } export function AssistantsTab({ selectedAssistant, availableAssistants, - llmProviders, onSelect, }: AssistantsTabProps) { - const [_, llmName] = getFinalLLM(llmProviders, null, null); - return ( <>

Choose Assistant

@@ -29,11 +23,11 @@ export function AssistantsTab({
{assistant.description}
-
- {assistant.document_sets.length > 0 && ( -
-

Document Sets:

- {assistant.document_sets.map((set) => ( - -
- - {set.name} -
-
- ))} -
- )} -
- Default Model:{" "} - {assistant.llm_model_version_override || llmName} + {assistant.document_sets.length > 0 && ( +
+

Document Sets:

+ {assistant.document_sets.map((set) => ( + +
+ + {set.name} +
+
+ ))}
-
+ )}
))}
diff --git a/web/src/app/chat/modal/configuration/ConfigurationModal.tsx b/web/src/app/chat/modal/configuration/ConfigurationModal.tsx index cd54ac25619..9cd92880613 100644 --- a/web/src/app/chat/modal/configuration/ConfigurationModal.tsx +++ b/web/src/app/chat/modal/configuration/ConfigurationModal.tsx @@ -161,7 +161,6 @@ export function ConfigurationModal({
{ setSelectedAssistant(assistant); diff --git a/web/src/app/chat/modal/configuration/FiltersTab.tsx b/web/src/app/chat/modal/configuration/FiltersTab.tsx index 581c15cced2..86adbbfcc2c 100644 --- a/web/src/app/chat/modal/configuration/FiltersTab.tsx +++ b/web/src/app/chat/modal/configuration/FiltersTab.tsx @@ -1,7 +1,7 @@ import { useChatContext } from "@/components/context/ChatContext"; import { FilterManager } from "@/lib/hooks"; import { listSourceMetadata } from "@/lib/sources"; -import { useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { DateRangePicker, DateRangePickerItem, @@ -11,18 +11,29 @@ import { import { getXDaysAgo } from "@/lib/dateUtils"; import { DocumentSetSelectable } from "@/components/documentSet/DocumentSetSelectable"; import { Bubble } from "@/components/Bubble"; -import { FiX } from "react-icons/fi"; export function FiltersTab({ filterManager, }: { filterManager: FilterManager; }): JSX.Element { - const [filterValue, setFilterValue] = useState(""); - const inputRef = useRef(null); + const { availableSources, availableDocumentSets } = useChatContext(); + const [docSetFilter, setDocSetFilter] = useState(""); + const docSetInputRef = useRef(null); - const { availableSources, availableDocumentSets, availableTags } = - useChatContext(); + useEffect(() => { + if (docSetInputRef.current) { + docSetInputRef.current.focus(); + } + }, []); + + const filteredDocumentSets = useMemo(() => { + const q = docSetFilter.trim().toLowerCase(); + if (!q) return availableDocumentSets; + return availableDocumentSets.filter((set) => + set.name.toLowerCase().includes(q) + ); + }, [availableDocumentSets, docSetFilter]); const allSources = listSourceMetadata(); const availableSourceMetadata = allSources.filter((source) => @@ -93,30 +104,62 @@ export function FiltersTab({ Choose which knowledge sets we should search over. If multiple are selected, we will search through all of them. -
    - {availableDocumentSets.length > 0 ? ( - availableDocumentSets.map((set) => { - const isSelected = - filterManager.selectedDocumentSets.includes(set.name); - return ( - - filterManager.setSelectedDocumentSets((prev) => - isSelected - ? prev.filter((s) => s !== set.name) - : [...prev, set.name] - ) + + {availableDocumentSets.length > 0 ? ( + <> +
    + setDocSetFilter(e.target.value)} + /> + {filterManager.selectedDocumentSets.length > 0 && ( + + )} +
    + +
      + {filteredDocumentSets.length > 0 ? ( + filteredDocumentSets.map((set) => { + const isSelected = + filterManager.selectedDocumentSets.includes(set.name); + return ( + + filterManager.setSelectedDocumentSets((prev) => + isSelected + ? prev.filter((s) => s !== set.name) + : [...prev, set.name] + ) + } + /> + ); + }) + ) : ( +
    • + No matching knowledge sets +
    • + )} +
    + + ) : ( +
    • No knowledge sets available
    • - )} -
    +
+ )}
@@ -164,95 +207,6 @@ export function FiltersTab({
- - -
-

Tags

-
    - {filterManager.selectedTags.length > 0 ? ( - filterManager.selectedTags.map((tag) => ( - - filterManager.setSelectedTags((prev) => - prev.filter( - (t) => - t.tag_key !== tag.tag_key || - t.tag_value !== tag.tag_value - ) - ) - } - > -
    -

    - {tag.tag_key}={tag.tag_value} -

    {" "} - -
    -
    - )) - ) : ( -

    No selected tags

    - )} -
- -
-
-
- setFilterValue(event.target.value)} - /> -
- -
- {availableTags.length > 0 ? ( - availableTags - .filter( - (tag) => - !filterManager.selectedTags.some( - (selectedTag) => - selectedTag.tag_key === tag.tag_key && - selectedTag.tag_value === tag.tag_value - ) && - (tag.tag_key.includes(filterValue) || - tag.tag_value.includes(filterValue)) - ) - .slice(0, 12) - .map((tag) => ( - - filterManager.setSelectedTags((prev) => - filterManager.selectedTags.includes(tag) - ? prev.filter( - (t) => - t.tag_key !== tag.tag_key || - t.tag_value !== tag.tag_value - ) - : [...prev, tag] - ) - } - > - <> - {tag.tag_key}={tag.tag_value} - - - )) - ) : ( -
- No matching tags found -
- )} -
-
-
-
diff --git a/web/src/app/chat/modal/configuration/LlmTab.tsx b/web/src/app/chat/modal/configuration/LlmTab.tsx index 79dd5d82f76..ca5697fdff9 100644 --- a/web/src/app/chat/modal/configuration/LlmTab.tsx +++ b/web/src/app/chat/modal/configuration/LlmTab.tsx @@ -1,13 +1,12 @@ -import { useChatContext } from "@/components/context/ChatContext"; -import { LlmOverride, LlmOverrideManager } from "@/lib/hooks"; -import React, { useCallback, useRef, useState } from "react"; +import { LlmOverrideManager } from "@/lib/hooks"; +import React, { useCallback, useState } from "react"; import { debounce } from "lodash"; import { DefaultDropdown } from "@/components/Dropdown"; import { Text } from "@tremor/react"; import { Persona } from "@/app/admin/assistants/interfaces"; -import { destructureValue, getFinalLLM, structureValue } from "@/lib/llm/utils"; +import { destructureValue, structureValue } from "@/lib/llm/utils"; import { updateModelOverrideForChatSession } from "../../lib"; -import { PopupSpec } from "@/components/admin/connectors/Popup"; +import { FLAT_LLM_MODELS } from "@/lib/llm/models"; export function LlmTab({ llmOverrideManager, @@ -18,7 +17,6 @@ export function LlmTab({ currentAssistant: Persona; chatSessionId?: number; }) { - const { llmProviders } = useChatContext(); const { llmOverride, setLlmOverride, temperature, setTemperature } = llmOverrideManager; @@ -38,32 +36,17 @@ export function LlmTab({ debouncedSetTemperature(value); }; - const [_, defaultLlmName] = getFinalLLM(llmProviders, currentAssistant, null); - - const llmOptions: { name: string; value: string }[] = []; - llmProviders.forEach((llmProvider) => { - llmProvider.model_names.forEach((modelName) => { - llmOptions.push({ - name: modelName, - value: structureValue( - llmProvider.name, - llmProvider.provider, - modelName - ), - }); - }); - }); + const llmOptions = FLAT_LLM_MODELS.map((m) => ({ + name: `${m.vendorLabel} — ${m.modelLabel}`, + value: structureValue(m.vendorLabel, m.vendorKey, m.modelId), + })); return (
- - Override the default model for the{" "} - {currentAssistant.name} assistant. The - override will apply only for this chat session. - - Default Model: {defaultLlmName}. + Select the model to use for this chat session. The selection applies + only to this session.
@@ -96,9 +79,9 @@ export function LlmTab({ type="range" onChange={(e) => handleTemperatureChange(parseFloat(e.target.value))} className=" - w-full - p-2 - border + w-full + p-2 + border border-border rounded-md " diff --git a/web/src/app/chat/modifiers/ChatFilters.tsx b/web/src/app/chat/modifiers/ChatFilters.tsx index 61c45f12d32..7ce8d656d08 100644 --- a/web/src/app/chat/modifiers/ChatFilters.tsx +++ b/web/src/app/chat/modifiers/ChatFilters.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { DocumentSet, Tag, ValidSources } from "@/lib/types"; +import { DocumentSet, ValidSources } from "@/lib/types"; import { SourceMetadata } from "@/lib/search/interfaces"; import { FiBook, @@ -7,7 +7,6 @@ import { FiCalendar, FiFilter, FiMap, - FiTag, FiX, } from "react-icons/fi"; import { DateRangePickerValue } from "@tremor/react"; @@ -17,13 +16,11 @@ import { BasicClickable } from "@/components/BasicClickable"; import { ControlledPopup, DefaultDropdownElement } from "@/components/Dropdown"; import { getXDaysAgo } from "@/lib/dateUtils"; import { SourceSelectorProps } from "@/components/search/filtering/Filters"; -import { containsObject, objectsAreEquivalent } from "@/lib/contains"; enum FilterType { Source = "Source", KnowledgeSet = "Knowledge Set", TimeRange = "Time Range", - Tag = "Tag", } function SelectedBubble({ @@ -51,12 +48,10 @@ function SelectFilterType({ onSelect, hasSources, hasKnowledgeSets, - hasTags, }: { onSelect: (filterType: FilterType) => void; hasSources: boolean; hasKnowledgeSets: boolean; - hasTags: boolean; }) { return (
@@ -80,16 +75,6 @@ function SelectFilterType({ /> )} - {hasTags && ( - onSelect(FilterType.Tag)} - isSelected={false} - /> - )} - void; }) { + const [filterValue, setFilterValue] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + if (inputRef.current) { + inputRef.current.focus(); + } + }, []); + + const filterValueLower = filterValue.toLowerCase(); + const filteredDocumentSets = filterValueLower + ? documentSets.filter((ds) => + ds.name.toLowerCase().includes(filterValueLower) + ) + : documentSets; + return ( -
- {documentSets.map((documentSet) => ( - onSelect(documentSet.name)} - isSelected={selectedDocumentSets.includes(documentSet.name)} - includeCheckbox +
+
+ {filteredDocumentSets.length > 0 ? ( + filteredDocumentSets.map((documentSet) => ( + onSelect(documentSet.name)} + isSelected={selectedDocumentSets.includes(documentSet.name)} + includeCheckbox + /> + )) + ) : ( +
+ No matching knowledge sets +
+ )} +
+ +
+ setFilterValue(event.target.value)} /> - ))} +
); } @@ -206,70 +225,6 @@ function TimeRangeSection({ ); } -function TagsSection({ - availableTags, - selectedTags, - onSelect, -}: { - availableTags: Tag[]; - selectedTags: Tag[]; - onSelect: (tag: Tag) => void; -}) { - const [filterValue, setFilterValue] = useState(""); - const inputRef = useRef(null); - - useEffect(() => { - if (inputRef.current) { - inputRef.current.focus(); - } - }, []); - - const filterValueLower = filterValue.toLowerCase(); - const filteredTags = filterValueLower - ? availableTags.filter( - (tags) => - tags.tag_value.toLowerCase().startsWith(filterValueLower) || - tags.tag_key.toLowerCase().startsWith(filterValueLower) - ) - : availableTags; - - return ( -
-
- {filteredTags.length > 0 ? ( - filteredTags.map((tag) => ( - - {tag.tag_key} - = - {tag.tag_value} -
- } - onSelect={() => onSelect(tag)} - isSelected={selectedTags.includes(tag)} - includeCheckbox - /> - )) - ) : ( -
No matching tags found
- )} -
- -
- setFilterValue(event.target.value)} - /> -
-
- ); -} - export function ChatFilters({ timeRange, setTimeRange, @@ -277,11 +232,8 @@ export function ChatFilters({ setSelectedSources, selectedDocumentSets, setSelectedDocumentSets, - selectedTags, - setSelectedTags, availableDocumentSets, existingSources, - availableTags, }: SourceSelectorProps) { const [filtersOpen, setFiltersOpen] = useState(false); const handleFiltersToggle = (value: boolean) => { @@ -312,16 +264,6 @@ export function ChatFilters({ }); }; - const handleTagToggle = (tag: Tag) => { - setSelectedTags((prev) => { - if (containsObject(prev, tag)) { - return prev.filter((t) => !objectsAreEquivalent(t, tag)); - } else { - return [...prev, tag]; - } - }); - }; - const allSources = listSourceMetadata(); const availableSources = allSources.filter((source) => existingSources.includes(source.internalName) @@ -354,21 +296,12 @@ export function ChatFilters({ }} /> ); - } else if (selectedFilterType === FilterType.Tag) { - popupDisplay = ( - - ); } else { popupDisplay = ( setSelectedFilterType(filterType)} hasSources={availableSources.length > 0} hasKnowledgeSets={availableDocumentSets.length > 0} - hasTags={availableTags.length > 0} /> ); } @@ -427,25 +360,6 @@ export function ChatFilters({ ))} - - {selectedTags.length > 0 && - selectedTags.map((tag) => ( - handleTagToggle(tag)} - > - <> -
- -
- - {tag.tag_key} - = - {tag.tag_value} - - -
- ))}
diff --git a/web/src/app/chat/sessionSidebar/ChatSessionDisplay.tsx b/web/src/app/chat/sessionSidebar/ChatSessionDisplay.tsx index 87657d76192..782b35a8c66 100644 --- a/web/src/app/chat/sessionSidebar/ChatSessionDisplay.tsx +++ b/web/src/app/chat/sessionSidebar/ChatSessionDisplay.tsx @@ -19,6 +19,7 @@ import { DefaultDropdownElement } from "@/components/Dropdown"; import { Popover } from "@/components/popover/Popover"; import { ShareChatSessionModal } from "../modal/ShareChatSessionModal"; import { CHAT_SESSION_ID_KEY, FOLDER_ID_KEY } from "@/lib/drag/constants"; +import { timeAgo } from "@/lib/time"; export function ChatSessionDisplay({ chatSession, @@ -120,9 +121,16 @@ export function ChatSessionDisplay({ className="-my-px px-1 mr-2 w-full rounded" /> ) : ( -

- {chatName || `Chat ${chatSession.id}`} -

+
+

+ {chatName || `Chat ${chatSession.id}`} +

+ {chatSession.time_created && ( +

+ {timeAgo(chatSession.time_created)} +

+ )} +
)} {isSelected && (isRenamingChat ? ( diff --git a/web/src/app/chat/sessionSidebar/ChatSidebar.tsx b/web/src/app/chat/sessionSidebar/ChatSidebar.tsx index e9207488e06..c145f7b4b8a 100644 --- a/web/src/app/chat/sessionSidebar/ChatSidebar.tsx +++ b/web/src/app/chat/sessionSidebar/ChatSidebar.tsx @@ -72,12 +72,7 @@ export const ChatSidebar = ({ id="chat-sidebar" >
- +
diff --git a/web/src/components/UserDropdown.tsx b/web/src/components/UserDropdown.tsx index 5ddbd9101ea..b62dc8230c1 100644 --- a/web/src/components/UserDropdown.tsx +++ b/web/src/components/UserDropdown.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useRef, useContext } from "react"; -import { FiSearch, FiMessageSquare, FiTool, FiLogOut } from "react-icons/fi"; +import { FiMessageSquare, FiTool, FiLogOut } from "react-icons/fi"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { User } from "@/lib/types"; @@ -81,15 +81,6 @@ export function UserDropdown({ > {!hideChatAndSearch && ( <> - {settings.search_page_enabled && ( - - - Darwin Search - - )} {settings.chat_page_enabled && ( <>
- +
@@ -58,30 +53,14 @@ export function Header({ user }: HeaderProps) {
- {(!settings || - (settings.search_page_enabled && settings.chat_page_enabled)) && ( - <> - -
-
- -

Search

-
-
- - - -
-
- -

Chat

-
-
- - + {(!settings || settings.chat_page_enabled) && ( + + + Chat + )}
diff --git a/web/src/components/search/DocumentDisplay.tsx b/web/src/components/search/DocumentDisplay.tsx index 4df96b67bdc..068bfa993c5 100644 --- a/web/src/components/search/DocumentDisplay.tsx +++ b/web/src/components/search/DocumentDisplay.tsx @@ -55,6 +55,15 @@ export const buildDocumentSummaryDisplay = ( } }); + // Every highlight may have been empty/whitespace (e.g. match_highlights == + // [""]), leaving `sections` empty. The length===0 guard above only catches + // an empty array, not an array of falsy strings — so guard again here and + // fall back to the blurb, matching how Slack's + // translate_vespa_highlight_to_slack handles the same case. + if (sections.length === 0) { + return blurb; + } + let previousIsContinuation = sections[0][2]; let previousIsBold = sections[0][1]; let currentText = ""; diff --git a/web/src/lib/llm/models.ts b/web/src/lib/llm/models.ts new file mode 100644 index 00000000000..0b7d6f06e25 --- /dev/null +++ b/web/src/lib/llm/models.ts @@ -0,0 +1,55 @@ +export interface VendorOption { + key: string; + label: string; +} + +export interface ModelOption { + name: string; + value: string; +} + +export const LLM_VENDORS: VendorOption[] = [ + { key: "openai", label: "OpenAI (GPT)" }, + { key: "awsbedrock", label: "AWS Bedrock (Claude)" }, +]; + +export const LLM_MODELS_BY_VENDOR: Record = { + openai: [ + { name: "GPT-4o (2024-11-20)", value: "gpt-4o-2024-11-20" }, + { name: "GPT-4.1 Mini", value: "gpt-4.1-mini-2025-04-14" }, + ], + awsbedrock: [ + { + name: "Claude Sonnet 4.5", + value: "anthropic.claude-sonnet-4-5-20250929-v1:0", + }, + ], +}; + +export interface FlatModelOption { + vendorKey: string; + vendorLabel: string; + modelId: string; + modelLabel: string; +} + +export const FLAT_LLM_MODELS: FlatModelOption[] = LLM_VENDORS.flatMap((vendor) => + (LLM_MODELS_BY_VENDOR[vendor.key] ?? []).map((m) => ({ + vendorKey: vendor.key, + vendorLabel: vendor.label, + modelId: m.value, + modelLabel: m.name, + })) +); + +export function getModelDisplayName( + vendorKey: string | undefined, + modelId: string | undefined +): string | null { + if (!modelId) return null; + const match = FLAT_LLM_MODELS.find( + (m) => + m.modelId === modelId && (!vendorKey || m.vendorKey === vendorKey) + ); + return match ? match.modelLabel : modelId; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index dd5adb9685c..eff795fac25 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -590,6 +590,8 @@ export interface ChannelConfig { enable_curated_response_integration?: boolean; response_message?: string; }; + llm_vendor?: string; + llm_model_name?: string; } export type SlackBotResponseType = "quotes" | "citations";