From c451791ea3afa28a34b0aef69757025df5d437be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 11:01:23 +0000 Subject: [PATCH] doc: document AI provider configuration (#216) Add docs/ai-provider-setup.md with OpenCode, OpenRouter and Ollama examples, the /v1 Base URL rule and the settings precedence. Replace Reka examples in the Settings page, .env-sample, compose file and AppHost sample config with generic AiApiKey/AiBaseUrl/AiModelName settings (REKA_API_KEY still works). Treat empty config values as missing so unset compose variables fall back correctly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RmvEuV5wuapUH2RHeMRrWR --- .env-sample | 8 +- README.md | 147 +++++++++--------- docker-compose/note-compose.yaml | 4 +- docs/ai-provider-setup.md | 107 +++++++++++++ docs/docker-compose-deployment.md | 2 + .../Services/AISettingsProviderTests.cs | 42 +++++ src/NoteBookmark.Api/AISettingsProvider.cs | 23 ++- src/NoteBookmark.AppHost/AppHost.cs | 20 ++- src/NoteBookmark.AppHost/appsettings.json | 4 +- .../AISettingsProvider.cs | 23 ++- .../Components/Pages/Settings.razor | 8 +- 11 files changed, 291 insertions(+), 97 deletions(-) create mode 100644 docs/ai-provider-setup.md diff --git a/.env-sample b/.env-sample index 16c5465..9ab090e 100644 --- a/.env-sample +++ b/.env-sample @@ -19,8 +19,12 @@ KEYCLOAK_CLIENT_SECRET=replace-with-client-secret # Optional # Keycloak__RequireHttpsMetadata=false -# AI -REKA_API_KEY=replace-with-reka-api-key +# AI (any OpenAI-compatible provider, see docs/ai-provider-setup.md) +# Base URL must end at /v1, without /chat/completions. +AI_API_KEY=replace-with-your-ai-api-key +AI_BASE_URL=https://openrouter.ai/api/v1 +AI_MODEL_NAME=mistralai/mistral-small-3.2-24b-instruct +# Older setups can keep using REKA_API_KEY instead of AI_API_KEY. # Parser PARSER_BASE_URL=https://...azurewebsites.net/api/parser diff --git a/README.md b/README.md index 12c5415..1229115 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,74 @@ -# Note Bookmark - -![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/10.0-512BD4?logo=dotnet&logoColor=fff) ![MAUI](https://img.shields.io/badge/MAUI-10.0-gray?logo=dotnet&logoColor=white&labelColor=512BD4) ![Android Supported](https://img.shields.io/badge/Android-supported-3DDC84?logo=android&logoColor=white) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) -[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-Compatible-10a37f?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU%2BT3BlbkFJPC90aXRsZT48cGF0aCBmaWxsPSJ3aGl0ZSIgZD0iTTIyLjI4MTkgOS44MjExYTUuOTg0NyA1Ljk4NDcgMCAwIDAtLjUxNTctNC45MTA4IDYuMDQ2MiA2LjA0NjIgMCAwIDAtNi41MDk4LTIuOUE2LjA2NTEgNi4wNjUxIDAgMCAwIDQuOTgwNyA0LjE4MThhNS45ODQ3IDUuOTg0NyAwIDAgMC0zLjk5NzcgMi45IDYuMDQ2MiA2LjA0NjIgMCAwIDAgLjc0MjcgNy4wOTY2IDUuOTggNS45OCAwIDAgMCAuNTExIDQuOTEwNyA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNDYgMi45MDAxQTUuOTg0NyA1Ljk4NDcgMCAwIDAgMTMuMjU5OSAyNGE2LjA1NTcgNi4wNTU3IDAgMCAwIDUuNzcxOC00LjIwNTggNS45ODk0IDUuOTg5NCAwIDAgMCAzLjk5NzctMi45MDAxIDYuMDU1NyA2LjA1NTcgMCAwIDAtLjc0NzUtNy4wNzI5em0tOS4wMjIgMTIuNjA4MWE0LjQ3NTUgNC40NzU1IDAgMCAxLTIuODc2NC0xLjA0MDhsLjE0MTktLjA4MDQgNC43NzgzLTIuNzU4MmEuNzk0OC43OTQ4IDAgMCAwIC4zOTI3LS42ODEzdi02LjczNjlsMi4wMiAxLjE2ODZhLjA3MS4wNzEgMCAwIDEgLjAzOC4wNTJ2NS41ODI2YTQuNTA0IDQuNTA0IDAgMCAxLTQuNDk0NSA0LjQ5NDR6bS05LjY2MDctNC4xMjU0YTQuNDcwOCA0LjQ3MDggMCAwIDEtLjUzNDYtMy4wMTM3bC4xNDIuMDg1MiA0Ljc4MyAyLjc1ODJhLjc3MTIuNzcxMiAwIDAgMCAuNzgwNiAwbDUuODQyOC0zLjM2ODV2Mi4zMzI0YS4wODA0LjA4MDQgMCAwIDEtLjAzMzIuMDYxNUw5Ljc0IDE5Ljk1MDJhNC40OTkyIDQuNDk5MiAwIDAgMS02LjE0MDgtMS42NDY0ek0yLjM0MDggNy44OTU2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY1NS0xLjk3MjhWMTEuNmEuNzY2NC43NjY0IDAgMCAwIC4zODc5LjY3NjVsNS44MTQ0IDMuMzU0My0yLjAyMDEgMS4xNjg1YS4wNzU3LjA3NTcgMCAwIDEtLjA3MSAwbC00LjgzMDMtMi43ODY1QTQuNTA0IDQuNTA0IDAgMCAxIDIuMzQwOCA3Ljg3MnptMTYuNTk2MyAzLjg1NThMMTMuMTAzOCA4LjM2NCAxNS4xMTkyIDcuMmEuMDc1Ny4wNzU3IDAgMCAxIC4wNzEgMGw0LjgzMDMgMi43OTEzYTQuNDk0NCA0LjQ5NDQgMCAwIDEtLjY3NjUgOC4xMDQydi01LjY3NzJhLjc5Ljc5IDAgMCAwLS40MDctLjY2N3ptMi4wMTA3LTMuMDIzMWwtLjE0Mi0uMDg1Mi00Ljc3MzUtMi43ODE4YS43NzU5Ljc3NTkgMCAwIDAtLjc4NTQgMEw5LjQwOSA5LjIyOTdWNi44OTc0YS4wNjYyLjA2NjIgMCAwIDEgLjAyODQtLjA2MTVsNC44MzAzLTIuNzg2NmE0LjQ5OTIgNC40OTkyIDAgMCAxIDYuNjgwMiA0LjY2ek04LjMwNjUgMTIuODYzbC0yLjAyLTEuMTYzOGEuMDgwNC4wODA0IDAgMCAxLS4wMzgtLjA1NjdWNi4wNzQyYTQuNDk5MiA0LjQ5OTIgMCAwIDEgNy4zNzU3LTMuNDUzN2wtLjE0Mi4wODA1TDguNzA0IDUuNDU5YS43OTQ4Ljc5NDggMCAwIDAtLjM5MjcuNjgxM3ptMS4wOTc2LTIuMzY1NGwyLjYwMi0xLjQ5OTggMi42MDY5IDEuNDk5OHYyLjk5OTRsLTIuNTk3NCAxLjQ5OTctMi42MDY3LTEuNDk5N1oiLz48L3N2Zz4%3D)](https://developers.openai.com/) - - - - -I use this project mostly everyday. I build it to help me collecting my thoughts about articles, and blob posts I read during the week and then aggregate them in a #ReadingNotes blog post. You can find those post on my blog [here](https://frankysnotes.com). - -NoteBookmark is composed of three main sections: - -- **Post**: where you can manage a posts "to read", and add notes to them. -- **Generate Summary**: where you can generate a summary of the posts you read. -- **Summaries**: where you can see all the summaries you generated. - -![Slide show of all NoteBookmark Screens](gh/images/NoteBookmark-Tour_hd.gif) - -## Run Options - -- Development: running the Aspire project is the easiest path and everything is wired automatically. -- Production-style: run with containers and deploy to Azure. - -Run locally with Aspire: - -```bash -dotnet run --project src/NoteBookmark.AppHost -``` - -## How to deploy Your own NoteBookmark - -### Get the code on your machine - -- Fork this repository to your account. -- Clone the repository to your local machine. - - -### Deploy the solution (5 mins) - -Using Azure Developer CLI let's initialize your environment. In a terminal, at the root of the project, run the following command. When ask give it a name (ex: NoteBookmark-dev). - -```bash -azd init -``` - -Now let's deploy the solution. Run the following command in the terminal. You will have to select your Azure subscription where you want to deploy the solution, and a location (ex: eastus). - -```bash -azd up -``` - -It should take around five minutes to deploy the solution. Once it's done, you will see the URL for **Deploying service blazor-app**. - -### Secure the App in a few clicks - -The app is now deployed, but it's not secure. Navigate to the Azure Portal, and find the Resource Group you just deployed (ex: rg-notebookmark-dev). In this resource group, open the Container App **Container App**. From the left menu, select **Authentication** and click the **Add identity provider**. - -You can choose between multiple providers, I like to use Microsoft since it's deploy in Azure and I'm already logged in. If Microsoft is choose, select the recomended **Client secret expiration** (ex: 180 days). You can keep all the other default settings. Click **Add**. - -Next time you will navigate to the app, you will be prompt a to login with your Microsoft account. The first time you will have a **Permissions requested** screen, click **Accept**. - -Voila! Your app is now secure. - -## Documentation - -For detailed setup guides and configuration information: -- [Keycloak Container Setup](/docs/keycloak-container-setup.md) - Start a local Keycloak instance if you do not already have one -- [Keycloak Authentication Setup](/docs/keycloak-setup.md) - Complete guide for setting up Keycloak authentication -- [Docker Compose Deployment](/docs/docker-compose-deployment.md) - Deploy NoteBookmark containers (assumes a healthy Keycloak + configured realm) - -## Contributing - -Your contributions are welcome! Take a look at [CONTRIBUTING](/CONTRIBUTING.md) for details. +# Note Bookmark + +![GitHub Release](https://img.shields.io/github/v/release/fboucher/NoteBookmark) ![.NET](https://img.shields.io/badge/10.0-512BD4?logo=dotnet&logoColor=fff) ![MAUI](https://img.shields.io/badge/MAUI-10.0-gray?logo=dotnet&logoColor=white&labelColor=512BD4) ![Android Supported](https://img.shields.io/badge/Android-supported-3DDC84?logo=android&logoColor=white) [![Unit Tests](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml/badge.svg)](https://github.com/FBoucher/NoteBookmark/actions/workflows/running-unit-tests.yml) +[![OpenAI Compatible](https://img.shields.io/badge/OpenAI-Compatible-10a37f?logo=data%3Aimage%2Fsvg%2Bxml%3Bbase64%2CPHN2ZyByb2xlPSJpbWciIHZpZXdCb3g9IjAgMCAyNCAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48dGl0bGU%2BT3BlbkFJPC90aXRsZT48cGF0aCBmaWxsPSJ3aGl0ZSIgZD0iTTIyLjI4MTkgOS44MjExYTUuOTg0NyA1Ljk4NDcgMCAwIDAtLjUxNTctNC45MTA4IDYuMDQ2MiA2LjA0NjIgMCAwIDAtNi41MDk4LTIuOUE2LjA2NTEgNi4wNjUxIDAgMCAwIDQuOTgwNyA0LjE4MThhNS45ODQ3IDUuOTg0NyAwIDAgMC0zLjk5NzcgMi45IDYuMDQ2MiA2LjA0NjIgMCAwIDAgLjc0MjcgNy4wOTY2IDUuOTggNS45OCAwIDAgMCAuNTExIDQuOTEwNyA2LjA1MSA2LjA1MSAwIDAgMCA2LjUxNDYgMi45MDAxQTUuOTg0NyA1Ljk4NDcgMCAwIDAgMTMuMjU5OSAyNGE2LjA1NTcgNi4wNTU3IDAgMCAwIDUuNzcxOC00LjIwNTggNS45ODk0IDUuOTg5NCAwIDAgMCAzLjk5NzctMi45MDAxIDYuMDU1NyA2LjA1NTcgMCAwIDAtLjc0NzUtNy4wNzI5em0tOS4wMjIgMTIuNjA4MWE0LjQ3NTUgNC40NzU1IDAgMCAxLTIuODc2NC0xLjA0MDhsLjE0MTktLjA4MDQgNC43NzgzLTIuNzU4MmEuNzk0OC43OTQ4IDAgMCAwIC4zOTI3LS42ODEzdi02LjczNjlsMi4wMiAxLjE2ODZhLjA3MS4wNzEgMCAwIDEgLjAzOC4wNTJ2NS41ODI2YTQuNTA0IDQuNTA0IDAgMCAxLTQuNDk0NSA0LjQ5NDR6bS05LjY2MDctNC4xMjU0YTQuNDcwOCA0LjQ3MDggMCAwIDEtLjUzNDYtMy4wMTM3bC4xNDIuMDg1MiA0Ljc4MyAyLjc1ODJhLjc3MTIuNzcxMiAwIDAgMCAuNzgwNiAwbDUuODQyOC0zLjM2ODV2Mi4zMzI0YS4wODA0LjA4MDQgMCAwIDEtLjAzMzIuMDYxNUw5Ljc0IDE5Ljk1MDJhNC40OTkyIDQuNDk5MiAwIDAgMS02LjE0MDgtMS42NDY0ek0yLjM0MDggNy44OTU2YTQuNDg1IDQuNDg1IDAgMCAxIDIuMzY1NS0xLjk3MjhWMTEuNmEuNzY2NC43NjY0IDAgMCAwIC4zODc5LjY3NjVsNS44MTQ0IDMuMzU0My0yLjAyMDEgMS4xNjg1YS4wNzU3LjA3NTcgMCAwIDEtLjA3MSAwbC00LjgzMDMtMi43ODY1QTQuNTA0IDQuNTA0IDAgMCAxIDIuMzQwOCA3Ljg3MnptMTYuNTk2MyAzLjg1NThMMTMuMTAzOCA4LjM2NCAxNS4xMTkyIDcuMmEuMDc1Ny4wNzU3IDAgMCAxIC4wNzEgMGw0LjgzMDMgMi43OTEzYTQuNDk0NCA0LjQ5NDQgMCAwIDEtLjY3NjUgOC4xMDQydi01LjY3NzJhLjc5Ljc5IDAgMCAwLS40MDctLjY2N3ptMi4wMTA3LTMuMDIzMWwtLjE0Mi0uMDg1Mi00Ljc3MzUtMi43ODE4YS43NzU5Ljc3NTkgMCAwIDAtLjc4NTQgMEw5LjQwOSA5LjIyOTdWNi44OTc0YS4wNjYyLjA2NjIgMCAwIDEgLjAyODQtLjA2MTVsNC44MzAzLTIuNzg2NmE0LjQ5OTIgNC40OTkyIDAgMCAxIDYuNjgwMiA0LjY2ek04LjMwNjUgMTIuODYzbC0yLjAyLTEuMTYzOGEuMDgwNC4wODA0IDAgMCAxLS4wMzgtLjA1NjdWNi4wNzQyYTQuNDk5MiA0LjQ5OTIgMCAwIDEgNy4zNzU3LTMuNDUzN2wtLjE0Mi4wODA1TDguNzA0IDUuNDU5YS43OTQ4Ljc5NDggMCAwIDAtLjM5MjcuNjgxM3ptMS4wOTc2LTIuMzY1NGwyLjYwMi0xLjQ5OTggMi42MDY5IDEuNDk5OHYyLjk5OTRsLTIuNTk3NCAxLjQ5OTctMi42MDY3LTEuNDk5N1oiLz48L3N2Zz4%3D)](https://developers.openai.com/) + + + + +I use this project mostly everyday. I build it to help me collecting my thoughts about articles, and blob posts I read during the week and then aggregate them in a #ReadingNotes blog post. You can find those post on my blog [here](https://frankysnotes.com). + +NoteBookmark is composed of three main sections: + +- **Post**: where you can manage a posts "to read", and add notes to them. +- **Generate Summary**: where you can generate a summary of the posts you read. +- **Summaries**: where you can see all the summaries you generated. + +![Slide show of all NoteBookmark Screens](gh/images/NoteBookmark-Tour_hd.gif) + +## Run Options + +- Development: running the Aspire project is the easiest path and everything is wired automatically. +- Production-style: run with containers and deploy to Azure. + +Run locally with Aspire: + +```bash +dotnet run --project src/NoteBookmark.AppHost +``` + +## How to deploy Your own NoteBookmark + +### Get the code on your machine + +- Fork this repository to your account. +- Clone the repository to your local machine. + + +### Deploy the solution (5 mins) + +Using Azure Developer CLI let's initialize your environment. In a terminal, at the root of the project, run the following command. When ask give it a name (ex: NoteBookmark-dev). + +```bash +azd init +``` + +Now let's deploy the solution. Run the following command in the terminal. You will have to select your Azure subscription where you want to deploy the solution, and a location (ex: eastus). + +```bash +azd up +``` + +It should take around five minutes to deploy the solution. Once it's done, you will see the URL for **Deploying service blazor-app**. + +### Secure the App in a few clicks + +The app is now deployed, but it's not secure. Navigate to the Azure Portal, and find the Resource Group you just deployed (ex: rg-notebookmark-dev). In this resource group, open the Container App **Container App**. From the left menu, select **Authentication** and click the **Add identity provider**. + +You can choose between multiple providers, I like to use Microsoft since it's deploy in Azure and I'm already logged in. If Microsoft is choose, select the recomended **Client secret expiration** (ex: 180 days). You can keep all the other default settings. Click **Add**. + +Next time you will navigate to the app, you will be prompt a to login with your Microsoft account. The first time you will have a **Permissions requested** screen, click **Accept**. + +Voila! Your app is now secure. + +## Documentation + +For detailed setup guides and configuration information: +- [Keycloak Container Setup](/docs/keycloak-container-setup.md) - Start a local Keycloak instance if you do not already have one +- [Keycloak Authentication Setup](/docs/keycloak-setup.md) - Complete guide for setting up Keycloak authentication +- [Docker Compose Deployment](/docs/docker-compose-deployment.md) - Deploy NoteBookmark containers (assumes a healthy Keycloak + configured realm) +- [AI Provider Setup](/docs/ai-provider-setup.md) - Configure the AI provider (API key, Base URL, model) with OpenCode, OpenRouter or Ollama examples + +## Contributing + +Your contributions are welcome! Take a look at [CONTRIBUTING](/CONTRIBUTING.md) for details. diff --git a/docker-compose/note-compose.yaml b/docker-compose/note-compose.yaml index e0668b1..a667421 100644 --- a/docker-compose/note-compose.yaml +++ b/docker-compose/note-compose.yaml @@ -35,7 +35,9 @@ services: services__keycloak__http__0: "http://keycloak:8080" ConnectionStrings__nb-tables: ${NB_STORAGE_OUTPUTS_TABLEENDPOINT} ConnectionStrings__nb-blobs: ${NB_STORAGE_OUTPUTS_BLOBENDPOINT} - REKA_API_KEY: ${REKA_API_KEY} + AppSettings__AiApiKey: ${AI_API_KEY:-${REKA_API_KEY:-}} + AppSettings__AiBaseUrl: ${AI_BASE_URL:-} + AppSettings__AiModelName: ${AI_MODEL_NAME:-} Keycloak__Authority: ${KEYCLOAK_AUTHORITY} Keycloak__ClientId: ${KEYCLOAK_CLIENT_ID} Keycloak__ClientSecret: ${KEYCLOAK_CLIENT_SECRET} diff --git a/docs/ai-provider-setup.md b/docs/ai-provider-setup.md new file mode 100644 index 0000000..ed602dd --- /dev/null +++ b/docs/ai-provider-setup.md @@ -0,0 +1,107 @@ +# AI Provider Setup + +NoteBookmark uses an AI model to generate post summaries and research suggestions. It works with any provider that exposes an **OpenAI-compatible** Chat Completions API. This guide explains the three AI settings, how the app decides which values to use, and gives working examples for [OpenCode.ai](#opencodeai), [OpenRouter](#openrouter) and [Ollama](#ollama-local). + +## The three settings + +| Setting | What it expects | +|---|---| +| **API Key** | The key issued by your provider. | +| **Base URL** | The root of the provider's OpenAI-compatible API, ending at `/v1`. | +| **Model Name** | The model id exactly as the provider names it. | + +### The Base URL must stop at `/v1` + +The app builds an OpenAI .NET SDK `ChatClient` with `Endpoint = BaseUrl`, and the SDK appends `/chat/completions` itself. So the Base URL must **not** include `/chat/completions` or any other trailing path. + +| ✅ Correct | ❌ Wrong | +|---|---| +| `https://opencode.ai/inference/openai/v1` | `https://opencode.ai/inference/openai/v1/chat/completions` | +| `https://openrouter.ai/api/v1` | `https://openrouter.ai/api/v1/chat/completions` | + +With the wrong form, the request goes to `.../chat/completions/chat/completions` and fails. + +## Where the settings come from + +The app reads the AI settings in this order: + +1. **The Settings page** (stored in the database). These values are used **only when API Key is non-empty**. If Base URL or Model Name is left empty, a built-in default is used for that field. +2. **Configuration** (environment variables or `appsettings.json`), used when no API Key is saved on the Settings page: + - API key: `AppSettings:AiApiKey`, then `AppSettings:REKA_API_KEY`, then the `REKA_API_KEY` environment variable (the Reka names are still supported for backward compatibility). + - Base URL: `AppSettings:AiBaseUrl` + - Model name: `AppSettings:AiModelName` + +As environment variables (for Docker Compose or Azure Container Apps) the configuration keys use a double underscore: `AppSettings__AiApiKey`, `AppSettings__AiBaseUrl`, `AppSettings__AiModelName`. + +> **Tip:** Set all three values together. If you only set the API key, the Base URL and Model Name fall back to the built-in defaults, which are unlikely to match your provider. + +## When a summary comes back empty + +AI errors don't show up in the UI. The summary just comes back empty. To find the cause, look at the **blazor-app** logs (the AI calls are made by the web app, not the API): + +- With Aspire, open the dashboard, select the `blazor-app` resource, and look at its console logs or structured logs. +- With Docker Compose, run `docker logs notebookmark-blazor`. + +Look for `An error occurred while generating summary:` followed by the provider's error message. The usual causes are: + +- A Base URL that ends with `/chat/completions` (see above). +- A model id the provider doesn't serve on its OpenAI-compatible endpoint. +- An invalid or missing API key. + +## Provider examples + +### OpenCode.ai + +| Field | Value | +|---|---| +| **API Key** | Your OpenCode key | +| **Base URL** | `https://opencode.ai/inference/openai/v1` | +| **Model Name** | A model served on the OpenAI-compatible path, for example a DeepSeek, GLM, Kimi, MiniMax, Qwen or Mistral model | + +The full model list is at `https://opencode.ai/inference/v1/models`. + +> **Gotcha: Gemini models don't work.** OpenCode serves Gemini models (for example `gemini-*-flash`) only on its Google-native path (`/inference/google/v1beta/...`), not on the OpenAI-compatible one. NoteBookmark can only talk to the OpenAI-compatible path, so pick a model from the list above instead. + +### OpenRouter + +| Field | Value | +|---|---| +| **API Key** | Your OpenRouter key (from ) | +| **Base URL** | `https://openrouter.ai/api/v1` | +| **Model Name** | Any id from , in `vendor/model` form (for example `mistralai/mistral-small-3.2-24b-instruct`) | + +### Ollama (local) + +| Field | Value | +|---|---| +| **API Key** | Any non-empty placeholder, for example `ollama`. Ollama ignores it, but the app only uses the Settings page values when the API Key is non-empty. | +| **Base URL** | `http://localhost:11434/v1`, or `http://host.docker.internal:11434/v1` when NoteBookmark runs in a container and Ollama runs on the host | +| **Model Name** | A model you've pulled, as shown by `ollama list` (for example `llama3.2`) | + +Pull the model first, for example `ollama pull llama3.2`. + +## Configuring without the Settings page + +Use these when you'd rather set the provider in configuration than through the UI. + +**Local development (Aspire)**, in `src/NoteBookmark.AppHost/appsettings.json` or user secrets: + +```json +"AppSettings": { + "AiApiKey": "your-openrouter-key", + "AiBaseUrl": "https://openrouter.ai/api/v1", + "AiModelName": "mistralai/mistral-small-3.2-24b-instruct" +} +``` + +The AppHost passes these to the web app. + +**Docker Compose**, in `docker-compose/.env` (see [Docker Compose Deployment](docker-compose-deployment.md)): + +```bash +AI_API_KEY=your-openrouter-key +AI_BASE_URL=https://openrouter.ai/api/v1 +AI_MODEL_NAME=mistralai/mistral-small-3.2-24b-instruct +``` + +`note-compose.yaml` maps them to `AppSettings__AiApiKey`, `AppSettings__AiBaseUrl` and `AppSettings__AiModelName` on the `blazor-app` service. diff --git a/docs/docker-compose-deployment.md b/docs/docker-compose-deployment.md index fb917d6..eae6ee7 100644 --- a/docs/docker-compose-deployment.md +++ b/docs/docker-compose-deployment.md @@ -25,6 +25,8 @@ cp .env-sample docker-compose/.env Edit `docker-compose/.env` and set all required values. +For the AI provider (`AI_API_KEY`, `AI_BASE_URL`, `AI_MODEL_NAME`), see [AI Provider Setup](ai-provider-setup.md). You can also leave them empty and set the provider on the Settings page instead. + Important Keycloak values for NoteBookmark: - `KEYCLOAK_AUTHORITY` (for example `http://localhost:8080/realms/notebookmark`) diff --git a/src/NoteBookmark.Api.Tests/Services/AISettingsProviderTests.cs b/src/NoteBookmark.Api.Tests/Services/AISettingsProviderTests.cs index 3ac01e0..52abb03 100644 --- a/src/NoteBookmark.Api.Tests/Services/AISettingsProviderTests.cs +++ b/src/NoteBookmark.Api.Tests/Services/AISettingsProviderTests.cs @@ -195,4 +195,46 @@ public async Task GetAISettingsAsync_EnvVarRekaApiKey_UsedWhenConfigKeysMissing( Environment.SetEnvironmentVariable("REKA_API_KEY", originalValue); } } + + [Fact] + public async Task GetAISettingsAsync_EmptyAiApiKeyConfig_FallsBackToRekaApiKeyConfig() + { + // An unset docker-compose variable arrives as an empty string, not null + _mockDataStorage.Setup(s => s.GetSettings()) + .ReturnsAsync(MakeSettings()); + _mockConfig.Setup(c => c["AppSettings:AiApiKey"]).Returns(""); + _mockConfig.Setup(c => c["AppSettings:REKA_API_KEY"]).Returns("reka-config-key"); + + var (apiKey, _, _) = await CreateSut().GetAISettingsAsync(); + + apiKey.Should().Be("reka-config-key"); + } + + [Fact] + public async Task GetAISettingsAsync_EmptyConfigBaseUrlAndModel_UsesDefaults() + { + _mockDataStorage.Setup(s => s.GetSettings()) + .ReturnsAsync(MakeSettings()); + _mockConfig.Setup(c => c["AppSettings:AiApiKey"]).Returns("config-key"); + _mockConfig.Setup(c => c["AppSettings:AiBaseUrl"]).Returns(""); + _mockConfig.Setup(c => c["AppSettings:AiModelName"]).Returns(" "); + + var (_, baseUrl, modelName) = await CreateSut().GetAISettingsAsync(); + + baseUrl.Should().Be("https://api.reka.ai/v1"); + modelName.Should().Be("reka-flash-3.1"); + } + + [Fact] + public async Task GetAISettingsAsync_ConfigFallback_UsesConfigModelNameWhenProvided() + { + _mockDataStorage.Setup(s => s.GetSettings()) + .ReturnsAsync(MakeSettings()); + _mockConfig.Setup(c => c["AppSettings:AiApiKey"]).Returns("config-key"); + _mockConfig.Setup(c => c["AppSettings:AiModelName"]).Returns("mistralai/mistral-small-3.2-24b-instruct"); + + var (_, _, modelName) = await CreateSut().GetAISettingsAsync(); + + modelName.Should().Be("mistralai/mistral-small-3.2-24b-instruct"); + } } diff --git a/src/NoteBookmark.Api/AISettingsProvider.cs b/src/NoteBookmark.Api/AISettingsProvider.cs index efcd15d..9e20d67 100644 --- a/src/NoteBookmark.Api/AISettingsProvider.cs +++ b/src/NoteBookmark.Api/AISettingsProvider.cs @@ -54,15 +54,26 @@ public AISettingsProvider( // Fallback to IConfiguration (environment variables, appsettings.json) _logger.LogDebug("Using AI settings from IConfiguration fallback"); - string? apiKey = _config["AppSettings:AiApiKey"] - ?? _config["AppSettings:REKA_API_KEY"] - ?? Environment.GetEnvironmentVariable("REKA_API_KEY"); + // Empty values (e.g. an unset docker-compose variable) count as missing + string? apiKey = new[] + { + _config["AppSettings:AiApiKey"], + _config["AppSettings:REKA_API_KEY"], + Environment.GetEnvironmentVariable("REKA_API_KEY") + }.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("AI API key not configured. Set AiApiKey in settings or REKA_API_KEY environment variable."); + throw new InvalidOperationException("AI API key not configured. Set the API Key on the Settings page, AppSettings:AiApiKey in configuration, or the REKA_API_KEY environment variable."); + + string? configBaseUrl = _config["AppSettings:AiBaseUrl"]; + string? configModelName = _config["AppSettings:AiModelName"]; - string fallbackBaseUrl = _config["AppSettings:AiBaseUrl"] ?? "https://api.reka.ai/v1"; - string fallbackModelName = _config["AppSettings:AiModelName"] ?? "reka-flash-3.1"; + string fallbackBaseUrl = !string.IsNullOrWhiteSpace(configBaseUrl) + ? configBaseUrl + : "https://api.reka.ai/v1"; + string fallbackModelName = !string.IsNullOrWhiteSpace(configModelName) + ? configModelName + : "reka-flash-3.1"; return (apiKey, fallbackBaseUrl, fallbackModelName); } diff --git a/src/NoteBookmark.AppHost/AppHost.cs b/src/NoteBookmark.AppHost/AppHost.cs index 024f1f8..df64e19 100644 --- a/src/NoteBookmark.AppHost/AppHost.cs +++ b/src/NoteBookmark.AppHost/AppHost.cs @@ -11,6 +11,14 @@ var parserKey = builder.Configuration["Parser:ApiKey"] ?? Environment.GetEnvironmentVariable("PARSER_API_KEY"); +// AI provider settings (see docs/ai-provider-setup.md). REKA_API_KEY is kept for backward compatibility. +var aiApiKey = builder.Configuration["AppSettings:AiApiKey"] + ?? builder.Configuration["AppSettings:REKA_API_KEY"] + ?? Environment.GetEnvironmentVariable("REKA_API_KEY") + ?? throw new InvalidOperationException("AI API key is not set. Set AppSettings:AiApiKey (or REKA_API_KEY)."); +var aiBaseUrl = builder.Configuration["AppSettings:AiBaseUrl"] ?? string.Empty; +var aiModelName = builder.Configuration["AppSettings:AiModelName"] ?? string.Empty; + // Load docker-compose environment var compose = builder.AddDockerComposeEnvironment("docker-env"); @@ -23,8 +31,6 @@ var noteStorage = builder.AddAzureStorage("nb-storage"); - var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - noteStorage.RunAsEmulator(); var tables = noteStorage.AddTables("nb-tables"); @@ -55,7 +61,9 @@ .WaitFor(keycloak) //.WaitFor(compose) // Wait for docker-compose services to be ready .WithExternalHttpEndpoints() - .WithEnvironment("REKA_API_KEY", apiKey) + .WithEnvironment("AppSettings__AiApiKey", aiApiKey) + .WithEnvironment("AppSettings__AiBaseUrl", aiBaseUrl) + .WithEnvironment("AppSettings__AiModelName", aiModelName) .PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-blazor"; @@ -66,8 +74,6 @@ // Production mode - no Aspire resources, expects docker-compose or Azure deployment var noteStorage = builder.AddAzureStorage("nb-storage"); - var apiKey = builder.Configuration["AppSettings:REKA_API_KEY"] ?? Environment.GetEnvironmentVariable("REKA_API_KEY") ?? throw new InvalidOperationException("REKA_API_KEY environment variable is not set."); - var tables = noteStorage.AddTables("nb-tables"); var blobs = noteStorage.AddBlobs("nb-blobs"); @@ -93,7 +99,9 @@ .WithReference(tables) // Server-side access to Azure Tables for unmasked settings .WaitFor(api) .WithExternalHttpEndpoints() - .WithEnvironment("REKA_API_KEY", apiKey) + .WithEnvironment("AppSettings__AiApiKey", aiApiKey) + .WithEnvironment("AppSettings__AiBaseUrl", aiBaseUrl) + .WithEnvironment("AppSettings__AiModelName", aiModelName) .PublishAsDockerComposeService((resource, service) => { service.ContainerName = "notebookmark-blazor"; diff --git a/src/NoteBookmark.AppHost/appsettings.json b/src/NoteBookmark.AppHost/appsettings.json index 84b7565..5b78b9c 100644 --- a/src/NoteBookmark.AppHost/appsettings.json +++ b/src/NoteBookmark.AppHost/appsettings.json @@ -7,7 +7,9 @@ } }, "AppSettings": { - "REKA_API_KEY": "KEY_HERE" + "AiApiKey": "KEY_HERE", + "AiBaseUrl": "https://openrouter.ai/api/v1", + "AiModelName": "mistralai/mistral-small-3.2-24b-instruct" }, "Parser": { "BaseUrl": "URL TO YOUR fboucher/azPostlight-Parser", diff --git a/src/NoteBookmark.BlazorApp/AISettingsProvider.cs b/src/NoteBookmark.BlazorApp/AISettingsProvider.cs index 71526c5..4c0d096 100644 --- a/src/NoteBookmark.BlazorApp/AISettingsProvider.cs +++ b/src/NoteBookmark.BlazorApp/AISettingsProvider.cs @@ -64,15 +64,26 @@ public AISettingsProvider( // Fallback to IConfiguration (environment variables, appsettings.json) _logger.LogDebug("Using AI settings from IConfiguration fallback"); - string? apiKey = _config["AppSettings:AiApiKey"] - ?? _config["AppSettings:REKA_API_KEY"] - ?? Environment.GetEnvironmentVariable("REKA_API_KEY"); + // Empty values (e.g. an unset docker-compose variable) count as missing + string? apiKey = new[] + { + _config["AppSettings:AiApiKey"], + _config["AppSettings:REKA_API_KEY"], + Environment.GetEnvironmentVariable("REKA_API_KEY") + }.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v)); if (string.IsNullOrEmpty(apiKey)) - throw new InvalidOperationException("AI API key not configured. Set AiApiKey in settings or REKA_API_KEY environment variable."); + throw new InvalidOperationException("AI API key not configured. Set the API Key on the Settings page, AppSettings:AiApiKey in configuration, or the REKA_API_KEY environment variable."); + + string? configBaseUrl = _config["AppSettings:AiBaseUrl"]; + string? configModelName = _config["AppSettings:AiModelName"]; - string fallbackBaseUrl = _config["AppSettings:AiBaseUrl"] ?? "https://api.reka.ai/v1"; - string fallbackModelName = _config["AppSettings:AiModelName"] ?? "reka-flash-3.1"; + string fallbackBaseUrl = !string.IsNullOrWhiteSpace(configBaseUrl) + ? configBaseUrl + : "https://api.reka.ai/v1"; + string fallbackModelName = !string.IsNullOrWhiteSpace(configModelName) + ? configModelName + : "reka-flash-3.1"; return (apiKey, fallbackBaseUrl, fallbackModelName); } diff --git a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor index e319833..2563185 100644 --- a/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor +++ b/src/NoteBookmark.SharedUI/Components/Pages/Settings.razor @@ -85,17 +85,21 @@ AI Provider Configuration + + Any OpenAI-compatible provider works. The Base URL must end at /v1 (without /chat/completions). + See the AI provider setup guide for OpenCode, OpenRouter and Ollama examples. + - + - + Save