Merge branch 'main' into tui-optional-openai-key

This commit is contained in:
Sebastián Estévez 2025-10-31 15:54:18 -04:00 committed by GitHub
commit 28f417ab5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 3103 additions and 1433 deletions

View file

@ -13,7 +13,7 @@
OpenRAG is a comprehensive Retrieval-Augmented Generation platform that enables intelligent document search and AI-powered conversations. Users can upload, process, and query documents through a chat interface backed by large language models and semantic search capabilities. The system utilizes Langflow for document ingestion, retrieval workflows, and intelligent nudges, providing a seamless RAG experience. Built with [Starlette](https://github.com/Kludex/starlette) and [Next.js](https://github.com/vercel/next.js). Powered by [OpenSearch](https://github.com/opensearch-project/OpenSearch), [Langflow](https://github.com/langflow-ai/langflow), and [Docling](https://github.com/docling-project/docling).
<a href="https://deepwiki.com/phact/openrag"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<a href="https://deepwiki.com/langflow-ai/openrag"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
</div>
<div align="center">
@ -26,11 +26,17 @@ OpenRAG is a comprehensive Retrieval-Augmented Generation platform that enables
## Quickstart
Use the OpenRAG Terminal User Interface (TUI) to manage your OpenRAG installation without complex command-line operations.
To quickly run OpenRAG without creating or modifying any project files, use `uvx`:
To quickly install and start OpenRAG, run `uvx openrag`.
```bash
uvx openrag
```
This runs OpenRAG without installing it to your project or globally.
To run a specific version of OpenRAG, add the version to the command, such as: `uvx --from openrag==0.1.25 openrag`.
To first set up a project and then install OpenRAG, do the following:
## Install Python package
To first set up a project and then install the OpenRAG Python package, do the following:
1. Create a new project with a virtual environment using `uv init`.
@ -42,17 +48,22 @@ To first set up a project and then install OpenRAG, do the following:
The `(venv)` prompt doesn't change, but `uv` commands will automatically use the project's virtual environment.
For more information on virtual environments, see the [uv documentation](https://docs.astral.sh/uv/pip/environments).
2. Ensure all dependencies are installed and updated in your virtual environment.
2. Add OpenRAG to your project.
```bash
uv sync
uv add openrag
```
3. Install and start the OpenRAG TUI.
To add a specific version of OpenRAG:
```bash
uvx openrag
uv add openrag==0.1.25
```
To install a specific version of the Langflow package, add the required version to the command, such as `uvx --from openrag==0.1.25 openrag`.
3. Start the OpenRAG TUI.
```bash
uv run openrag
```
4. Continue with the [Quickstart](https://docs.openr.ag/quickstart).
For the full TUI installation guide, see [TUI](https://docs.openr.ag/install).

View file

@ -3,7 +3,9 @@ import TabItem from '@theme/TabItem';
## Application onboarding
The first time you start OpenRAG, whether using the TUI or a `.env` file, you must complete application onboarding.
The first time you start OpenRAG, whether using the TUI or a `.env` file, it's recommended that you complete application onboarding.
To skip onboarding, click **Skip onboarding**.
Values from onboarding can be changed later in the OpenRAG **Settings** page.
@ -17,17 +19,19 @@ Choose one LLM provider and complete only those steps:
3. To load 2 sample PDFs, enable **Sample dataset**.
This is recommended, but not required.
4. Click **Complete**.
5. Continue with the [Quickstart](/quickstart).
5. To complete the onboarding tasks, click **What is OpenRAG**, and then click **Add a Document**.
6. Continue with the [Quickstart](/quickstart).
</TabItem>
<TabItem value="IBM watsonx.ai" label="IBM watsonx.ai">
1. Complete the fields for **watsonx.ai API Endpoint**, **IBM API key**, and **IBM Project ID**.
1. Complete the fields for **watsonx.ai API Endpoint**, **IBM Project ID**, and **IBM API key**.
These values are found in your IBM watsonx deployment.
2. Under **Advanced settings**, select your **Embedding Model** and **Language Model**.
3. To load 2 sample PDFs, enable **Sample dataset**.
This is recommended, but not required.
4. Click **Complete**.
5. Continue with the [Quickstart](/quickstart).
5. To complete the onboarding tasks, click **What is OpenRAG**, and then click **Add a Document**.
6. Continue with the [Quickstart](/quickstart).
</TabItem>
<TabItem value="Ollama" label="Ollama">
@ -42,6 +46,7 @@ Choose one LLM provider and complete only those steps:
3. To load 2 sample PDFs, enable **Sample dataset**.
This is recommended, but not required.
4. Click **Complete**.
5. Continue with the [Quickstart](/quickstart).
5. To complete the onboarding tasks, click **What is OpenRAG**, and then click **Add a Document**.
6. Continue with the [Quickstart](/quickstart).
</TabItem>
</Tabs>

View file

@ -52,7 +52,7 @@ This filter is the [Knowledge filter](/knowledge#create-knowledge-filters), and
For an example of changing out the agent's language model in OpenRAG, see the [Quickstart](/quickstart#change-components).
To restore the flow to its initial state, in OpenRAG, click <Icon name="Settings" aria-hidden="true"/> **Settings**, and then click **Restore Flow**.
To restore the flow to its initial state, in OpenRAG, click <Icon name="Settings2" aria-hidden="true"/> **Settings**, and then click **Restore Flow**.
OpenRAG warns you that this discards all custom settings. Click **Restore** to restore the flow.
## Additional Langflow functionality

View file

@ -8,21 +8,23 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import PartialModifyFlows from '@site/docs/_partial-modify-flows.mdx';
OpenRAG uses [Docling](https://docling-project.github.io/docling/) for its document ingestion pipeline.
OpenRAG uses [Docling](https://docling-project.github.io/docling/) for document ingestion.
More specifically, OpenRAG uses [Docling Serve](https://github.com/docling-project/docling-serve), which starts a `docling serve` process on your local machine and runs Docling ingestion through an API service.
Docling ingests documents from your local machine or OAuth connectors, splits them into chunks, and stores them as separate, structured documents in the OpenSearch `documents` index.
OpenRAG chose Docling for its support for a wide variety of file formats, high performance, and advanced understanding of tables and images.
## Docling ingestion settings
To modify OpenRAG's ingestion settings, including the Docling settings and ingestion flows, click 2" aria-hidden="true"/> **Settings**.
## Knowledge ingestion settings
These settings configure the Docling ingestion parameters.
OpenRAG will warn you if `docling serve` is not running.
To start or stop `docling serve` or any other native services, in the TUI main menu, click **Start Native Services** or **Stop Native Services**.
**Embedding model** determines which AI model is used to create vector embeddings. The default is `text-embedding-3-small`.
**Embedding model** determines which AI model is used to create vector embeddings. The default is the OpenAI `text-embedding-3-small` model.
**Chunk size** determines how large each text chunk is in number of characters.
Larger chunks yield more context per chunk, but may include irrelevant information. Smaller chunks yield more precise semantic search, but may lack context.
@ -32,6 +34,8 @@ The default value of `1000` characters provides a good starting point that balan
Use larger overlap values for documents where context is most important, and use smaller overlap values for simpler documents, or when optimization is most important.
The default value of 200 characters of overlap with a chunk size of 1000 (20% overlap) is suitable for general use cases. Decrease the overlap to 10% for a more efficient pipeline, or increase to 40% for more complex documents.
**Table Structure** enables Docling's [`DocumentConverter`](https://docling-project.github.io/docling/reference/document_converter/) tool for parsing tables. Instead of treating tables as plain text, tables are output as structured table data with preserved relationships and metadata. **Table Structure** is enabled by default.
**OCR** enables or disabled OCR processing when extracting text from images and scanned documents.
OCR is disabled by default. This setting is best suited for processing text-based documents as quickly as possible with Docling's [`DocumentConverter`](https://docling-project.github.io/docling/reference/document_converter/). Images are ignored and not processed.
@ -41,14 +45,6 @@ If OpenRAG detects that the local machine is running on macOS, OpenRAG uses the
**Picture descriptions** adds image descriptions generated by the [SmolVLM-256M-Instruct](https://huggingface.co/HuggingFaceTB/SmolVLM-Instruct) model to OCR processing. Enabling picture descriptions can slow ingestion performance.
## Use OpenRAG default ingestion instead of Docling serve
If you want to use OpenRAG's built-in pipeline instead of Docling serve, set `DISABLE_INGEST_WITH_LANGFLOW=true` in [Environment variables](/reference/configuration#document-processing).
The built-in pipeline still uses the Docling processor, but uses it directly without the Docling Serve API.
For more information, see [`processors.py` in the OpenRAG repository](https://github.com/langflow-ai/openrag/blob/main/src/models/processors.py#L58).
## Knowledge ingestion flows
[Flows](https://docs.langflow.org/concepts-overview) in Langflow are functional representations of application workflows, with multiple [component](https://docs.langflow.org/concepts-components) nodes connected as single steps in a workflow.
@ -74,4 +70,12 @@ An additional knowledge ingestion flow is included in OpenRAG, where it is used
The agent calls this component to fetch web content, and the results are ingested into OpenSearch.
For more on using MCP clients in Langflow, see [MCP clients](https://docs.langflow.org/mcp-client).\
To connect additional MCP servers to the MCP client, see [Connect to MCP servers from your application](https://docs.langflow.org/mcp-tutorial).
To connect additional MCP servers to the MCP client, see [Connect to MCP servers from your application](https://docs.langflow.org/mcp-tutorial).
## Use OpenRAG default ingestion instead of Docling serve
If you want to use OpenRAG's built-in pipeline instead of Docling serve, set `DISABLE_INGEST_WITH_LANGFLOW=true` in [Environment variables](/reference/configuration#document-processing).
The built-in pipeline still uses the Docling processor, but uses it directly without the Docling Serve API.
For more information, see [`processors.py` in the OpenRAG repository](https://github.com/langflow-ai/openrag/blob/main/src/models/processors.py#L58).

View file

@ -31,12 +31,14 @@ The **Knowledge Ingest** flow uses Langflow's [**File** component](https://docs.
The default path to your local folder is mounted from the `./documents` folder in your OpenRAG project directory to the `/app/documents/` directory inside the Docker container. Files added to the host or the container will be visible in both locations. To configure this location, modify the **Documents Paths** variable in either the TUI's [Advanced Setup](/install#setup) menu or in the `.env` used by Docker Compose.
To load and process a single file from the mapped location, click <Icon name="Plus" aria-hidden="true"/> **Add Knowledge**, and then click **Add File**.
To load and process a single file from the mapped location, click **Add Knowledge**, and then click <Icon name="File" aria-hidden="true"/> **File**.
The file is loaded into your OpenSearch database, and appears in the Knowledge page.
To load and process a directory from the mapped location, click <Icon name="Plus" aria-hidden="true"/> **Add Knowledge**, and then click **Process Folder**.
To load and process a directory from the mapped location, click **Add Knowledge**, and then click <Icon name="Folder" aria-hidden="true"/> **Folder**.
The files are loaded into your OpenSearch database, and appear in the Knowledge page.
To add files directly to a chat session, click <Icon name="Plus" aria-hidden="true"/> in the chat input and select the files you want to include. Files added this way are processed and made available to the agent for the current conversation, and are not permanently added to the knowledge base.
### Ingest files through OAuth connectors {#oauth-ingestion}
OpenRAG supports Google Drive, OneDrive, and Sharepoint as OAuth connectors for seamless document synchronization.
@ -61,11 +63,11 @@ If you wish to use another provider, add the secrets to another provider.
<TabItem value=".env" label=".env">
1. Stop the Docker deployment.
2. Add the OAuth provider's client and secret key in the `.env` file for Docker Compose.
```bash
GOOGLE_OAUTH_CLIENT_ID='YOUR_OAUTH_CLIENT_ID'
GOOGLE_OAUTH_CLIENT_SECRET='YOUR_OAUTH_CLIENT_SECRET'
```
3. Save your `.env`. file.
```bash
GOOGLE_OAUTH_CLIENT_ID='YOUR_OAUTH_CLIENT_ID'
GOOGLE_OAUTH_CLIENT_SECRET='YOUR_OAUTH_CLIENT_SECRET'
```
3. Save your `.env` file.
4. Start the Docker deployment.
</TabItem>
</Tabs>
@ -75,11 +77,11 @@ A successful authentication opens OpenRAG with the required scopes for your conn
To add knowledge from an OAuth-connected storage provider, do the following:
1. Click <Icon name="Plus" aria-hidden="true"/> **Add Knowledge**, and then select the storage provider, for example, **Google Drive**.
1. Click **Add Knowledge**, and then select the storage provider, for example, **Google Drive**.
The **Add Cloud Knowledge** page opens.
2. To add files or folders from the connected storage, click <Icon name="Plus" aria-hidden="true"/> **Add Files**.
2. To add files or folders from the connected storage, click **Add Files**.
Select the files or folders you want and click **Select**.
You can select multiples.
You can select multiple files.
3. When your files are selected, click **Ingest Files**.
The ingestion process may take some time, depending on the size of your documents.
4. When ingestion is complete, your documents are available in the Knowledge screen.
@ -104,11 +106,11 @@ Knowledge filters help agents work more efficiently with large document collecti
To create a knowledge filter, do the following:
1. Click <Icon name="Funnel" aria-hidden="true"/> **All Knowledge**, and then click <Icon name="Plus" aria-hidden="true"/> **Create New Filter**.
The **Create New Knowledge Filter** pane appears.
2. Enter a **Name** and **Description**, and then click <Icon name="Save" aria-hidden="true"/> **Create Filter**.
A new filter is created with default settings that match everything.
3. To modify the default filter, click <Icon name="Funnel" aria-hidden="true"/> **All Knowledge**, and then click your new filter to edit it in the **Knowledge Filter** pane.
1. Click **Knowledge**, and then click <Icon name="Plus" aria-hidden="true"/> **Knowledge Filters**.
The **Knowledge Filter** pane appears.
2. Enter a **Name** and **Description**, and then click **Create Filter**.
A new filter is created with default settings that match all documents.
3. To modify the filter, click <Icon name="Library" aria-hidden="true"/> **Knowledge**, and then click your new filter to edit it in the **Knowledge Filter** pane.
The following filter options are configurable.
@ -116,15 +118,17 @@ A new filter is created with default settings that match everything.
* **Data Sources**: Select specific data sources or folders to include.
* **Document Types**: Filter by file type.
* **Owners**: Filter by who uploaded the documents.
* **Sources**: Filter by connector types, such as local upload or Google Drive.
* **Result Limit**: Set maximum number of results. The default is `10`.
* **Connectors**: Filter by connector types, such as local upload or Google Drive.
* **Response Limit**: Set maximum number of results. The default is `10`.
* **Score Threshold**: Set minimum relevance score. The default score is `0`.
4. When you're done editing the filter, click <Icon name="Save" aria-hidden="true"/> **Save Configuration**.
4. When you're done editing the filter, click **Update Filter**.
5. To apply the filter to OpenRAG globally, click <Icon name="Funnel" aria-hidden="true"/> **All Knowledge**, and then select the filter to apply.
5. To apply the filter to OpenRAG globally, click <Icon name="Library" aria-hidden="true"/> **Knowledge**, and then select the filter to apply. One filter can be enabled at a time.
To apply the filter to a single chat session, in the <Icon name="MessageSquare" aria-hidden="true"/> **Chat** window, click **@**, and then select the filter to apply.
To apply the filter to a single chat session, in the <Icon name="MessageSquare" aria-hidden="true"/> **Chat** window, click <Icon name="Funnel" aria-hidden="true"/>, and then select the filter to apply.
To delete the filter, in the **Knowledge Filter** pane, click **Delete Filter**.
## OpenRAG default configuration

View file

@ -48,15 +48,28 @@ To install OpenRAG with Docker Compose, do the following:
touch .env
```
4. The Docker Compose files are populated with the values from your .env. The following values must be set:
4. The Docker Compose files are populated with the values from your `.env` file. The following values must be set:
```bash
OPENSEARCH_PASSWORD=your_secure_password
OPENAI_API_KEY=your_openai_api_key
LANGFLOW_SUPERUSER=admin
LANGFLOW_SUPERUSER_PASSWORD=your_langflow_password
LANGFLOW_SECRET_KEY=your_secret_key
```
`OPENSEARCH_PASSWORD` can be automatically generated when using the TUI, but for a Docker Compose installation, you can set it manually instead. To generate an OpenSearch admin password, see the [OpenSearch documentation](https://docs.opensearch.org/latest/security/configuration/demo-configuration/#setting-up-a-custom-admin-password).
The `OPENAI_API_KEY` is found in your OpenAI account.
`LANGFLOW_SECRET_KEY` is automatically generated when using the TUI, and Langflow will also auto-generate it if not set. For more information, see the [Langflow documentation](https://docs.langflow.org/api-keys-and-authentication#langflow-secret-key).
The following Langflow configuration values are optional but important to consider:
```bash
LANGFLOW_SUPERUSER=admin
LANGFLOW_SUPERUSER_PASSWORD=your_langflow_password
```
`LANGFLOW_SUPERUSER` defaults to `admin`. You can omit it or set it to a different username. `LANGFLOW_SUPERUSER_PASSWORD` is optional. If omitted, Langflow runs in [autologin mode](https://docs.langflow.org/api-keys-and-authentication#langflow-auto-login) with no password required. If set, Langflow requires password authentication.
For more information on configuring OpenRAG with environment variables, see [Environment variables](/reference/configuration).

View file

@ -7,9 +7,9 @@ import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
import PartialOnboarding from '@site/docs/_partial-onboarding.mdx';
[Install the OpenRAG Python wheel](#install-python-wheel), and then run the [OpenRAG Terminal User Interface(TUI)](#setup) to start your OpenRAG deployment with a guided setup process.
[Install OpenRAG](#install) and then run the [OpenRAG Terminal User Interface(TUI)](#setup) to start your OpenRAG deployment with a guided setup process.
The OpenRAG Terminal User Interface (TUI) allows you to set up, configure, and monitor your OpenRAG deployment directly from the terminal, on any operating system.
The OpenRAG Terminal User Interface (TUI) allows you to set up, configure, and monitor your OpenRAG deployment directly from the terminal.
![OpenRAG TUI Interface](@site/static/img/OpenRAG_TUI_2025-09-10T13_04_11_757637.svg)
@ -28,13 +28,13 @@ If you prefer running Podman or Docker containers and manually editing `.env` fi
- Create an [OpenAI API key](https://platform.openai.com/api-keys). This key is **required** to start OpenRAG, but you can choose a different model provider during [Application Onboarding](#application-onboarding).
- Optional: Install GPU support with an NVIDIA GPU, [CUDA](https://docs.nvidia.com/cuda/) support, and compatible NVIDIA drivers on the OpenRAG host machine. If you don't have GPU capabilities, OpenRAG provides an alternate CPU-only deployment.
## Install the OpenRAG Python wheel {#install-python-wheel}
## Install OpenRAG {#install}
The OpenRAG wheel installs the Terminal User Interface (TUI) for configuring and running OpenRAG.
:::note Windows users
To use OpenRAG on Windows, use [WSL (Windows Subsystem for Linux)](https://learn.microsoft.com/en-us/windows/wsl/install).
:::
To quickly install and start OpenRAG, run `uvx openrag`.
To first set up a project and then install OpenRAG, do the following:
To set up a project and install OpenRAG as a dependency, do the following:
1. Create a new project with a virtual environment using `uv init`.
@ -46,20 +46,23 @@ To first set up a project and then install OpenRAG, do the following:
The `(venv)` prompt doesn't change, but `uv` commands will automatically use the project's virtual environment.
For more information on virtual environments, see the [uv documentation](https://docs.astral.sh/uv/pip/environments).
2. Ensure all dependencies are installed and updated in your virtual environment.
2. Add OpenRAG to your project.
```bash
uv sync
uv add openrag
```
3. Install and start the OpenRAG TUI.
To add a specific version of OpenRAG:
```bash
uvx openrag
uv add openrag==0.1.25
```
3. Start the OpenRAG TUI.
```bash
uv run openrag
```
To install a specific version of the Langflow package, add the required version to the command, such as `uvx --from openrag==0.1.25 openrag`.
<details closed>
<summary>Install a local wheel without uvx</summary>
<summary>Install a local wheel</summary>
If you downloaded the OpenRAG wheel to your local machine, follow these steps:
@ -102,10 +105,14 @@ If the TUI detects OAuth credentials, it enforces the **Advanced Setup** path.
1. To install OpenRAG with **Basic Setup**, click **Basic Setup** or press <kbd>1</kbd>.
2. Click **Generate Passwords** to generate passwords for OpenSearch and Langflow.
The OpenSearch password is required. The Langflow admin password is optional.
If no Langflow admin password is generated, Langflow runs in [autologin mode](https://docs.langflow.org/api-keys-and-authentication#langflow-auto-login) with no password required.
3. Paste your OpenAI API key in the OpenAI API key field.
4. Click **Save Configuration**.
Your passwords are saved in the `.env` file used to start OpenRAG.
5. To start OpenRAG, click **Start Container Services**.
5. To start OpenRAG, click **Start All Services**.
Startup pulls container images and runs them, so it can take some time.
When startup is complete, the TUI displays the following:
```bash
@ -119,6 +126,10 @@ If the TUI detects OAuth credentials, it enforces the **Advanced Setup** path.
1. To install OpenRAG with **Advanced Setup**, click **Advanced Setup** or press <kbd>2</kbd>.
2. Click **Generate Passwords** to generate passwords for OpenSearch and Langflow.
The OpenSearch password is required. The Langflow admin password is optional.
If no Langflow admin password is generated, Langflow runs in [autologin mode](https://docs.langflow.org/api-keys-and-authentication#langflow-auto-login) with no password required.
3. Paste your OpenAI API key in the OpenAI API key field.
4. Add your client and secret values for Google or Microsoft OAuth.
These values can be found with your OAuth provider.
@ -127,14 +138,14 @@ If the TUI detects OAuth credentials, it enforces the **Advanced Setup** path.
These are the URLs your OAuth provider will redirect back to after user sign-in.
Register these redirect values with your OAuth provider as they are presented in the TUI.
6. Click **Save Configuration**.
7. To start OpenRAG, click **Start Container Services**.
7. To start OpenRAG, click **Start All Services**.
Startup pulls container images and runs them, so it can take some time.
When startup is complete, the TUI displays the following:
```bash
Services started successfully
Command completed successfully
```
8. To open the OpenRAG application, click **Open App**, press <kbd>6</kbd>, or navigate to `http://localhost:3000`.
8. To open the OpenRAG application, click **Open App**.
You are presented with your provider's OAuth sign-in screen.
After sign-in, you are redirected to the redirect URI.
@ -155,13 +166,21 @@ If the TUI detects OAuth credentials, it enforces the **Advanced Setup** path.
<PartialOnboarding />
## Manage OpenRAG containers with the TUI
## Close the OpenRAG TUI
To close the OpenRAG TUI, press <kbd>q</kbd>.
The OpenRAG containers will continue to be served until the containers are stopped.
For more information, see [Manage OpenRAG containers with the TUI ](#tui-container-management).
To start the TUI again, run `uv run openrag`.
## Manage OpenRAG containers with the TUI {#tui-container-management}
After installation, the TUI can deploy, manage, and upgrade your OpenRAG containers.
### Start container services
### Start all services
Click **Start Container Services** to start the OpenRAG containers.
Click **Start All Services** to start the OpenRAG containers.
The TUI automatically detects your container runtime, and then checks if your machine has compatible GPU support by checking for `CUDA`, `NVIDIA_SMI`, and Docker/Podman runtime support. This check determines which Docker Compose file OpenRAG uses.
The TUI then pulls the images and deploys the containers with the following command.
```bash
@ -170,15 +189,6 @@ docker compose up -d
If images are missing, the TUI runs `docker compose pull`, then runs `docker compose up -d`.
### Start native services
A "native" service in OpenRAG refers to a service run natively on your machine, and not within a container.
The `docling serve` process is a native service in OpenRAG, because it's a document processing service that is run on your local machine, and controlled separately from the containers.
To start or stop `docling serve` or any other native services, in the TUI main menu, click **Start Native Services** or **Stop Native Services**.
To view the status, port, or PID of a native service, in the TUI main menu, click [Status](#status).
### Status
The **Status** menu displays information on your container deployment.
@ -207,6 +217,15 @@ When the first command is complete, OpenRAG removes any additional Docker object
docker system prune -f
```
### Native services status
A _native service_ in OpenRAG refers to a service run locally on your machine, and not within a container.
The `docling serve` process is a native service in OpenRAG, because it's a document processing service that is run on your local machine, and controlled separately from the containers.
To start or stop `docling serve` or any other native services, in the TUI Status menu, click **Stop** or **Restart**.
To view the status, port, or PID of a native service, in the TUI main menu, click [Status](#status).
## Diagnostics
The **Diagnostics** menu provides health monitoring for your container runtimes and monitoring of your OpenSearch security.

View file

@ -7,7 +7,7 @@ import Icon from "@site/src/components/icon/icon";
import Tabs from '@theme/Tabs';
import TabItem from '@theme/TabItem';
Get started with OpenRAG by loading your knowledge, swapping out your language model, and then chatting with the OpenRAG API.
Get started with OpenRAG by loading your knowledge, swapping out your language model, and then chatting with the Langflow API.
## Prerequisites
@ -17,20 +17,20 @@ Get started with OpenRAG by loading your knowledge, swapping out your language m
1. In OpenRAG, click <Icon name="MessageSquare" aria-hidden="true"/> **Chat**.
The chat is powered by the OpenRAG OpenSearch Agent.
For more information, see [Langflow Agents](/agents).
For more information, see [Langflow in OpenRAG](/agents).
2. Ask `What documents are available to you?`
The agent responds with a message summarizing the documents that OpenRAG loads by default.
Knowledge is stored in OpenSearch.
For more information, see [Knowledge](/knowledge).
For more information, see [OpenSearch in OpenRAG](/knowledge).
3. To confirm the agent is correct about the default knowledge, click <Icon name="Library" aria-hidden="true"/> **Knowledge**.
The **Knowledge** page lists the documents OpenRAG has ingested into the OpenSearch vector database.
Click on a document to display the chunks derived from splitting the default documents into the vector database.
4. To add documents to your knowledge base, click <Icon name="Plus" aria-hidden="true"/> **Add Knowledge**.
* Select **Add File** to add a single file from your local machine.
* Select **Process Folder** to process an entire folder of documents from your local machine.
Click on a document to display the chunks derived from splitting the default documents into the OpenSearch vector database.
4. To add documents to your knowledge base, click **Add Knowledge**.
* Select <Icon name="File" aria-hidden="true"/> **File** to add a single file from your local machine.
* Select <Icon name="Folder" aria-hidden="true"/> **Folder** to process an entire folder of documents from your local machine. The default directory is `/documents` in your OpenRAG directory.
* Select your cloud storage provider to add knowledge from an OAuth-connected storage provider. For more information, see [OAuth ingestion](/knowledge#oauth-ingestion).
5. Return to the Chat window and ask a question about your loaded data.
For example, with a manual about a PC tablet loaded, ask `How do I connect this device to WiFI?`
For example, with a manual about a PC tablet loaded, ask `How do I connect this device to WiFi?`
The agent responds with a message indicating it now has your knowledge as context for answering questions.
6. Click <Icon name="Gear" aria-hidden="true"/> **Function Call: search_documents (tool_call)**.
This log describes how the agent uses tools.
@ -44,8 +44,12 @@ In this example, you'll try a different LLM to demonstrate how the Agent's respo
1. To edit the Agent's behavior, click **Edit in Langflow**.
You can more quickly access the **Language Model** and **Agent Instructions** fields in this page, but for illustration purposes, navigate to the Langflow visual builder.
To revert the flow to its initial state, click **Restore flow**.
2. OpenRAG warns you that you're entering Langflow. Click **Proceed**.
The OpenRAG OpenSearch Agent flow appears in a new browser window.
If Langflow requests login information, enter the `LANGFLOW_SUPERUSER` and `LANGFLOW_SUPERUSER_PASSWORD` from the `.env` file in your OpenRAG directory.
The OpenRAG OpenSearch Agent flow appears in a new browser window.
![OpenRAG Open Search Agent Flow](/img/opensearch-agent-flow.png)
3. Find the **Language Model** component, and then change the **Model Name** field to a different OpenAI model.

View file

@ -7,7 +7,7 @@ OpenRAG is an open-source package for building agentic RAG systems that integrat
OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:
* [Langflow](https://docs.langflow.org): Langflow is a popular tool for building and deploying AI agents and MCP servers. It supports all major LLMs, vector databases, and a growing library of AI tools.
* [Langflow](https://docs.langflow.org): Langflow is a versatile tool for building and deploying AI agents and MCP servers. It supports all major LLMs, vector databases, and a growing library of AI tools.
* [OpenSearch](https://docs.opensearch.org/latest/): OpenSearch is a community-driven, Apache 2.0-licensed open source search and analytics suite that makes it easy to ingest, search, visualize, and analyze data.

View file

@ -38,6 +38,26 @@ This example increases the machine size to 8 GB of RAM, which should be sufficie
Ensure ports 3000, 7860, 8000, 9200, 5601 are available.
## OCR ingestion fails (easyocr not installed)
If Docling ingestion fails with an OCR-related error and mentions `easyocr` is missing, this is likely due to a stale `uv` cache.
`easyocr` is already included as a dependency in OpenRAG's `pyproject.toml`. Project-managed installations using `uv sync` and `uv run` always sync dependencies directly from your `pyproject.toml`, so they should have `easyocr` installed.
If you're running OpenRAG with `uvx openrag`, `uvx` creates a cached, ephemeral environment that doesn't modify your project. This cache may become stale.
On macOS, this cache directory is typically a user cache directory such as `/Users/USER_NAME/.cache/uv`.
1. To clear the uv cache, run:
```bash
uv cache clean
```
2. Start OpenRAG:
```bash
uvx openrag
```
If you do not need OCR, you can disable OCR-based processing in your ingestion settings to avoid requiring `easyocr`.
## Langflow container already exists
If you are running other versions of Langflow containers on your machine, you may encounter an issue where Docker or Podman thinks Langflow is already up.

Binary file not shown.

View file

@ -31,7 +31,7 @@
"list": false,
"show": true,
"multiline": true,
"value": "from typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.base.models.ollama_constants import OLLAMA_EMBEDDING_MODELS, URL_LIST\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, MessageTextInput, Output\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n MessageTextInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n value=\"\",\n required=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n def build_embeddings(self) -> Embeddings:\n try:\n output = OllamaEmbeddings(model=self.model_name, base_url=self.base_url)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \",\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\",\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(field_value):\n # Check if any URL in the list is valid\n valid_url = \"\"\n for url in URL_LIST:\n if await self.is_valid_ollama_url(url):\n valid_url = url\n break\n build_config[\"base_url\"][\"value\"] = valid_url\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n if await self.is_valid_ollama_url(self.base_url):\n build_config[\"model_name\"][\"options\"] = await self.get_model(self.base_url)\n elif await self.is_valid_ollama_url(build_config[\"base_url\"].get(\"value\", \"\")):\n build_config[\"model_name\"][\"options\"] = await self.get_model(build_config[\"base_url\"].get(\"value\", \"\"))\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n model_ids = []\n try:\n url = urljoin(base_url_value, \"/api/tags\")\n async with httpx.AsyncClient() as client:\n response = await client.get(url)\n response.raise_for_status()\n data = response.json()\n\n model_ids = [model[\"name\"] for model in data.get(\"models\", [])]\n # this to ensure that not embedding models are included.\n # not even the base models since models can have 1b 2b etc\n # handles cases when embeddings models have tags like :latest - etc.\n model_ids = [\n model\n for model in model_ids\n if any(model.startswith(f\"{embedding_model}\") for embedding_model in OLLAMA_EMBEDDING_MODELS)\n ]\n\n except (ImportError, ValueError, httpx.RequestError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n return (await client.get(f\"{url}/api/tags\")).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n",
"value": "from typing import Any\nfrom urllib.parse import urljoin\n\nimport httpx\nfrom langchain_ollama import OllamaEmbeddings\n\nfrom lfx.base.models.model import LCModelComponent\nfrom lfx.base.models.ollama_constants import OLLAMA_EMBEDDING_MODELS\nfrom lfx.field_typing import Embeddings\nfrom lfx.io import DropdownInput, MessageTextInput, Output\nfrom lfx.utils.util import transform_localhost_url\n\nHTTP_STATUS_OK = 200\n\n\nclass OllamaEmbeddingsComponent(LCModelComponent):\n display_name: str = \"Ollama Embeddings\"\n description: str = \"Generate embeddings using Ollama models.\"\n documentation = \"https://python.langchain.com/docs/integrations/text_embedding/ollama\"\n icon = \"Ollama\"\n name = \"OllamaEmbeddings\"\n\n inputs = [\n DropdownInput(\n name=\"model_name\",\n display_name=\"Ollama Model\",\n value=\"\",\n options=[],\n real_time_refresh=True,\n refresh_button=True,\n combobox=True,\n required=True,\n ),\n MessageTextInput(\n name=\"base_url\",\n display_name=\"Ollama Base URL\",\n value=\"\",\n required=True,\n ),\n ]\n\n outputs = [\n Output(display_name=\"Embeddings\", name=\"embeddings\", method=\"build_embeddings\"),\n ]\n\n def build_embeddings(self) -> Embeddings:\n transformed_base_url = transform_localhost_url(self.base_url)\n try:\n output = OllamaEmbeddings(model=self.model_name, base_url=transformed_base_url)\n except Exception as e:\n msg = (\n \"Unable to connect to the Ollama API. \",\n \"Please verify the base URL, ensure the relevant Ollama model is pulled, and try again.\",\n )\n raise ValueError(msg) from e\n return output\n\n async def update_build_config(self, build_config: dict, _field_value: Any, field_name: str | None = None):\n if field_name in {\"base_url\", \"model_name\"} and not await self.is_valid_ollama_url(self.base_url):\n msg = \"Ollama is not running on the provided base URL. Please start Ollama and try again.\"\n raise ValueError(msg)\n if field_name in {\"model_name\", \"base_url\", \"tool_model_enabled\"}:\n if await self.is_valid_ollama_url(self.base_url):\n build_config[\"model_name\"][\"options\"] = await self.get_model(self.base_url)\n else:\n build_config[\"model_name\"][\"options\"] = []\n\n return build_config\n\n async def get_model(self, base_url_value: str) -> list[str]:\n \"\"\"Get the model names from Ollama.\"\"\"\n model_ids = []\n try:\n base_url_value = transform_localhost_url(base_url_value)\n url = urljoin(base_url_value, \"/api/tags\")\n async with httpx.AsyncClient() as client:\n response = await client.get(url)\n response.raise_for_status()\n data = response.json()\n\n model_ids = [model[\"name\"] for model in data.get(\"models\", [])]\n # this to ensure that not embedding models are included.\n # not even the base models since models can have 1b 2b etc\n # handles cases when embeddings models have tags like :latest - etc.\n model_ids = [\n model\n for model in model_ids\n if any(model.startswith(f\"{embedding_model}\") for embedding_model in OLLAMA_EMBEDDING_MODELS)\n ]\n\n except (ImportError, ValueError, httpx.RequestError) as e:\n msg = \"Could not get model names from Ollama.\"\n raise ValueError(msg) from e\n\n return model_ids\n\n async def is_valid_ollama_url(self, url: str) -> bool:\n try:\n async with httpx.AsyncClient() as client:\n url = transform_localhost_url(url)\n return (await client.get(f\"{url}/api/tags\")).status_code == HTTP_STATUS_OK\n except httpx.RequestError:\n return False\n",
"fileTypes": [],
"file_path": "",
"password": false,
@ -99,44 +99,27 @@
"legacy": false,
"edited": false,
"metadata": {
"keywords": [
"model",
"llm",
"language model",
"large language model"
],
"keywords": ["model", "llm", "language model", "large language model"],
"module": "lfx.components.ollama.ollama_embeddings.OllamaEmbeddingsComponent",
"code_hash": "c41821735548",
"code_hash": "9ef83e250bee",
"dependencies": {
"total_dependencies": 3,
"dependencies": [
{
"name": "httpx",
"version": "0.28.1"
},
{
"name": "langchain_ollama",
"version": "0.2.1"
},
{
"name": "lfx",
"version": null
}
{ "name": "httpx", "version": "0.28.1" },
{ "name": "langchain_ollama", "version": "0.2.1" },
{ "name": "lfx", "version": "0.1.12.dev32" }
]
}
},
"tool_mode": false,
"last_updated": "2025-09-29T18:40:10.242Z",
"last_updated": "2025-10-29T19:54:23.774Z",
"official": false
},
"showNode": true,
"type": "OllamaEmbeddings",
"id": "OllamaEmbeddings-vnNn8"
},
"id": "OllamaEmbeddings-vnNn8",
"position": {
"x": 0,
"y": 0
"id": "OllamaEmbeddings-3JO8z"
},
"id": "OllamaEmbeddings-3JO8z",
"position": { "x": 0, "y": 0 },
"type": "genericNode"
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -7,29 +7,29 @@ const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => {
// CSS for the stepped animation states
const animationCSS = `
.state1 { animation: showState1 600ms infinite; }
.state2 { animation: showState2 600ms infinite; }
.state3 { animation: showState3 600ms infinite; }
.state4 { animation: showState4 600ms infinite; }
.state1 { animation: showDogState1 600ms infinite; }
.state2 { animation: showDogState2 600ms infinite; }
.state3 { animation: showDogState3 600ms infinite; }
.state4 { animation: showDogState4 600ms infinite; }
@keyframes showState1 {
@keyframes showDogState1 {
0%, 24.99% { opacity: 1; }
25%, 100% { opacity: 0; }
}
@keyframes showState2 {
@keyframes showDogState2 {
0%, 24.99% { opacity: 0; }
25%, 49.99% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes showState3 {
@keyframes showDogState3 {
0%, 49.99% { opacity: 0; }
50%, 74.99% { opacity: 1; }
75%, 100% { opacity: 0; }
}
@keyframes showState4 {
@keyframes showDogState4 {
0%, 74.99% { opacity: 0; }
75%, 100% { opacity: 1; }
}

View file

@ -194,21 +194,6 @@ export async function uploadFile(
raw: uploadIngestJson,
};
window.dispatchEvent(
new CustomEvent("fileUploaded", {
detail: {
file,
result: {
file_id: fileId,
file_path: filePath,
run: runJson,
deletion: deletionJson,
unified: true,
},
},
})
);
return result;
} catch (error) {
window.dispatchEvent(

View file

@ -42,6 +42,7 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",
@ -8348,6 +8349,21 @@
"react": "^19.1.1"
}
},
"node_modules/react-hook-form": {
"version": "7.65.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz",
"integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",

View file

@ -43,6 +43,7 @@
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.65.0",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^15.6.1",

View file

@ -1,61 +0,0 @@
import {
type UseMutationOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
interface UpdateFlowSettingVariables {
llm_model?: string;
system_prompt?: string;
embedding_model?: string;
table_structure?: boolean;
ocr?: boolean;
picture_descriptions?: boolean;
chunk_size?: number;
chunk_overlap?: number;
}
interface UpdateFlowSettingResponse {
message: string;
}
export const useUpdateFlowSettingMutation = (
options?: Omit<
UseMutationOptions<
UpdateFlowSettingResponse,
Error,
UpdateFlowSettingVariables
>,
"mutationFn"
>,
) => {
const queryClient = useQueryClient();
async function updateFlowSetting(
variables: UpdateFlowSettingVariables,
): Promise<UpdateFlowSettingResponse> {
const response = await fetch("/api/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(variables),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update settings");
}
return response.json();
}
return useMutation({
mutationFn: updateFlowSetting,
onSettled: () => {
// Invalidate settings query to refetch updated data
queryClient.invalidateQueries({ queryKey: ["settings"] });
},
...options,
});
};

View file

@ -0,0 +1,72 @@
import {
type UseMutationOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import type { Settings } from "../queries/useGetSettingsQuery";
export interface UpdateSettingsRequest {
// Agent settings
llm_model?: string;
system_prompt?: string;
// Knowledge settings
chunk_size?: number;
chunk_overlap?: number;
table_structure?: boolean;
ocr?: boolean;
picture_descriptions?: boolean;
embedding_model?: string;
// Provider settings
model_provider?: string;
api_key?: string;
endpoint?: string;
project_id?: string;
}
export interface UpdateSettingsResponse {
message: string;
settings: Settings;
}
export const useUpdateSettingsMutation = (
options?: Omit<
UseMutationOptions<UpdateSettingsResponse, Error, UpdateSettingsRequest>,
"mutationFn"
>
) => {
const queryClient = useQueryClient();
async function updateSettings(
variables: UpdateSettingsRequest
): Promise<UpdateSettingsResponse> {
const response = await fetch("/api/settings", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(variables),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to update settings");
}
return response.json();
}
return useMutation({
mutationFn: updateSettings,
onSuccess: (...args) => {
queryClient.invalidateQueries({
queryKey: ["settings"],
refetchType: "all"
});
options?.onSuccess?.(...args);
},
onError: options?.onError,
onSettled: options?.onSettled,
});
};

View file

@ -53,8 +53,6 @@ export const useGetOpenAIModelsQuery = (
{
queryKey: ["models", "openai", params],
queryFn: getOpenAIModels,
retry: 2,
enabled: !!params?.apiKey,
staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results
...options,
@ -89,7 +87,6 @@ export const useGetOllamaModelsQuery = (
{
queryKey: ["models", "ollama", params],
queryFn: getOllamaModels,
retry: 2,
staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results
...options,
@ -130,8 +127,6 @@ export const useGetIBMModelsQuery = (
{
queryKey: ["models", "ibm", params],
queryFn: getIBMModels,
retry: 2,
enabled: !!params?.endpoint && !!params?.apiKey && !!params?.projectId, // Only run if all required params are provided
staleTime: 0, // Always fetch fresh data
gcTime: 0, // Don't cache results
...options,

View file

@ -26,6 +26,9 @@ export interface Settings {
edited?: boolean;
provider?: {
model_provider?: string;
// Note: api_key is never returned by the backend for security reasons
endpoint?: string;
project_id?: string;
};
knowledge?: KnowledgeSettings;
agent?: AgentSettings;

View file

@ -46,7 +46,7 @@ export function AssistantMessage({
>
<Message
icon={
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 transition-colors duration-300"
disabled={isCompleted || isInactive}

View file

@ -1,5 +1,5 @@
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
import { forwardRef, useImperativeHandle, useRef } from "react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { Button } from "@/components/ui/button";
@ -9,7 +9,6 @@ import {
PopoverContent,
} from "@/components/ui/popover";
import type { KnowledgeFilterData } from "../types";
import { useState } from "react";
import { FilePreview } from "./file-preview";
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
@ -81,7 +80,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
fileInputRef.current?.click();
},
}));
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
@ -92,242 +91,247 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
};
return (
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring p-2">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring p-2">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? 'flex-col' : 'flex-row items-center'
}`}>
{/* Filter + Textarea Section */}
<div className={`flex items-center gap-2 ${textareaHeight > 40 ? 'w-full' : 'flex-1'}`}>
{textareaHeight <= 40 && (
selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)
)}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div
className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`}
>
{/* Filter + Textarea Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
>
{textareaHeight <= 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
</div>
</div>
{/* Action Buttons Section */}
<div className={`flex items-center gap-2 ${textareaHeight > 40 ? 'justify-between w-full' : ''}`}>
{textareaHeight > 40 && (
selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
)
)}
<div className="flex items-center gap-2">
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Plus className="h-4 w-4" />
<Funnel className="h-4 w-4" />
</Button>
))}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
autoComplete="off"
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
</div>
{/* Action Buttons Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
>
{textareaHeight > 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
variant="default"
type="submit"
type="button"
variant="ghost"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
<Funnel className="h-4 w-4" />
</Button>
</div>
))}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
);
},
);

View file

@ -1154,8 +1154,6 @@ function ChatPage() {
}
};
console.log(messages)
return (
<>
{/* Debug header - only show in debug mode */}

View file

@ -1,44 +0,0 @@
"use client";
import { Suspense, useState } from "react";
import { DoclingHealthBanner } from "@/components/docling-health-banner";
import { ProtectedRoute } from "@/components/protected-route";
import { OnboardingContent } from "./components/onboarding-content";
import { ProgressBar } from "./components/progress-bar";
const TOTAL_STEPS = 4;
function NewOnboardingPage() {
const [currentStep, setCurrentStep] = useState(0);
const handleStepComplete = () => {
if (currentStep < TOTAL_STEPS - 1) {
setCurrentStep(currentStep + 1);
}
};
return (
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-primary-foreground relative p-4">
<DoclingHealthBanner className="absolute top-0 left-0 right-0 w-full z-20" />
{/* Chat-like content area */}
<div className="flex flex-col items-center gap-5 w-full max-w-3xl z-10">
<div className="w-full h-[872px] bg-background border rounded-lg p-4 shadow-sm overflow-y-auto">
<OnboardingContent handleStepComplete={handleStepComplete} currentStep={currentStep} />
</div>
<ProgressBar currentStep={currentStep} totalSteps={TOTAL_STEPS} />
</div>
</div>
);
}
export default function ProtectedNewOnboardingPage() {
return (
<ProtectedRoute>
<Suspense fallback={<div>Loading...</div>}>
<NewOnboardingPage />
</Suspense>
</ProtectedRoute>
);
}

View file

@ -37,7 +37,6 @@ export function AdvancedOnboarding({
languageModel !== undefined &&
setLanguageModel !== undefined;
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
return (
<Accordion type="single" collapsible>
<AccordionItem value="item-1">

View file

@ -1,7 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon } from "lucide-react";
import { CheckIcon, XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import {
@ -20,6 +20,7 @@ export function AnimatedProviderSteps({
steps,
storageKey = "provider-steps",
processingStartTime,
hasError = false,
}: {
currentStep: number;
isCompleted: boolean;
@ -27,6 +28,7 @@ export function AnimatedProviderSteps({
steps: string[];
storageKey?: string;
processingStartTime?: number | null;
hasError?: boolean;
}) {
const [startTime, setStartTime] = useState<number | null>(null);
const [elapsedTime, setElapsedTime] = useState<number>(0);
@ -63,7 +65,7 @@ export function AnimatedProviderSteps({
}
}, [isCompleted, startTime, storageKey]);
const isDone = currentStep >= steps.length && !isCompleted;
const isDone = currentStep >= steps.length && !isCompleted && !hasError;
return (
<AnimatePresence mode="wait">
@ -79,8 +81,8 @@ export function AnimatedProviderSteps({
<div className="flex items-center gap-2">
<div
className={cn(
"transition-all duration-150 relative",
isDone ? "w-3.5 h-3.5" : "w-3.5 h-2.5",
"transition-all duration-300 relative",
isDone || hasError ? "w-3.5 h-3.5" : "w-6 h-6",
)}
>
<CheckIcon
@ -89,28 +91,34 @@ export function AnimatedProviderSteps({
isDone ? "opacity-100" : "opacity-0",
)}
/>
<XIcon
className={cn(
"text-accent-red-foreground shrink-0 w-3.5 h-3.5 absolute inset-0 transition-all duration-150",
hasError ? "opacity-100" : "opacity-0",
)}
/>
<AnimatedProcessingIcon
className={cn(
"text-current shrink-0 absolute inset-0 transition-all duration-150",
isDone ? "opacity-0" : "opacity-100",
isDone || hasError ? "opacity-0" : "opacity-100",
)}
/>
</div>
<span className="text-mmd font-medium text-muted-foreground">
{isDone ? "Done" : "Thinking"}
<span className="!text-mmd font-medium text-muted-foreground">
{hasError ? "Error" : isDone ? "Done" : "Thinking"}
</span>
</div>
<div className="overflow-hidden">
<AnimatePresence>
{!isDone && (
{!isDone && !hasError && (
<motion.div
initial={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -24, height: 0 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
className="flex items-center gap-4 overflow-y-hidden relative h-6"
>
<div className="w-px h-6 bg-border ml-2" />
<div className="w-px h-6 bg-border ml-3" />
<div className="relative h-5 w-full">
<AnimatePresence mode="sync" initial={false}>
<motion.span

View file

@ -67,13 +67,14 @@ export function IBMOnboarding({
isLoading: isLoadingModels,
error: modelsError,
} = useGetIBMModelsQuery(
debouncedEndpoint && debouncedApiKey && debouncedProjectId
? {
endpoint: debouncedEndpoint,
apiKey: debouncedApiKey,
projectId: debouncedProjectId,
}
: undefined,
{
endpoint: debouncedEndpoint,
apiKey: debouncedApiKey,
projectId: debouncedProjectId,
},
{
enabled: !!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId,
}
);
// Use custom hook for model selection logic

View file

@ -26,6 +26,7 @@ export function ModelSelector({
searchPlaceholder = "Search model...",
noOptionsPlaceholder = "No models available",
custom = false,
hasError = false,
}: {
options: {
value: string;
@ -39,6 +40,7 @@ export function ModelSelector({
noOptionsPlaceholder?: string;
custom?: boolean;
onValueChange: (value: string) => void;
hasError?: boolean;
}) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
@ -57,7 +59,7 @@ export function ModelSelector({
role="combobox"
disabled={options.length === 0}
aria-expanded={open}
className="w-full gap-2 justify-between font-normal text-sm"
className={cn("w-full gap-2 justify-between font-normal text-sm", hasError && "!border-destructive")}
>
{value ? (
<div className="flex items-center gap-2">

View file

@ -1,6 +1,7 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
@ -24,6 +25,7 @@ import { AnimatedProviderSteps } from "./animated-provider-steps";
import { IBMOnboarding } from "./ibm-onboarding";
import { OllamaOnboarding } from "./ollama-onboarding";
import { OpenAIOnboarding } from "./openai-onboarding";
import { TabTrigger } from "./tab-trigger";
interface OnboardingCardProps {
onComplete: () => void;
@ -69,6 +71,7 @@ const OnboardingCard = ({
embedding_model: "",
llm_model: "",
});
setError(null);
};
const [settings, setSettings] = useState<OnboardingVariables>({
@ -85,6 +88,8 @@ const OnboardingCard = ({
null,
);
const [error, setError] = useState<string | null>(null);
// Query tasks to track completion
const { data: tasks } = useGetTasksQuery({
enabled: currentStep !== null, // Only poll when onboarding has started
@ -125,11 +130,15 @@ const OnboardingCard = ({
onSuccess: (data) => {
console.log("Onboarding completed successfully", data);
setCurrentStep(0);
setError(null);
},
onError: (error) => {
toast.error("Failed to complete onboarding", {
description: error.message,
});
setError(error.message);
setCurrentStep(TOTAL_PROVIDER_STEPS);
// Reset to provider selection after 1 second
setTimeout(() => {
setCurrentStep(null);
}, 1000);
},
});
@ -143,6 +152,9 @@ const OnboardingCard = ({
return;
}
// Clear any previous error
setError(null);
// Prepare onboarding data
const onboardingData: OnboardingVariables = {
model_provider: settings.model_provider,
@ -180,148 +192,179 @@ const OnboardingCard = ({
{currentStep === null ? (
<motion.div
key="onboarding-form"
initial={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -24 }}
initial={{ opacity: 0, y: -24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<div className={`w-full max-w-[600px] flex flex-col gap-6`}>
<Tabs
defaultValue={modelProvider}
onValueChange={handleSetModelProvider}
>
<TabsList className="mb-4">
<TabsTrigger value="openai">
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "openai" ? "bg-white" : "bg-muted",
)}
>
<OpenAILogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "openai"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
OpenAI
</TabsTrigger>
<TabsTrigger value="watsonx">
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "watsonx" ? "bg-[#1063FE]" : "bg-muted",
)}
>
<IBMLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "watsonx"
? "text-white"
: "text-muted-foreground",
)}
/>
</div>
IBM watsonx.ai
</TabsTrigger>
<TabsTrigger value="ollama">
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "ollama" ? "bg-white" : "bg-muted",
)}
>
<OllamaLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "ollama"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
Ollama
</TabsTrigger>
</TabsList>
<AnimatePresence>
{isLoadingModels && (
<div className={`w-full max-w-[600px] flex flex-col`}>
<AnimatePresence mode="wait">
{error && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.1, ease: "easeInOut" }}
className="overflow-hidden"
key="error"
initial={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
>
<div className="py-3">
<AnimatedProviderSteps
currentStep={loadingStep}
isCompleted={false}
setCurrentStep={setLoadingStep}
steps={[
"Connecting to the provider",
"Fetching language models",
"Fetching embedding models",
]}
storageKey="model-loading-steps"
/></div>
<div className="pb-6 flex items-center gap-4">
<X className="w-4 h-4 text-destructive shrink-0" />
<span className="text-mmd text-muted-foreground">
{error}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
<TabsContent value="openai">
<OpenAIOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
/>
</TabsContent>
<TabsContent value="watsonx">
<IBMOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
/>
</TabsContent>
<TabsContent value="ollama">
<OllamaOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
/>
</TabsContent>
</Tabs>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
size="sm"
onClick={handleComplete}
disabled={!isComplete || isLoadingModels}
loading={onboardingMutation.isPending}
<div className={`w-full flex flex-col gap-6`}>
<Tabs
defaultValue={modelProvider}
onValueChange={handleSetModelProvider}
>
<TabsList className="mb-4">
<TabsTrigger
value="openai"
className={cn(
error &&
modelProvider === "openai" &&
"data-[state=active]:border-destructive",
)}
>
<span className="select-none">Complete</span>
</Button>
</div>
</TooltipTrigger>
{!isComplete && (
<TooltipContent>
{isLoadingModels
? "Loading models..."
: !!settings.llm_model &&
!!settings.embedding_model &&
!isDoclingHealthy
? "docling-serve must be running to continue"
: "Please fill in all required fields"}
</TooltipContent>
)}
</Tooltip>
<TabTrigger
selected={modelProvider === "openai"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "openai" ? "bg-white" : "bg-muted",
)}
>
<OpenAILogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "openai"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
OpenAI
</TabTrigger>
</TabsTrigger>
<TabsTrigger
value="watsonx"
className={cn(
error &&
modelProvider === "watsonx" &&
"data-[state=active]:border-destructive",
)}
>
<TabTrigger
selected={modelProvider === "watsonx"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "watsonx"
? "bg-[#1063FE]"
: "bg-muted",
)}
>
<IBMLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "watsonx"
? "text-white"
: "text-muted-foreground",
)}
/>
</div>
IBM watsonx.ai
</TabTrigger>
</TabsTrigger>
<TabsTrigger
value="ollama"
className={cn(
error &&
modelProvider === "ollama" &&
"data-[state=active]:border-destructive",
)}
>
<TabTrigger
selected={modelProvider === "ollama"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md",
modelProvider === "ollama" ? "bg-white" : "bg-muted",
)}
>
<OllamaLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "ollama"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
Ollama
</TabTrigger>
</TabsTrigger>
</TabsList>
<TabsContent value="openai">
<OpenAIOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
/>
</TabsContent>
<TabsContent value="watsonx">
<IBMOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
/>
</TabsContent>
<TabsContent value="ollama">
<OllamaOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
/>
</TabsContent>
</Tabs>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
size="sm"
onClick={handleComplete}
disabled={!isComplete || isLoadingModels}
loading={onboardingMutation.isPending}
>
<span className="select-none">Complete</span>
</Button>
</div>
</TooltipTrigger>
{!isComplete && (
<TooltipContent>
{isLoadingModels
? "Loading models..."
: !!settings.llm_model &&
!!settings.embedding_model &&
!isDoclingHealthy
? "docling-serve must be running to continue"
: "Please fill in all required fields"}
</TooltipContent>
)}
</Tooltip>
</div>
</div>
</motion.div>
) : (
@ -329,6 +372,7 @@ const OnboardingCard = ({
key="provider-steps"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<AnimatedProviderSteps
@ -337,6 +381,7 @@ const OnboardingCard = ({
setCurrentStep={setCurrentStep}
steps={STEP_LIST}
processingStartTime={processingStartTime}
hasError={!!error}
/>
</motion.div>
)}

View file

@ -74,7 +74,7 @@ export function OnboardingStep({
<div className="w-8 h-8 rounded-lg flex-shrink-0" />
) : (
icon || (
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 text-accent-foreground transition-colors duration-300"
disabled={isCompleted}

View file

@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "motion/react";
import { type ChangeEvent, useRef, useState } from "react";
import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps";
import { Button } from "@/components/ui/button";
import { uploadFileForContext } from "@/lib/upload-utils";
import { uploadFile } from "@/lib/upload-utils";
interface OnboardingUploadProps {
onComplete: () => void;
@ -29,7 +29,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
setIsUploading(true);
try {
setCurrentStep(0);
await uploadFileForContext(file);
await uploadFile(file, true);
console.log("Document uploaded successfully");
} catch (error) {
console.error("Upload failed", (error as Error).message);
@ -76,7 +76,7 @@ const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
onClick={handleUploadClick}
disabled={isUploading}
>
{isUploading ? "Uploading..." : "Add a Document"}
<div>{isUploading ? "Uploading..." : "Add a document"}</div>
</Button>
<input
ref={fileInputRef}

View file

@ -30,12 +30,12 @@ export function ProgressBar({ currentStep, totalSteps, onSkip }: ProgressBarProp
<div className="flex-1 flex justify-end">
{currentStep > 0 && onSkip && (
<Button
variant="ghost"
variant="link"
size="sm"
onClick={onSkip}
className="flex items-center gap-2 text-xs text-muted-foreground"
className="flex items-center gap-2 text-mmd !text-placeholder-foreground hover:!text-foreground hover:!no-underline"
>
Skip onboarding
Skip overview
<ArrowRight className="w-4 h-4" />
</Button>
)}

View file

@ -0,0 +1,33 @@
import AnimatedProcessingIcon from "@/components/ui/animated-processing-icon";
import { cn } from "@/lib/utils";
export function TabTrigger({
children,
selected,
isLoading,
}: {
children: React.ReactNode;
selected: boolean;
isLoading: boolean;
}) {
return (
<div className="flex flex-col relative items-start justify-between gap-4 h-full w-full">
<div
className={cn(
"flex absolute items-center justify-center h-full w-full transition-opacity duration-200",
isLoading && selected ? "opacity-100" : "opacity-0",
)}
>
<AnimatedProcessingIcon className="text-current shrink-0 h-10 w-10" />
</div>
<div
className={cn(
"flex flex-col items-start justify-between gap-4 h-full w-full transition-opacity duration-200",
isLoading && selected ? "opacity-0" : "opacity-100",
)}
>
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,156 @@
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { ModelProvider } from "../helpers/model-helpers";
import OpenAILogo from "@/components/logo/openai-logo";
import IBMLogo from "@/components/logo/ibm-logo";
import OllamaLogo from "@/components/logo/ollama-logo";
import { useAuth } from "@/contexts/auth-context";
import { ReactNode, useState } from "react";
import OpenAISettingsDialog from "./openai-settings-dialog";
import OllamaSettingsDialog from "./ollama-settings-dialog";
import WatsonxSettingsDialog from "./watsonx-settings-dialog";
import { cn } from "@/lib/utils";
import Link from "next/link";
export const ModelProviders = () => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const [dialogOpen, setDialogOpen] = useState<ModelProvider | undefined>();
const modelProvidersMap: Record<
ModelProvider,
{
name: string;
logo: (props: React.SVGProps<SVGSVGElement>) => ReactNode;
logoColor: string;
logoBgColor: string;
}
> = {
openai: {
name: "OpenAI",
logo: OpenAILogo,
logoColor: "text-black",
logoBgColor: "bg-white",
},
ollama: {
name: "Ollama",
logo: OllamaLogo,
logoColor: "text-black",
logoBgColor: "bg-white",
},
watsonx: {
name: "IBM watsonx.ai",
logo: IBMLogo,
logoColor: "text-white",
logoBgColor: "bg-[#1063FE]",
},
};
const currentProviderKey =
(settings.provider?.model_provider as ModelProvider) || "openai";
// Get all provider keys with active provider first
const allProviderKeys: ModelProvider[] = ["openai", "ollama", "watsonx"];
const sortedProviderKeys = [
currentProviderKey,
...allProviderKeys.filter((key) => key !== currentProviderKey),
];
return (
<>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{sortedProviderKeys.map((providerKey) => {
const {
name,
logo: Logo,
logoColor,
logoBgColor,
} = modelProvidersMap[providerKey];
const isActive = providerKey === currentProviderKey;
return (
<Card
key={providerKey}
className={cn(
"relative flex flex-col",
!isActive && "text-muted-foreground"
)}
>
<CardHeader>
<div className="flex flex-col items-start justify-between">
<div className="flex flex-col gap-3">
<div className="mb-1">
<div
className={cn(
"w-8 h-8 rounded flex items-center justify-center border",
isActive ? logoBgColor : "bg-muted"
)}
>
{
<Logo
className={
isActive ? logoColor : "text-muted-foreground"
}
/>
}
</div>
</div>
<CardTitle className="flex flex-row items-center gap-2">
{name}
{isActive && (
<div className="h-2 w-2 bg-accent-emerald-foreground rounded-full" />
)}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end space-y-4">
{isActive ? (
<Button
variant="outline"
onClick={() => setDialogOpen(providerKey)}
>
Edit Setup
</Button>
) : (
<p>
See{" "}
<Link
href="https://docs.openr.ag/install/#application-onboarding"
className="text-accent-purple-foreground"
target="_blank"
rel="noopener noreferrer"
>
Application onboarding docs
</Link>{" "}
for configuration detail.
</p>
)}
</CardContent>
</Card>
);
})}
</div>
<OpenAISettingsDialog
open={dialogOpen === "openai"}
setOpen={() => setDialogOpen(undefined)}
/>
<OllamaSettingsDialog
open={dialogOpen === "ollama"}
setOpen={() => setDialogOpen(undefined)}
/>
<WatsonxSettingsDialog
open={dialogOpen === "watsonx"}
setOpen={() => setDialogOpen(undefined)}
/>
</>
);
};
export default ModelProviders;

View file

@ -0,0 +1,122 @@
import { Controller, useFormContext } from "react-hook-form";
import { LabelWrapper } from "@/components/label-wrapper";
import { ReactNode, useEffect } from "react";
import { ModelOption } from "@/app/api/queries/useGetModelsQuery";
import { ModelSelector } from "@/app/onboarding/components/model-selector";
interface ModelSelectorsProps {
languageModels: ModelOption[];
embeddingModels: ModelOption[];
isLoadingModels: boolean;
logo: ReactNode;
languageModelName?: string;
embeddingModelName?: string;
}
export function ModelSelectors({
languageModels,
embeddingModels,
isLoadingModels,
logo,
languageModelName = "llmModel",
embeddingModelName = "embeddingModel",
}: ModelSelectorsProps) {
const {
control,
watch,
formState: { errors },
setValue,
} = useFormContext<Record<string, any>>();
const llmModel = watch(languageModelName);
const embeddingModel = watch(embeddingModelName);
const defaultLlmModel =
languageModels.find((model) => model.default)?.value ||
languageModels[0]?.value;
const defaultEmbeddingModel =
embeddingModels.find((model) => model.default)?.value ||
embeddingModels[0]?.value;
useEffect(() => {
if (defaultLlmModel && !llmModel) {
setValue(languageModelName, defaultLlmModel, { shouldValidate: true });
}
if (defaultEmbeddingModel && !embeddingModel) {
setValue(embeddingModelName, defaultEmbeddingModel, {
shouldValidate: true,
});
}
}, [defaultLlmModel, defaultEmbeddingModel, setValue]);
return (
<>
<div className="space-y-2">
<LabelWrapper
label="Embedding model"
helperText="Model used for knowledge ingest and retrieval"
id="embedding-model"
required={true}
>
<Controller
control={control}
name={embeddingModelName}
rules={{ required: "Embedding model is required" }}
render={({ field }) => (
<ModelSelector
options={embeddingModels}
icon={logo}
noOptionsPlaceholder={
isLoadingModels
? "Loading models..."
: "No embedding models detected"
}
placeholder="Select an embedding model"
value={field.value}
onValueChange={field.onChange}
/>
)}
/>
</LabelWrapper>
{embeddingModels.length > 0 && errors[embeddingModelName] && (
<p className="text-sm text-destructive">
{errors[embeddingModelName]?.message as string}
</p>
)}
</div>
<div className="space-y-2">
<LabelWrapper
label="Language model"
helperText="Model used for chat"
id="language-model"
required={true}
>
<Controller
control={control}
name={languageModelName}
rules={{ required: "Language model is required" }}
render={({ field }) => (
<ModelSelector
options={languageModels}
icon={logo}
noOptionsPlaceholder={
isLoadingModels
? "Loading models..."
: "No language models detected"
}
placeholder="Select a language model"
value={field.value}
onValueChange={field.onChange}
/>
)}
/>
</LabelWrapper>
{languageModels.length > 0 && errors[languageModelName] && (
<p className="text-sm text-destructive">
{errors[languageModelName]?.message as string}
</p>
)}
</div>
</>
);
}

View file

@ -0,0 +1,105 @@
import OllamaLogo from "@/components/logo/ollama-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
OllamaSettingsForm,
type OllamaSettingsFormData,
} from "./ollama-settings-form";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
const OllamaSettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const isOllamaConfigured = settings.provider?.model_provider === "ollama";
const methods = useForm<OllamaSettingsFormData>({
mode: "onSubmit",
defaultValues: {
endpoint: isOllamaConfigured
? settings.provider?.endpoint
: "http://localhost:11434",
llmModel: isOllamaConfigured ? settings.agent?.llm_model : "",
embeddingModel: isOllamaConfigured
? settings.knowledge?.embedding_model
: "",
},
});
const { handleSubmit } = methods;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
toast.success("Ollama settings updated successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to update Ollama settings", {
description: error.message,
});
},
});
const onSubmit = (data: OllamaSettingsFormData) => {
settingsMutation.mutate({
endpoint: data.endpoint,
model_provider: "ollama",
llm_model: data.llmModel,
embedding_model: data.embeddingModel,
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<OllamaLogo className="text-black" />
</div>
Ollama Setup
</DialogTitle>
</DialogHeader>
<OllamaSettingsForm />
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={settingsMutation.isPending}>
{settingsMutation.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default OllamaSettingsDialog;

View file

@ -0,0 +1,81 @@
import { useFormContext } from "react-hook-form";
import { LabelWrapper } from "@/components/label-wrapper";
import { Input } from "@/components/ui/input";
import { useGetOllamaModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import { useDebouncedValue } from "@/lib/debounce";
import OllamaLogo from "@/components/logo/ollama-logo";
import { ModelSelectors } from "./model-selectors";
export interface OllamaSettingsFormData {
endpoint: string;
llmModel: string;
embeddingModel: string;
}
export function OllamaSettingsForm() {
const {
register,
watch,
formState: { errors, isDirty },
} = useFormContext<OllamaSettingsFormData>();
const endpoint = watch("endpoint");
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetOllamaModelsQuery(
{
endpoint: debouncedEndpoint,
},
{
enabled: isDirty && !!debouncedEndpoint,
}
);
const languageModels = modelsData?.language_models || [];
const embeddingModels = modelsData?.embedding_models || [];
const endpointError = modelsError
? "Connection failed. Check your Ollama server URL."
: errors.endpoint?.message;
return (
<div className="space-y-4">
<div className="space-y-2">
<LabelWrapper
label="Ollama Base URL"
helperText="Base URL of your Ollama server"
required
id="endpoint"
>
<Input
{...register("endpoint", {
required: "Ollama base URL is required",
})}
className={endpointError ? "!border-destructive" : ""}
id="endpoint"
type="text"
placeholder="http://localhost:11434"
/>
</LabelWrapper>
{endpointError && (
<p className="text-sm text-destructive">{endpointError}</p>
)}
{isLoadingModels && (
<p className="text-sm text-muted-foreground">
Validating connection...
</p>
)}
</div>
<ModelSelectors
languageModels={languageModels}
embeddingModels={embeddingModels}
isLoadingModels={isLoadingModels}
logo={<OllamaLogo className="w-4 h-4" />}
/>
</div>
);
}

View file

@ -0,0 +1,116 @@
import OpenAILogo from "@/components/logo/openai-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
OpenAISettingsForm,
type OpenAISettingsFormData,
} from "./openai-settings-form";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
const OpenAISettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const isOpenAIConfigured = settings.provider?.model_provider === "openai";
const methods = useForm<OpenAISettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
llmModel: isOpenAIConfigured ? settings.agent?.llm_model : "",
embeddingModel: isOpenAIConfigured
? settings.knowledge?.embedding_model
: "",
},
});
const { handleSubmit } = methods;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
toast.success("OpenAI settings updated successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to update OpenAI settings", {
description: error.message,
});
},
});
const onSubmit = (data: OpenAISettingsFormData) => {
const payload: {
api_key?: string;
model_provider: string;
llm_model: string;
embedding_model: string;
} = {
model_provider: "openai",
llm_model: data.llmModel,
embedding_model: data.embeddingModel,
};
// Only include api_key if a value was entered
if (data.apiKey) {
payload.api_key = data.apiKey;
}
// Submit the update
settingsMutation.mutate(payload);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<OpenAILogo className="text-black" />
</div>
OpenAI Setup
</DialogTitle>
</DialogHeader>
<OpenAISettingsForm isCurrentProvider={isOpenAIConfigured} />
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={settingsMutation.isPending}>
{settingsMutation.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default OpenAISettingsDialog;

View file

@ -0,0 +1,129 @@
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import { LabelWrapper } from "@/components/label-wrapper";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useGetOpenAIModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import { useDebouncedValue } from "@/lib/debounce";
import { AnimatedConditional } from "@/components/animated-conditional";
import OpenAILogo from "@/components/logo/openai-logo";
import { ModelSelectors } from "./model-selectors";
export interface OpenAISettingsFormData {
apiKey: string;
llmModel: string;
embeddingModel: string;
}
export function OpenAISettingsForm({
isCurrentProvider = false,
}: {
isCurrentProvider: boolean;
}) {
const [useExistingKey, setUseExistingKey] = useState(true);
const {
register,
watch,
setValue,
clearErrors,
formState: { errors },
} = useFormContext<OpenAISettingsFormData>();
const apiKey = watch("apiKey");
const debouncedApiKey = useDebouncedValue(apiKey, 500);
// Handle switch change
const handleUseExistingKeyChange = (checked: boolean) => {
setUseExistingKey(checked);
if (checked) {
// Clear the API key field when using existing key
setValue("apiKey", "");
}
};
// Clear form errors when useExistingKey changes
useEffect(() => {
clearErrors("apiKey");
}, [useExistingKey, clearErrors]);
const shouldFetchModels = isCurrentProvider
? useExistingKey
? true
: !!debouncedApiKey
: !!debouncedApiKey;
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetOpenAIModelsQuery(
{
apiKey: useExistingKey ? "" : debouncedApiKey,
},
{
enabled: shouldFetchModels,
}
);
const languageModels = modelsData?.language_models || [];
const embeddingModels = modelsData?.embedding_models || [];
const apiKeyError = modelsError
? "Invalid OpenAI API key. Verify or replace the key."
: errors.apiKey?.message;
return (
<div className="space-y-4">
<div className="space-y-2">
{isCurrentProvider && (
<LabelWrapper
label="Use existing OpenAI API key"
id="use-existing-key"
description="Reuse the key from your environment config. Turn off to enter a different key."
flex
>
<Switch
checked={useExistingKey}
onCheckedChange={handleUseExistingKeyChange}
/>
</LabelWrapper>
)}
<AnimatedConditional
isOpen={!useExistingKey}
duration={0.2}
vertical
className={!useExistingKey ? "!mt-4" : "!mt-0"}
>
<LabelWrapper
label="OpenAI API key"
helperText="The API key for your OpenAI account"
required
id="api-key"
>
<Input
{...register("apiKey", {
required: !useExistingKey ? "API key is required" : false,
})}
className={apiKeyError ? "!border-destructive" : ""}
id="api-key"
type="password"
placeholder="sk-..."
/>
</LabelWrapper>
</AnimatedConditional>
{apiKeyError && (
<p className="text-sm text-destructive">{apiKeyError}</p>
)}
{isLoadingModels && (
<p className="text-sm text-muted-foreground">Validating API key...</p>
)}
</div>
<ModelSelectors
languageModels={languageModels}
embeddingModels={embeddingModels}
isLoadingModels={isLoadingModels}
logo={<OpenAILogo className="w-4 h-4" />}
/>
</div>
);
}

View file

@ -0,0 +1,124 @@
import IBMLogo from "@/components/logo/ibm-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import {
WatsonxSettingsForm,
type WatsonxSettingsFormData,
} from "./watsonx-settings-form";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useAuth } from "@/contexts/auth-context";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
const WatsonxSettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const isWatsonxConfigured = settings.provider?.model_provider === "watsonx";
const methods = useForm<WatsonxSettingsFormData>({
mode: "onSubmit",
defaultValues: {
endpoint: isWatsonxConfigured
? settings.provider?.endpoint
: "https://us-south.ml.cloud.ibm.com",
apiKey: "",
projectId: isWatsonxConfigured ? settings.provider?.project_id : "",
llmModel: isWatsonxConfigured ? settings.agent?.llm_model : "",
embeddingModel: isWatsonxConfigured
? settings.knowledge?.embedding_model
: "",
},
});
const { handleSubmit } = methods;
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
toast.success("watsonx settings updated successfully");
setOpen(false);
},
onError: (error) => {
toast.error("Failed to update watsonx settings", {
description: error.message,
});
},
});
const onSubmit = (data: WatsonxSettingsFormData) => {
const payload: {
endpoint: string;
api_key?: string;
project_id: string;
model_provider: string;
llm_model: string;
embedding_model: string;
} = {
endpoint: data.endpoint,
project_id: data.projectId,
model_provider: "watsonx",
llm_model: data.llmModel,
embedding_model: data.embeddingModel,
};
// Only include api_key if a value was entered
if (data.apiKey) {
payload.api_key = data.apiKey;
}
// Submit the update
settingsMutation.mutate(payload);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent autoFocus={false} className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<IBMLogo className="text-black" />
</div>
IBM watsonx.ai Setup
</DialogTitle>
</DialogHeader>
<WatsonxSettingsForm isCurrentProvider={isWatsonxConfigured} />
<DialogFooter>
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={settingsMutation.isPending}>
{settingsMutation.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default WatsonxSettingsDialog;

View file

@ -0,0 +1,229 @@
import { useEffect, useState } from "react";
import { useFormContext, Controller } from "react-hook-form";
import { LabelWrapper } from "@/components/label-wrapper";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useGetIBMModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import { useDebouncedValue } from "@/lib/debounce";
import { AnimatedConditional } from "@/components/animated-conditional";
import IBMLogo from "@/components/logo/ibm-logo";
import { ModelSelectors } from "./model-selectors";
import { ModelSelector } from "@/app/onboarding/components/model-selector";
export interface WatsonxSettingsFormData {
endpoint: string;
apiKey: string;
projectId: string;
llmModel: string;
embeddingModel: string;
}
const endpointOptions = [
{
value: "https://us-south.ml.cloud.ibm.com",
label: "https://us-south.ml.cloud.ibm.com",
},
{
value: "https://eu-de.ml.cloud.ibm.com",
label: "https://eu-de.ml.cloud.ibm.com",
},
{
value: "https://eu-gb.ml.cloud.ibm.com",
label: "https://eu-gb.ml.cloud.ibm.com",
},
{
value: "https://au-syd.ml.cloud.ibm.com",
label: "https://au-syd.ml.cloud.ibm.com",
},
{
value: "https://jp-tok.ml.cloud.ibm.com",
label: "https://jp-tok.ml.cloud.ibm.com",
},
{
value: "https://ca-tor.ml.cloud.ibm.com",
label: "https://ca-tor.ml.cloud.ibm.com",
},
];
export function WatsonxSettingsForm({
isCurrentProvider = false,
}: {
isCurrentProvider: boolean;
}) {
const [useExistingKey, setUseExistingKey] = useState(true);
const {
control,
register,
watch,
setValue,
clearErrors,
formState: { errors },
} = useFormContext<WatsonxSettingsFormData>();
const endpoint = watch("endpoint");
const apiKey = watch("apiKey");
const projectId = watch("projectId");
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
const debouncedApiKey = useDebouncedValue(apiKey, 500);
const debouncedProjectId = useDebouncedValue(projectId, 500);
// Handle switch change
const handleUseExistingKeyChange = (checked: boolean) => {
setUseExistingKey(checked);
if (checked) {
// Clear the API key field when using existing key
setValue("apiKey", "");
}
};
// Clear form errors when useExistingKey changes
useEffect(() => {
clearErrors("apiKey");
}, [useExistingKey, clearErrors]);
const shouldFetchModels = isCurrentProvider
? useExistingKey
? !!debouncedEndpoint && !!debouncedProjectId
: !!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId
: !!debouncedEndpoint && !!debouncedProjectId && !!debouncedApiKey;
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetIBMModelsQuery(
{
endpoint: debouncedEndpoint,
apiKey: useExistingKey ? "" : debouncedApiKey,
projectId: debouncedProjectId,
},
{
enabled: shouldFetchModels,
}
);
const languageModels = modelsData?.language_models || [];
const embeddingModels = modelsData?.embedding_models || [];
return (
<div className="space-y-4">
<div className="space-y-2">
<LabelWrapper
label="watsonx.ai API Endpoint"
helperText="Base URL of the API"
id="api-endpoint"
required
>
<Controller
control={control}
name="endpoint"
rules={{ required: "API endpoint is required" }}
render={({ field }) => (
<ModelSelector
options={endpointOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
value={field.value}
custom
onValueChange={field.onChange}
searchPlaceholder="Search endpoint..."
noOptionsPlaceholder="No endpoints available"
placeholder="Select endpoint..."
hasError={!!errors.endpoint || !!modelsError}
/>
)}
/>
</LabelWrapper>
{errors.endpoint && (
<p className="text-sm text-destructive">{errors.endpoint.message}</p>
)}
</div>
<div className="space-y-2">
<LabelWrapper
label="watsonx Project ID"
helperText="Project ID for the model"
required
id="project-id"
>
<Input
{...register("projectId", {
required: "Project ID is required",
})}
className={
errors.projectId || modelsError ? "!border-destructive" : ""
}
id="project-id"
type="text"
placeholder="your-project-id"
/>
</LabelWrapper>
{errors.projectId && (
<p className="text-sm text-destructive">{errors.projectId.message}</p>
)}
</div>
<div className={useExistingKey ? "space-y-3" : "space-y-2"}>
{isCurrentProvider && (
<LabelWrapper
label="Use existing watsonx API key"
id="use-existing-key"
description="Reuse the key from your environment config. Turn off to enter a different key."
flex
>
<Switch
checked={useExistingKey}
onCheckedChange={handleUseExistingKeyChange}
/>
</LabelWrapper>
)}
<AnimatedConditional
isOpen={!useExistingKey}
duration={0.2}
vertical
className={!useExistingKey ? "!mt-4" : "!mt-0"}
>
<LabelWrapper
label="watsonx API key"
helperText="API key to access watsonx.ai"
required
id="api-key"
>
<Input
{...register("apiKey", {
required: !useExistingKey ? "API key is required" : false,
})}
className={
errors.apiKey || modelsError ? "!border-destructive" : ""
}
id="api-key"
type="password"
placeholder="your-api-key"
/>
</LabelWrapper>
{errors.apiKey && (
<p className="text-sm text-destructive mt-2">
{errors.apiKey.message}
</p>
)}
</AnimatedConditional>
{isLoadingModels && (
<p className="text-sm text-muted-foreground">
Validating configuration...
</p>
)}
{modelsError && (
<p className="text-sm text-destructive">
Connection failed. Check your configuration.
</p>
)}
</div>
<ModelSelectors
languageModels={languageModels}
embeddingModels={embeddingModels}
isLoadingModels={isLoadingModels}
logo={<IBMLogo className="w-4 h-4 text-[#1063FE]" />}
/>
</div>
);
}

View file

@ -4,7 +4,6 @@ import { ArrowUpRight, Loader2, Minus, PlugZap, Plus } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useCallback, useEffect, useState } from "react";
import { useUpdateFlowSettingMutation } from "@/app/api/mutations/useUpdateFlowSettingMutation";
import {
useGetIBMModelsQuery,
useGetOllamaModelsQuery,
@ -53,6 +52,8 @@ import { ModelSelectItems } from "./helpers/model-select-item";
import GoogleDriveIcon from "./icons/google-drive-icon";
import OneDriveIcon from "./icons/one-drive-icon";
import SharePointIcon from "./icons/share-point-icon";
import ModelProviders from "./components/model-providers";
import { useUpdateSettingsMutation } from "../api/mutations/useUpdateSettingsMutation";
const { MAX_SYSTEM_PROMPT_CHARS } = UI_CONSTANTS;
@ -138,7 +139,9 @@ function KnowledgeSourcesPage() {
// Fetch available models based on provider
const { data: openaiModelsData } = useGetOpenAIModelsQuery(
undefined, // Let backend use stored API key from configuration
{
apiKey: ""
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "openai",
@ -146,7 +149,9 @@ function KnowledgeSourcesPage() {
);
const { data: ollamaModelsData } = useGetOllamaModelsQuery(
undefined, // No params for now, could be extended later
{
endpoint: settings.provider?.endpoint,
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "ollama",
@ -154,7 +159,11 @@ function KnowledgeSourcesPage() {
);
const { data: ibmModelsData } = useGetIBMModelsQuery(
undefined, // No params for now, could be extended later
{
endpoint: settings.provider?.endpoint,
apiKey: "",
projectId: settings.provider?.project_id,
},
{
enabled:
(isAuthenticated || isNoAuthMode) && currentProvider === "watsonx",
@ -172,7 +181,7 @@ function KnowledgeSourcesPage() {
: openaiModelsData; // fallback to openai
// Mutations
const updateFlowSettingMutation = useUpdateFlowSettingMutation({
const updateSettingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
console.log("Setting updated successfully");
},
@ -183,8 +192,8 @@ function KnowledgeSourcesPage() {
// Debounced update function
const debouncedUpdate = useDebounce(
(variables: Parameters<typeof updateFlowSettingMutation.mutate>[0]) => {
updateFlowSettingMutation.mutate(variables);
(variables: Parameters<typeof updateSettingsMutation.mutate>[0]) => {
updateSettingsMutation.mutate(variables);
},
500
);
@ -230,20 +239,20 @@ function KnowledgeSourcesPage() {
// Update model selection immediately
const handleModelChange = (newModel: string) => {
updateFlowSettingMutation.mutate({ llm_model: newModel });
updateSettingsMutation.mutate({ llm_model: newModel });
};
// Update system prompt with save button
const handleSystemPromptSave = () => {
updateFlowSettingMutation.mutate({ system_prompt: systemPrompt });
updateSettingsMutation.mutate({ system_prompt: systemPrompt });
};
// Update embedding model selection immediately
const handleEmbeddingModelChange = (newModel: string) => {
updateFlowSettingMutation.mutate({ embedding_model: newModel });
updateSettingsMutation.mutate({ embedding_model: newModel });
};
const isEmbeddingModelSelectDisabled = updateFlowSettingMutation.isPending;
const isEmbeddingModelSelectDisabled = updateSettingsMutation.isPending;
// Update chunk size setting with debounce
const handleChunkSizeChange = (value: string) => {
@ -262,17 +271,17 @@ function KnowledgeSourcesPage() {
// Update docling settings
const handleTableStructureChange = (checked: boolean) => {
setTableStructure(checked);
updateFlowSettingMutation.mutate({ table_structure: checked });
updateSettingsMutation.mutate({ table_structure: checked });
};
const handleOcrChange = (checked: boolean) => {
setOcr(checked);
updateFlowSettingMutation.mutate({ ocr: checked });
updateSettingsMutation.mutate({ ocr: checked });
};
const handlePictureDescriptionsChange = (checked: boolean) => {
setPictureDescriptions(checked);
updateFlowSettingMutation.mutate({ picture_descriptions: checked });
updateSettingsMutation.mutate({ picture_descriptions: checked });
};
// Helper function to get connector icon
@ -715,7 +724,7 @@ function KnowledgeSourcesPage() {
<div
className={`w-8 h-8 ${
connector ? "bg-white" : "bg-muted grayscale"
} rounded flex items-center justify-center`}
} rounded flex items-center justify-center border`}
>
{connector.icon}
</div>
@ -738,6 +747,7 @@ function KnowledgeSourcesPage() {
{connector?.status === "connected" ? (
<>
<Button
variant="outline"
onClick={() => navigateToKnowledgePage(connector)}
disabled={isSyncing === connector.id}
className="w-full cursor-pointer"
@ -804,6 +814,17 @@ function KnowledgeSourcesPage() {
})}
</div>
</div>
{/* Model Providers Section */}
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold tracking-tight mb-2">
Model Providers
</h2>
</div>
<ModelProviders />
</div>
{/* Agent Behavior Section */}
<Card>
<CardHeader>
@ -872,7 +893,8 @@ function KnowledgeSourcesPage() {
</div>
</div>
<CardDescription>
This Agent retrieves from your knowledge and generates chat responses. Edit in Langflow for full control.
This Agent retrieves from your knowledge and generates chat
responses. Edit in Langflow for full control.
</CardDescription>
</CardHeader>
<CardContent>
@ -928,14 +950,14 @@ function KnowledgeSourcesPage() {
<Button
onClick={handleSystemPromptSave}
disabled={
updateFlowSettingMutation.isPending ||
updateSettingsMutation.isPending ||
systemPrompt.length > MAX_SYSTEM_PROMPT_CHARS
}
className="min-w-[120px]"
size="sm"
variant="outline"
>
{updateFlowSettingMutation.isPending ? (
{updateSettingsMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
@ -953,9 +975,7 @@ function KnowledgeSourcesPage() {
<Card>
<CardHeader>
<div className="flex items-center justify-between mb-3">
<CardTitle className="text-lg">
Knowledge Ingest
</CardTitle>
<CardTitle className="text-lg">Knowledge Ingest</CardTitle>
<div className="flex gap-2">
<ConfirmationDialog
trigger={
@ -1019,7 +1039,8 @@ function KnowledgeSourcesPage() {
</div>
</div>
<CardDescription>
Configure how files are ingested and stored for retrieval. Edit in Langflow for full control.
Configure how files are ingested and stored for retrieval. Edit in
Langflow for full control.
</CardDescription>
</CardHeader>
<CardContent>

View file

@ -2,52 +2,54 @@ import { motion } from "framer-motion";
import { ANIMATION_DURATION } from "@/lib/constants";
export const AnimatedConditional = ({
children,
isOpen,
className,
slide = false,
delay,
vertical = false,
children,
isOpen,
className,
slide = false,
delay,
duration,
vertical = false,
}: {
children: React.ReactNode;
isOpen: boolean;
className?: string;
delay?: number;
vertical?: boolean;
slide?: boolean;
children: React.ReactNode;
isOpen: boolean;
className?: string;
delay?: number;
duration?: number;
vertical?: boolean;
slide?: boolean;
}) => {
const animationProperty = slide
? vertical
? "translateY"
: "translateX"
: vertical
? "height"
: "width";
const animationValue = isOpen
? slide
? "0px"
: "auto"
: slide
? "-100%"
: "0px";
const animationProperty = slide
? vertical
? "translateY"
: "translateX"
: vertical
? "height"
: "width";
const animationValue = isOpen
? slide
? "0px"
: "auto"
: slide
? "-100%"
: "0px";
return (
<motion.div
initial={{ [animationProperty]: animationValue }}
animate={{ [animationProperty]: animationValue }}
exit={{ [animationProperty]: 0 }}
transition={{
duration: ANIMATION_DURATION,
ease: "easeOut",
delay: delay,
}}
style={{
overflow: "hidden",
whiteSpace: vertical ? "normal" : "nowrap",
}}
className={className}
>
{children}
</motion.div>
);
return (
<motion.div
initial={{ [animationProperty]: animationValue }}
animate={{ [animationProperty]: animationValue }}
exit={{ [animationProperty]: 0 }}
transition={{
duration: duration ?? ANIMATION_DURATION,
ease: "easeOut",
delay: delay,
}}
style={{
overflow: "hidden",
whiteSpace: vertical ? "normal" : "nowrap",
}}
className={className}
>
{children}
</motion.div>
);
};

View file

@ -8,8 +8,8 @@ import {
useGetConversationsQuery,
} from "@/app/api/queries/useGetConversationsQuery";
import type { Settings } from "@/app/api/queries/useGetSettingsQuery";
import { OnboardingContent } from "@/app/new-onboarding/components/onboarding-content";
import { ProgressBar } from "@/app/new-onboarding/components/progress-bar";
import { OnboardingContent } from "@/app/onboarding/components/onboarding-content";
import { ProgressBar } from "@/app/onboarding/components/progress-bar";
import { AnimatedConditional } from "@/components/animated-conditional";
import { Header } from "@/components/header";
import { Navigation } from "@/components/navigation";
@ -156,7 +156,7 @@ export function ChatRenderer({
}}
className={cn(
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
!showLayout && "absolute",
!showLayout && "absolute max-h-[calc(100vh-190px)]",
showLayout && !isOnChatPage && "bg-background",
)}
>
@ -199,7 +199,7 @@ export function ChatRenderer({
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
className={cn("absolute bottom-10 left-0 right-0")}
className={cn("absolute bottom-6 left-0 right-0")}
>
<ProgressBar
currentStep={currentStep}

View file

@ -51,8 +51,8 @@ const AnimatedProcessingIcon = ({
return (
<svg
width="16"
height="16"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "openrag"
version = "0.1.25"
version = "0.1.26"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"

View file

@ -0,0 +1,437 @@
"""Provider validation utilities for testing API keys and models during onboarding."""
import httpx
from utils.container_utils import transform_localhost_url
from utils.logging_config import get_logger
logger = get_logger(__name__)
async def validate_provider_setup(
provider: str,
api_key: str = None,
embedding_model: str = None,
llm_model: str = None,
endpoint: str = None,
project_id: str = None,
) -> None:
"""
Validate provider setup by testing completion with tool calling and embedding.
Args:
provider: Provider name ('openai', 'watsonx', 'ollama')
api_key: API key for the provider (optional for ollama)
embedding_model: Embedding model to test
llm_model: LLM model to test
endpoint: Provider endpoint (required for ollama and watsonx)
project_id: Project ID (required for watsonx)
Raises:
Exception: If validation fails with message "Setup failed, please try again or select a different provider."
"""
provider_lower = provider.lower()
try:
logger.info(f"Starting validation for provider: {provider_lower}")
# Test completion with tool calling
await test_completion_with_tools(
provider=provider_lower,
api_key=api_key,
llm_model=llm_model,
endpoint=endpoint,
project_id=project_id,
)
# Test embedding
await test_embedding(
provider=provider_lower,
api_key=api_key,
embedding_model=embedding_model,
endpoint=endpoint,
project_id=project_id,
)
logger.info(f"Validation successful for provider: {provider_lower}")
except Exception as e:
logger.error(f"Validation failed for provider {provider_lower}: {str(e)}")
raise Exception("Setup failed, please try again or select a different provider.")
async def test_completion_with_tools(
provider: str,
api_key: str = None,
llm_model: str = None,
endpoint: str = None,
project_id: str = None,
) -> None:
"""Test completion with tool calling for the provider."""
if provider == "openai":
await _test_openai_completion_with_tools(api_key, llm_model)
elif provider == "watsonx":
await _test_watsonx_completion_with_tools(api_key, llm_model, endpoint, project_id)
elif provider == "ollama":
await _test_ollama_completion_with_tools(llm_model, endpoint)
else:
raise ValueError(f"Unknown provider: {provider}")
async def test_embedding(
provider: str,
api_key: str = None,
embedding_model: str = None,
endpoint: str = None,
project_id: str = None,
) -> None:
"""Test embedding generation for the provider."""
if provider == "openai":
await _test_openai_embedding(api_key, embedding_model)
elif provider == "watsonx":
await _test_watsonx_embedding(api_key, embedding_model, endpoint, project_id)
elif provider == "ollama":
await _test_ollama_embedding(embedding_model, endpoint)
else:
raise ValueError(f"Unknown provider: {provider}")
# OpenAI validation functions
async def _test_openai_completion_with_tools(api_key: str, llm_model: str) -> None:
"""Test OpenAI completion with tool calling."""
try:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
# Simple tool calling test
payload = {
"model": llm_model,
"messages": [
{"role": "user", "content": "What tools do you have available?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state"
}
},
"required": ["location"]
}
}
}
],
"max_tokens": 50,
}
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.openai.com/v1/chat/completions",
headers=headers,
json=payload,
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"OpenAI completion test failed: {response.status_code} - {response.text}")
raise Exception(f"OpenAI API error: {response.status_code}")
logger.info("OpenAI completion with tool calling test passed")
except httpx.TimeoutException:
logger.error("OpenAI completion test timed out")
raise Exception("Request timed out")
except Exception as e:
logger.error(f"OpenAI completion test failed: {str(e)}")
raise
async def _test_openai_embedding(api_key: str, embedding_model: str) -> None:
"""Test OpenAI embedding generation."""
try:
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": embedding_model,
"input": "test embedding",
}
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.openai.com/v1/embeddings",
headers=headers,
json=payload,
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"OpenAI embedding test failed: {response.status_code} - {response.text}")
raise Exception(f"OpenAI API error: {response.status_code}")
data = response.json()
if not data.get("data") or len(data["data"]) == 0:
raise Exception("No embedding data returned")
logger.info("OpenAI embedding test passed")
except httpx.TimeoutException:
logger.error("OpenAI embedding test timed out")
raise Exception("Request timed out")
except Exception as e:
logger.error(f"OpenAI embedding test failed: {str(e)}")
raise
# IBM Watson validation functions
async def _test_watsonx_completion_with_tools(
api_key: str, llm_model: str, endpoint: str, project_id: str
) -> None:
"""Test IBM Watson completion with tool calling."""
try:
# Get bearer token from IBM IAM
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://iam.cloud.ibm.com/identity/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "urn:ibm:params:oauth:grant-type:apikey",
"apikey": api_key,
},
timeout=30.0,
)
if token_response.status_code != 200:
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
raise Exception("Failed to authenticate with IBM Watson")
bearer_token = token_response.json().get("access_token")
if not bearer_token:
raise Exception("No access token received from IBM")
headers = {
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
}
# Test completion with tools
url = f"{endpoint}/ml/v1/text/chat"
params = {"version": "2024-09-16"}
payload = {
"model_id": llm_model,
"project_id": project_id,
"messages": [
{"role": "user", "content": "What tools do you have available?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state"
}
},
"required": ["location"]
}
}
}
],
"max_tokens": 50,
}
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=headers,
params=params,
json=payload,
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"IBM Watson completion test failed: {response.status_code} - {response.text}")
raise Exception(f"IBM Watson API error: {response.status_code}")
logger.info("IBM Watson completion with tool calling test passed")
except httpx.TimeoutException:
logger.error("IBM Watson completion test timed out")
raise Exception("Request timed out")
except Exception as e:
logger.error(f"IBM Watson completion test failed: {str(e)}")
raise
async def _test_watsonx_embedding(
api_key: str, embedding_model: str, endpoint: str, project_id: str
) -> None:
"""Test IBM Watson embedding generation."""
try:
# Get bearer token from IBM IAM
async with httpx.AsyncClient() as client:
token_response = await client.post(
"https://iam.cloud.ibm.com/identity/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "urn:ibm:params:oauth:grant-type:apikey",
"apikey": api_key,
},
timeout=30.0,
)
if token_response.status_code != 200:
logger.error(f"IBM IAM token request failed: {token_response.status_code}")
raise Exception("Failed to authenticate with IBM Watson")
bearer_token = token_response.json().get("access_token")
if not bearer_token:
raise Exception("No access token received from IBM")
headers = {
"Authorization": f"Bearer {bearer_token}",
"Content-Type": "application/json",
}
# Test embedding
url = f"{endpoint}/ml/v1/text/embeddings"
params = {"version": "2024-09-16"}
payload = {
"model_id": embedding_model,
"project_id": project_id,
"inputs": ["test embedding"],
}
async with httpx.AsyncClient() as client:
response = await client.post(
url,
headers=headers,
params=params,
json=payload,
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"IBM Watson embedding test failed: {response.status_code} - {response.text}")
raise Exception(f"IBM Watson API error: {response.status_code}")
data = response.json()
if not data.get("results") or len(data["results"]) == 0:
raise Exception("No embedding data returned")
logger.info("IBM Watson embedding test passed")
except httpx.TimeoutException:
logger.error("IBM Watson embedding test timed out")
raise Exception("Request timed out")
except Exception as e:
logger.error(f"IBM Watson embedding test failed: {str(e)}")
raise
# Ollama validation functions
async def _test_ollama_completion_with_tools(llm_model: str, endpoint: str) -> None:
"""Test Ollama completion with tool calling."""
try:
ollama_url = transform_localhost_url(endpoint)
url = f"{ollama_url}/api/chat"
payload = {
"model": llm_model,
"messages": [
{"role": "user", "content": "What tools do you have available?"}
],
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city and state"
}
},
"required": ["location"]
}
}
}
],
"stream": False,
}
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"Ollama completion test failed: {response.status_code} - {response.text}")
raise Exception(f"Ollama API error: {response.status_code}")
logger.info("Ollama completion with tool calling test passed")
except httpx.TimeoutException:
logger.error("Ollama completion test timed out")
raise Exception("Request timed out")
except Exception as e:
logger.error(f"Ollama completion test failed: {str(e)}")
raise
async def _test_ollama_embedding(embedding_model: str, endpoint: str) -> None:
"""Test Ollama embedding generation."""
try:
ollama_url = transform_localhost_url(endpoint)
url = f"{ollama_url}/api/embeddings"
payload = {
"model": embedding_model,
"prompt": "test embedding",
}
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
timeout=30.0,
)
if response.status_code != 200:
logger.error(f"Ollama embedding test failed: {response.status_code} - {response.text}")
raise Exception(f"Ollama API error: {response.status_code}")
data = response.json()
if not data.get("embedding"):
raise Exception("No embedding data returned")
logger.info("Ollama embedding test passed")
except httpx.TimeoutException:
logger.error("Ollama embedding test timed out")
raise Exception("Request timed out")
except Exception as e:
logger.error(f"Ollama embedding test failed: {str(e)}")
raise

View file

@ -1,5 +1,6 @@
import json
import platform
import time
from starlette.responses import JSONResponse
from utils.container_utils import transform_localhost_url
from utils.logging_config import get_logger
@ -62,6 +63,8 @@ async def get_settings(request, session_manager):
# OpenRAG configuration
"provider": {
"model_provider": provider_config.model_provider,
"endpoint": provider_config.endpoint if provider_config.endpoint else None,
"project_id": provider_config.project_id if provider_config.project_id else None,
# Note: API key is not exposed for security
},
"knowledge": {
@ -182,6 +185,10 @@ async def update_settings(request, session_manager):
"ocr",
"picture_descriptions",
"embedding_model",
"model_provider",
"api_key",
"endpoint",
"project_id",
}
# Check for invalid fields
@ -395,22 +402,117 @@ async def update_settings(request, session_manager):
# Don't fail the entire settings update if flow update fails
# The config will still be saved
# Update provider settings
if "model_provider" in body:
if (
not isinstance(body["model_provider"], str)
or not body["model_provider"].strip()
):
return JSONResponse(
{"error": "model_provider must be a non-empty string"},
status_code=400,
)
current_config.provider.model_provider = body["model_provider"].strip()
config_updated = True
if "api_key" in body:
if not isinstance(body["api_key"], str):
return JSONResponse(
{"error": "api_key must be a string"}, status_code=400
)
# Only update if non-empty string (empty string means keep current value)
if body["api_key"].strip():
current_config.provider.api_key = body["api_key"]
config_updated = True
if "endpoint" in body:
if not isinstance(body["endpoint"], str) or not body["endpoint"].strip():
return JSONResponse(
{"error": "endpoint must be a non-empty string"}, status_code=400
)
current_config.provider.endpoint = body["endpoint"].strip()
config_updated = True
if "project_id" in body:
if (
not isinstance(body["project_id"], str)
or not body["project_id"].strip()
):
return JSONResponse(
{"error": "project_id must be a non-empty string"}, status_code=400
)
current_config.provider.project_id = body["project_id"].strip()
config_updated = True
if not config_updated:
return JSONResponse(
{"error": "No valid fields provided for update"}, status_code=400
)
# Save the updated configuration
if config_manager.save_config_file(current_config):
logger.info(
"Configuration updated successfully", updated_fields=list(body.keys())
)
return JSONResponse({"message": "Configuration updated successfully"})
else:
if not config_manager.save_config_file(current_config):
return JSONResponse(
{"error": "Failed to save configuration"}, status_code=500
)
# Update Langflow global variables if provider settings changed
if any(key in body for key in ["model_provider", "api_key", "endpoint", "project_id"]):
try:
provider = current_config.provider.model_provider.lower() if current_config.provider.model_provider else "openai"
# Set API key for IBM/Watson providers
if (provider == "watsonx") and "api_key" in body:
api_key = body["api_key"]
await clients._create_langflow_global_variable(
"WATSONX_API_KEY", api_key, modify=True
)
logger.info("Set WATSONX_API_KEY global variable in Langflow")
# Set project ID for IBM/Watson providers
if (provider == "watsonx") and "project_id" in body:
project_id = body["project_id"]
await clients._create_langflow_global_variable(
"WATSONX_PROJECT_ID", project_id, modify=True
)
logger.info("Set WATSONX_PROJECT_ID global variable in Langflow")
# Set API key for OpenAI provider
if provider == "openai" and "api_key" in body:
api_key = body["api_key"]
await clients._create_langflow_global_variable(
"OPENAI_API_KEY", api_key, modify=True
)
logger.info("Set OPENAI_API_KEY global variable in Langflow")
# Set base URL for Ollama provider
if provider == "ollama" and "endpoint" in body:
endpoint = transform_localhost_url(body["endpoint"])
await clients._create_langflow_global_variable(
"OLLAMA_BASE_URL", endpoint, modify=True
)
logger.info("Set OLLAMA_BASE_URL global variable in Langflow")
# Update model values across flows if provider changed
if "model_provider" in body:
flows_service = _get_flows_service()
await flows_service.change_langflow_model_value(
provider,
current_config.knowledge.embedding_model,
current_config.agent.llm_model,
current_config.provider.endpoint,
)
logger.info(f"Successfully updated Langflow flows for provider {provider}")
except Exception as e:
logger.error(f"Failed to update Langflow settings: {str(e)}")
# Don't fail the entire settings update if Langflow update fails
# The config was still saved
logger.info(
"Configuration updated successfully", updated_fields=list(body.keys())
)
return JSONResponse({"message": "Configuration updated successfully"})
except Exception as e:
logger.error("Failed to update settings", error=str(e))
return JSONResponse(
@ -533,6 +635,29 @@ async def onboarding(request, flows_service):
{"error": "No valid fields provided for update"}, status_code=400
)
# Validate provider setup before initializing OpenSearch index
try:
from api.provider_validation import validate_provider_setup
provider = current_config.provider.model_provider.lower() if current_config.provider.model_provider else "openai"
logger.info(f"Validating provider setup for {provider}")
await validate_provider_setup(
provider=provider,
api_key=current_config.provider.api_key,
embedding_model=current_config.knowledge.embedding_model,
llm_model=current_config.agent.llm_model,
endpoint=current_config.provider.endpoint,
project_id=current_config.provider.project_id,
)
logger.info(f"Provider setup validation completed successfully for {provider}")
except Exception as e:
logger.error(f"Provider validation failed: {str(e)}")
return JSONResponse(
{"error": str(e)},
status_code=400,
)
# Initialize the OpenSearch index now that we have the embedding model configured
try:
# Import here to avoid circular imports
@ -694,7 +819,7 @@ async def onboarding(request, flows_service):
except Exception as e:
logger.error("Failed to update onboarding settings", error=str(e))
return JSONResponse(
{"error": f"Failed to update onboarding settings: {str(e)}"},
{"error": str(e)},
status_code=500,
)

View file

@ -709,7 +709,7 @@ WATSONX_LLM_COMPONENT_DISPLAY_NAME = os.getenv(
)
OLLAMA_EMBEDDING_COMPONENT_DISPLAY_NAME = os.getenv(
"OLLAMA_EMBEDDING_COMPONENT_DISPLAY_NAME", "Ollama Model"
"OLLAMA_EMBEDDING_COMPONENT_DISPLAY_NAME", "Ollama Embeddings"
)
OLLAMA_LLM_COMPONENT_DISPLAY_NAME = os.getenv("OLLAMA_LLM_COMPONENT_DISPLAY_NAME", "Ollama")

View file

@ -1,5 +1,6 @@
import httpx
from typing import Dict, List
from api.provider_validation import test_embedding
from utils.container_utils import transform_localhost_url
from utils.logging_config import get_logger
@ -9,18 +10,6 @@ logger = get_logger(__name__)
class ModelsService:
"""Service for fetching available models from different AI providers"""
OLLAMA_EMBEDDING_MODELS = [
"nomic-embed-text",
"mxbai-embed-large",
"snowflake-arctic-embed",
"all-minilm",
"bge-m3",
"bge-large",
"paraphrase-multilingual",
"granite-embedding",
"jina-embeddings-v2-base-en",
]
OPENAI_TOOL_CALLING_MODELS = [
"gpt-5",
"gpt-5-mini",
@ -75,7 +64,7 @@ class ModelsService:
{
"value": model_id,
"label": model_id,
"default": model_id == "gpt-5",
"default": model_id == "gpt-4o",
}
)
@ -171,10 +160,12 @@ class ModelsService:
has_tools = TOOL_CALLING_CAPABILITY in capabilities
# Check if it's an embedding model
is_embedding = any(
embed_model in model_name.lower()
for embed_model in self.OLLAMA_EMBEDDING_MODELS
)
try:
await test_embedding("ollama", endpoint=endpoint, embedding_model=model_name)
is_embedding = True
except Exception as e:
logger.warning(f"Failed to test embedding for model {model_name}: {str(e)}")
is_embedding = False
if is_embedding:
# Embedding models only need completion capability
@ -182,7 +173,7 @@ class ModelsService:
{
"value": model_name,
"label": model_name,
"default": False,
"default": "nomic-embed-text" in model_name.lower(),
}
)
elif not is_embedding and has_completion and has_tools:
@ -191,7 +182,7 @@ class ModelsService:
{
"value": model_name,
"label": model_name,
"default": "llama3" in model_name.lower(),
"default": "gpt-oss" in model_name.lower(),
}
)
except Exception as e:

4
uv.lock generated
View file

@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.13"
resolution-markers = [
"platform_machine == 'x86_64' and sys_platform == 'linux'",
@ -2352,7 +2352,7 @@ wheels = [
[[package]]
name = "openrag"
version = "0.1.25"
version = "0.1.26"
source = { editable = "." }
dependencies = [
{ name = "agentd" },