fix: resolve merge conflicts and apply extract_text_content to all graphs

Resolve conflicts in ask.py and chat.py by merging the try/except error
handling from main with the extract_text_content helper from the PR.

Also apply the same fix to source_chat.py and transformation.py which
had the same vulnerable isinstance/str() pattern for structured LLM
response content (e.g. Gemini's envelope format).
This commit is contained in:
Luis Novo 2026-02-17 16:20:14 -03:00
commit 07c05ca354
147 changed files with 12537 additions and 4028 deletions

View file

@ -1,276 +1,59 @@
# Open Notebook Configuration
# Copy this file to .env and customize as needed
# API CONFIGURATION
# URL where the API can be accessed by the browser
# This setting allows the frontend to connect to the API at runtime (no rebuild needed!)
#
# IMPORTANT: Do NOT include /api at the end - it will be added automatically!
#
# Common scenarios:
# - Docker on localhost: http://localhost:5055 (default, works for most cases)
# - Docker on LAN/remote server: http://192.168.1.100:5055 or http://your-server-ip:5055
# - Behind reverse proxy with custom domain: https://your-domain.com
# - Behind reverse proxy with subdomain: https://api.your-domain.com
#
# Examples for reverse proxy users:
# - API_URL=https://notebook.example.com (frontend will call https://notebook.example.com/api/*)
# - API_URL=https://api.example.com (frontend will call https://api.example.com/api/*)
#
# Note: If not set, the system will auto-detect based on the incoming request.
# Only set this if you need to override the auto-detection (e.g., reverse proxy scenarios).
API_URL=http://localhost:5055
# =============================================================================
# REQUIRED
# =============================================================================
# INTERNAL API URL (Server-Side)
# URL where Next.js server-side should proxy API requests (via rewrites)
# This is DIFFERENT from API_URL which is used by the browser client
#
# INTERNAL_API_URL is used by Next.js rewrites to forward /api/* requests to the FastAPI backend
# API_URL is used by the browser to know where to make API calls
#
# Default: http://localhost:5055 (single-container deployment - both services on same host)
# Override for multi-container: INTERNAL_API_URL=http://api-service:5055
#
# Common scenarios:
# - Single container (default): Don't set - defaults to http://localhost:5055
# - Multi-container Docker Compose: INTERNAL_API_URL=http://api:5055 (use service name)
# - Kubernetes/advanced networking: INTERNAL_API_URL=http://api-service.namespace.svc.cluster.local:5055
#
# Why two variables?
# - API_URL: External/public URL that browsers use (can be https://your-domain.com)
# - INTERNAL_API_URL: Internal container networking URL (usually http://localhost:5055 or service name)
#
# INTERNAL_API_URL=http://localhost:5055
# Encryption key for storing API credentials securely in the database
# Change this to any secret string (minimum 16 characters recommended)
OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# API CLIENT TIMEOUT (in seconds)
# Controls how long the frontend/Streamlit UI waits for API responses
# Increase this if you're using slow AI providers or hardware (Ollama on CPU, remote LM Studio, etc.)
# Default: 300 seconds (5 minutes) - sufficient for most transformation/insight operations
#
# Common scenarios:
# - Fast cloud APIs (OpenAI, Anthropic): 300 seconds is more than enough
# - Local Ollama on GPU: 300 seconds should work fine
# - Local Ollama on CPU: Consider 600 seconds (10 minutes) or more
# - Remote LM Studio over slow network: Consider 900 seconds (15 minutes)
# - Very large documents: May need 900+ seconds
#
# API_CLIENT_TIMEOUT=300
# =============================================================================
# DATABASE (Default values work with docker-compose.yml)
# =============================================================================
# ESPERANTO LLM TIMEOUT (in seconds)
# Controls the timeout for AI model API calls at the Esperanto library level
# This is separate from API_CLIENT_TIMEOUT and applies to the actual LLM provider requests
# Only increase this if you're experiencing timeouts during model inference itself
# Default: 60 seconds (built into Esperanto)
#
# Important: This should generally be LOWER than API_CLIENT_TIMEOUT to allow proper error handling
#
# Common scenarios:
# - Fast cloud APIs (OpenAI, Anthropic, Groq): 60 seconds is sufficient
# - Local Ollama with small models: 120-180 seconds may help
# - Local Ollama with large models on CPU: 300+ seconds
# - Remote or self-hosted LLMs: 180-300 seconds depending on hardware
#
# Note: If transformations complete but you see timeout errors, increase API_CLIENT_TIMEOUT first.
# Only increase ESPERANTO_LLM_TIMEOUT if the model itself is timing out during inference.
#
# ESPERANTO_LLM_TIMEOUT=60
SURREAL_URL=ws://surrealdb:8000/rpc
SURREAL_USER=root
SURREAL_PASSWORD=root
SURREAL_NAMESPACE=open_notebook
SURREAL_DATABASE=open_notebook
# SSL VERIFICATION CONFIGURATION
# Configure SSL certificate verification for local AI providers (Ollama, LM Studio, etc.)
# behind reverse proxies with self-signed certificates
#
# Option 1: Custom CA Bundle (recommended for self-signed certs)
# Point to your CA certificate file to verify SSL while using custom certificates
# ESPERANTO_SSL_CA_BUNDLE=/path/to/your/ca-bundle.pem
#
# Option 2: Disable SSL Verification (development only)
# WARNING: Disabling SSL verification exposes you to man-in-the-middle attacks
# Only use in trusted development/testing environments
# ESPERANTO_SSL_VERIFY=false
# =============================================================================
# OPTIONAL: AI Provider API Keys
# =============================================================================
# You can configure these via the UI (Settings → API Keys) or set them here
# UI configuration is recommended for better security and flexibility
# SECURITY
# Set this to protect your Open Notebook instance with a password (for public hosting)
# OPEN_NOTEBOOK_PASSWORD=
# OpenAI
# OPENAI_API_KEY=sk-...
# HTTP/HTTPS PROXY
# Route all external HTTP requests through a proxy server
# Useful for corporate/firewalled environments
#
# The underlying libraries (esperanto, content-core, podcast-creator) automatically
# detect proxy settings from these standard environment variables.
#
# Affects:
# - AI provider API calls (OpenAI, Anthropic, Google, etc.)
# - Content extraction from URLs (web scraping, YouTube)
# - Podcast generation (LLM and TTS calls)
#
# Examples:
# HTTP_PROXY=http://proxy.corp.com:8080
# HTTPS_PROXY=http://proxy.corp.com:8080
# NO_PROXY=localhost,127.0.0.1,.local
# Anthropic
# ANTHROPIC_API_KEY=sk-ant-...
# OPENAI
# OPENAI_API_KEY=
# Google AI
# GOOGLE_API_KEY=...
# Groq
# GROQ_API_KEY=gsk_...
# ANTHROPIC
# ANTHROPIC_API_KEY=
# =============================================================================
# OPTIONAL: Advanced Configuration
# =============================================================================
# GEMINI
# this is the best model for long context and podcast generation
# GOOGLE_API_KEY=
# GEMINI_API_BASE_URL= # Optional: Override default endpoint (for Vertex AI, proxies, etc.)
# External API URL (for webhooks, callbacks, etc.)
# API_URL=http://localhost:5055
# VERTEXAI
# VERTEX_PROJECT=my-google-cloud-project-name
# GOOGLE_APPLICATION_CREDENTIALS=./google-credentials.json
# VERTEX_LOCATION=us-east5
# Ollama endpoint (if running locally)
# OLLAMA_BASE_URL=http://ollama:11434
# MISTRAL
# MISTRAL_API_KEY=
# Content processing
# CHUNK_SIZE=1500
# CHUNK_OVERLAP=150
# DEEPSEEK
# DEEPSEEK_API_KEY=
# Security
# BASIC_AUTH_USERNAME=admin
# BASIC_AUTH_PASSWORD=secret
# OLLAMA
# OLLAMA_API_BASE="http://10.20.30.20:11434"
# OPEN ROUTER
# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
# OPENROUTER_API_KEY=
# GROQ
# GROQ_API_KEY=
# XAI
# XAI_API_KEY=
# ELEVENLABS
# Used only by the podcast feature
# ELEVENLABS_API_KEY=
# TTS BATCH SIZE
# Controls concurrent TTS requests for podcast generation (default: 5)
# Lower values reduce provider load but increase generation time
# Recommended: OpenAI=5, ElevenLabs=2, Google=4, Custom=1
# TTS_BATCH_SIZE=2
# VOYAGE AI
# VOYAGE_API_KEY=
# OPENAI COMPATIBLE ENDPOINTS
# Generic configuration (applies to all modalities: language, embedding, STT, TTS)
# OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
# OPENAI_COMPATIBLE_API_KEY=
# Mode-specific configuration (overrides generic if set)
# Use these when you want different endpoints for different capabilities
# OPENAI_COMPATIBLE_BASE_URL_LLM=http://localhost:1234/v1
# OPENAI_COMPATIBLE_API_KEY_LLM=
# OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8080/v1
# OPENAI_COMPATIBLE_API_KEY_EMBEDDING=
# OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:9000/v1
# OPENAI_COMPATIBLE_API_KEY_STT=
# OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:9000/v1
# OPENAI_COMPATIBLE_API_KEY_TTS=
# AZURE OPENAI
# Generic configuration (applies to all modalities: language, embedding, STT, TTS)
# AZURE_OPENAI_API_KEY=
# AZURE_OPENAI_ENDPOINT=
# AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mode-specific configuration (overrides generic if set)
# Use these when you want different deployments for different AI capabilities
# AZURE_OPENAI_API_KEY_LLM=
# AZURE_OPENAI_ENDPOINT_LLM=
# AZURE_OPENAI_API_VERSION_LLM=
# AZURE_OPENAI_API_KEY_EMBEDDING=
# AZURE_OPENAI_ENDPOINT_EMBEDDING=
# AZURE_OPENAI_API_VERSION_EMBEDDING=
# AZURE_OPENAI_API_KEY_STT=
# AZURE_OPENAI_ENDPOINT_STT=
# AZURE_OPENAI_API_VERSION_STT=
# AZURE_OPENAI_API_KEY_TTS=
# AZURE_OPENAI_ENDPOINT_TTS=
# AZURE_OPENAI_API_VERSION_TTS=
# USE THIS IF YOU WANT TO DEBUG THE APP ON LANGSMITH
# LANGCHAIN_TRACING_V2=true
# LANGCHAIN_ENDPOINT="https://api.smith.langchain.com"
# LANGCHAIN_API_KEY=
# LANGCHAIN_PROJECT="Open Notebook"
# CONNECTION DETAILS FOR YOUR SURREAL DB
# New format (preferred) - WebSocket URL
SURREAL_URL="ws://surrealdb/rpc:8000"
SURREAL_USER="root"
SURREAL_PASSWORD="root"
SURREAL_NAMESPACE="open_notebook"
SURREAL_DATABASE="open_notebook"
# RETRY CONFIGURATION (surreal-commands v1.2.0+)
# Global defaults for all background commands unless explicitly overridden at command level
# These settings help commands automatically recover from transient failures like:
# - Database transaction conflicts during concurrent operations
# - Network timeouts when calling external APIs
# - Rate limits from LLM/embedding providers
# - Temporary resource unavailability
# Enable/disable retry globally (default: true)
# Set to false to disable retries for all commands (useful for debugging)
SURREAL_COMMANDS_RETRY_ENABLED=true
# Maximum retry attempts before giving up (default: 3)
# Database operations use 5 attempts (defined per-command)
# API calls use 3 attempts (defined per-command)
SURREAL_COMMANDS_RETRY_MAX_ATTEMPTS=3
# Wait strategy between retry attempts (default: exponential_jitter)
# Options: exponential_jitter, exponential, fixed, random
# - exponential_jitter: Recommended - prevents thundering herd during DB conflicts
# - exponential: Good for API rate limits (predictable backoff)
# - fixed: Use for quick recovery scenarios
# - random: Use when you want unpredictable retry timing
SURREAL_COMMANDS_RETRY_WAIT_STRATEGY=exponential_jitter
# Minimum wait time between retries in seconds (default: 1)
# Database conflicts: 1 second (fast retry for transient issues)
# API rate limits: 5 seconds (wait for quota reset)
SURREAL_COMMANDS_RETRY_WAIT_MIN=1
# Maximum wait time between retries in seconds (default: 30)
# Database conflicts: 30 seconds maximum
# API rate limits: 120 seconds maximum (defined per-command)
# Total retry time won't exceed max_attempts * wait_max
SURREAL_COMMANDS_RETRY_WAIT_MAX=30
# WORKER CONCURRENCY
# Maximum number of concurrent tasks in the worker pool (default: 5)
# This affects the likelihood of database transaction conflicts during batch operations
#
# Tuning guidelines based on deployment size:
# - Resource-constrained (low CPU/memory): 1-2 workers
# Reduces conflicts and resource usage, but slower processing
#
# - Normal deployment (balanced): 5 workers (RECOMMENDED)
# Good balance between throughput and conflict rate
# Retry logic handles occasional conflicts gracefully
#
# - Large instances (high CPU/memory): 10-20 workers
# Higher throughput but more frequent DB conflicts
# Relies heavily on retry logic with jittered backoff
#
# Note: Higher concurrency increases vectorization speed but also increases
# SurrealDB transaction conflicts. The retry logic with exponential-jitter
# backoff ensures operations complete successfully even at high concurrency.
SURREAL_COMMANDS_MAX_TASKS=5
# OPEN_NOTEBOOK_PASSWORD=
# FIRECRAWL - Get a key at https://firecrawl.dev/
FIRECRAWL_API_KEY=
# JINA - Get a key at https://jina.ai/
JINA_API_KEY=
# For more configuration options, see:
# https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/environment-reference.md

59
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: Tests
on:
pull_request:
branches: [main]
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- '.github/workflows/claude*.yml'
permissions:
contents: read
jobs:
backend:
name: Backend Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up uv
uses: astral-sh/setup-uv@v4
with:
enable-cache: true
- name: Set up Python
run: uv python install
- name: Install dependencies
run: uv sync
- name: Run tests
run: uv run pytest tests/ -v
frontend:
name: Frontend Tests
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test

1
.gitignore vendored
View file

@ -131,6 +131,7 @@ doc_exports/
specs/
.claude
.sisyphus
.playwright-mcp/

View file

@ -7,13 +7,115 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.7.2] - 2026-02-16
### Added
- Error classification utility that maps LLM provider errors to user-friendly messages (#506)
- Global exception handlers in FastAPI for all custom exception types with proper HTTP status codes
- `getApiErrorMessage()` frontend helper that falls back to backend messages when no i18n mapping exists
### Fixed
- LLM errors (invalid API key, wrong model, rate limits) now show descriptive messages instead of "An unexpected error occurred" (#590)
- SSE streaming error events in source chat and ask hooks were swallowed by inner JSON parse catch blocks
- Transformation execution errors were caught and re-wrapped as generic 500s instead of using proper status codes
- Fail fast when source content extraction returns empty instead of retrying (#589)
- Chat input and message overflow with long unbroken strings (#588)
- Word-wrap overflow in source cards, note editor, inline edit, note titles, and dialog content (#588)
- Translation proxy shadowing `name` keys (#588)
- OpenAI-compatible provider name handling via Esperanto update (#583)
### Changed
- `ValueError` replaced with `ConfigurationError` in model provisioning for proper error classification
- `ConfigurationError` added to command retry `stop_on` lists to avoid retrying permanent config failures
### Dependencies
- Bump esperanto to 2.19.3 (#583)
- Bump podcast-creator to 0.9.1
## [1.7.1] - 2026-02-14
### Added
- French (fr-FR) language support (#581)
- CI test workflow and improved i18n validation (#580)
- Expose embed `command_id` in note API responses (#545)
### Fixed
- ElevenLabs TTS credential passthrough via Esperanto update (#578)
- Handle empty/whitespace source content without retry loop (#576)
- Increase transformation `max_tokens` and update Esperanto dep (#568)
- Turn the embedding field into optional (#557)
### Docs
- Fix docker container names in local setup guides (#577)
### Dependencies
- Bump langchain-core from 1.2.7 to 1.2.11 (#564)
- Bump cryptography from 46.0.3 to 46.0.5 (#563)
## [1.7.0] - 2026-02-10
### Added
- **Credential-Based Provider Management** (#477)
- New Settings → API Keys page for managing AI provider credentials via the UI
- Support for 14 providers: OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter, Voyage AI, ElevenLabs, Ollama, Azure OpenAI, OpenAI-Compatible, and Vertex AI
- Secure storage of API keys in SurrealDB with field-level encryption (Fernet AES-128-CBC + HMAC-SHA256)
- One-click connection testing, model discovery, and model registration per credential
- Migration tool to import existing environment variable keys into the credential system
- Azure OpenAI support with service-specific endpoints (LLM, Embedding, STT, TTS)
- OpenAI-Compatible support with per-service URL configurations
- Vertex AI support with project, location, and credentials path
- Environment variable API keys deprecated in favor of Settings UI
- **Security Enhancements**
- Docker secrets support via `_FILE` suffix pattern (e.g., `OPEN_NOTEBOOK_PASSWORD_FILE`)
- Default encryption key derived from "0p3n-N0t3b0ok" for easy setup (change in production!)
- Default password "open-notebook-change-me" for out-of-box experience (change in production!)
- URL validation for SSRF protection - blocks private IPs and localhost (except for Ollama which runs locally)
- Security warnings logged when using default credentials
- HTML clipboard detection for text sources (#426)
- When pasting content, automatically detects HTML format (e.g., from Word, web pages)
- Shows info message when HTML is detected, informing user it will be converted to Markdown
- Preserves formatting that would be lost with plain text paste
- Bump content-core to 0.11.0 for HTML to Markdown conversion support
- **Improved Getting Started Experience**
- Simplified docker-compose.yml in repository root (single official file)
- Added examples/ folder with ready-made configurations:
- `docker-compose-ollama.yml` - Local AI with Ollama
- `docker-compose-speaches.yml` - Local TTS/STT with Speaches
- `docker-compose-full-local.yml` - 100% local setup (Ollama + Speaches)
- Inline quick start in README (no need to navigate to docs)
- Cross-references between docker-compose examples and documentation
- .env.example template with all configuration options
### Fixed
- Azure form race condition: all configuration now saved in single atomic request
- Migration API "error error" display: added proper MigrationResult model with message field
- Connection tester for Ollama providers: improved error handling and URL validation
- SqliteSaver async compatibility issues in chat system (#509, #525, #538)
- Re-embedding failures with empty content (#513, #515)
- Deletion cascade for notes and sources (#77)
- YouTube content availability issues (#494)
- Large document embedding errors (#489)
### Security
- API keys are encrypted at rest using Fernet symmetric encryption
- Keys are never returned to the frontend, only configuration status
- SSRF protection prevents internal network access via URL validation
### Docs
- Complete documentation update for credential-based system across 25 files
- All quick-start, installation, and configuration guides now use Settings UI workflow
- Environment variable API key instructions moved to deprecated/legacy sections
- Fixed broken links in installation docs
- Added comprehensive examples/ folder with documented docker-compose configurations
- Updated local-tts.md and local-stt.md with links to ready-made examples
### Internationalization
- Added Russian (ru-RU) language support (#524)
- Added Italian (it-IT) language support (#508)
## [1.6.2] - 2026-01-24
### Fixed

View file

@ -36,7 +36,7 @@ This file provides architectural guidance for contributors working on Open Noteb
│ Database (SurrealDB) │
│ Graph database @ port 8000 │
├─────────────────────────────────────────────────────────┤
│ - Records: Notebook, Source, Note, ChatSession, etc.
│ - Records: Notebook, Source, Note, ChatSession, Credential
│ - Relationships: source-to-notebook, note-to-source │
│ - Vector embeddings for semantic search │
└─────────────────────────────────────────────────────────┘
@ -98,7 +98,8 @@ User documentation is at @docs/
### 3. Multi-Provider AI
- **Esperanto library**: Unified interface to 8+ AI providers
- **ModelManager**: Factory pattern with fallback logic
- **Credential system**: Individual encrypted credential records per provider; models link to credentials for direct config
- **ModelManager**: Factory pattern with fallback logic; uses credential config when available, env vars as fallback
- **Smart selection**: Detects large contexts, prefers long-context models
- **Override support**: Per-request model configuration
@ -217,4 +218,4 @@ See dedicated CLAUDE.md files for detailed guidance:
---
**Last Updated**: January 2026 | **Project Version**: 1.2.4+
**Last Updated**: February 2026 | **Project Version**: 1.7.2

101
README.md
View file

@ -94,45 +94,86 @@ Learn more about our project at [https://www.open-notebook.ai](https://www.open-
[![Python][Python]][Python-url] [![Next.js][Next.js]][Next-url] [![React][React]][React-url] [![SurrealDB][SurrealDB]][SurrealDB-url] [![LangChain][LangChain]][LangChain-url]
## 🚀 Quick Start
## 🚀 Quick Start (2 Minutes)
Choose your installation method:
### Prerequisites
- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed
- That's it! (API keys configured later in the UI)
### 🐳 **Docker (Recommended)**
### Step 1: Get docker-compose.yml
**Best for most users** - Fast setup with Docker Compose:
**Option A:** Download directly
```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/docker-compose.yml
```
**[Docker Compose Installation Guide](docs/1-INSTALLATION/docker-compose.md)**
- Multi-container setup (recommended)
- 5-10 minutes setup time
- Requires Docker Desktop
**Option B:** Create the file manually
Copy this into a new file called `docker-compose.yml`:
**Quick Start:**
- Get an API key (OpenAI, Anthropic, Google, etc.) or setup Ollama
- Create docker-compose.yml (example in guide)
- Run: docker compose up -d
- Access: http://localhost:8502
```yaml
services:
surrealdb:
image: surrealdb/surrealdb:v2
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
user: root
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
restart: always
open_notebook:
image: lfnovo/open_notebook:v1-latest
ports:
- "8502:8502"
- "5055:5055"
environment:
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=root
volumes:
- ./notebook_data:/app/data
depends_on:
- surrealdb
restart: always
```
### Step 2: Set Your Encryption Key
Edit `docker-compose.yml` and change this line:
```yaml
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
```
to any secret value (e.g., `my-super-secret-key-123`)
### Step 3: Start Services
```bash
docker compose up -d
```
Wait 15-20 seconds, then open: **http://localhost:8502**
### Step 4: Configure AI Provider
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Choose your provider (OpenAI, Anthropic, Google, etc.)
4. Paste your API key and click **Save**
5. Click **Test Connection****Discover Models** → **Register Models**
Done! You're ready to create your first notebook.
> **Need an API key?** Get one from:
> [OpenAI](https://platform.openai.com/api-keys) · [Anthropic](https://console.anthropic.com/) · [Google](https://aistudio.google.com/) · [Groq](https://console.groq.com/) (free tier)
> **Want free local AI?** See [examples/docker-compose-ollama.yml](examples/) for Ollama setup
---
### 💻 **From Source (Developers)**
### 📚 More Installation Options
**For development and contributors:**
**[From Source Installation Guide](docs/1-INSTALLATION/from-source.md)**
- Clone and run locally
- 10-15 minutes setup time
- Requires: Python 3.11+, Node.js 18+, Docker, uv
**Quick Start:**
```bash
git clone https://github.com/lfnovo/open-notebook.git
uv sync
make start-all
```
Access: http://localhost:3000 (dev) or http://localhost:8502 (production)
- **[With Ollama (Free Local AI)](examples/docker-compose-ollama.yml)** - Run models locally without API costs
- **[From Source (Developers)](docs/1-INSTALLATION/from-source.md)** - For development and contributions
- **[Complete Installation Guide](docs/1-INSTALLATION/index.md)** - All deployment scenarios
---

View file

@ -58,6 +58,7 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- **routers/notes.py**: POST /notes, GET /notes/{id}
- **routers/sources.py**: POST /sources, GET /sources/{id}, DELETE /sources/{id}
- **routers/models.py**: GET /models, POST /models/config
- **routers/credentials.py**: CRUD + test + discover + migrate for credential management
- **routers/transformations.py**: POST /transformations
- **routers/insights.py**: GET /sources/{source_id}/insights
- **routers/auth.py**: POST /auth/password (password-based auth)
@ -115,3 +116,114 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- **Direct service tests**: Import service, call methods directly with test data
- **Mock graphs**: Replace graph.ainvoke() with mock for testing service logic
- **Database: Use test database** (separate SurrealDB instance or mock repo_query)
---
## Credential Management (API Configuration UI)
The Credential Management system enables users to configure AI provider credentials through the UI instead of environment variables. Keys are stored securely in SurrealDB (encrypted via Fernet) with database-first fallback to environment variables.
### Router: `routers/credentials.py`
**Endpoints**:
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/credentials` | List all credentials (optional `?provider=` filter) |
| GET | `/credentials/by-provider/{provider}` | List credentials for a provider |
| POST | `/credentials` | Create a new credential |
| GET | `/credentials/{credential_id}` | Get a specific credential |
| PUT | `/credentials/{credential_id}` | Update a credential |
| DELETE | `/credentials/{credential_id}` | Delete a credential |
| POST | `/credentials/{credential_id}/test` | Test connection using credential |
| POST | `/credentials/{credential_id}/discover` | Discover available models |
| POST | `/credentials/{credential_id}/register-models` | Register discovered models |
| POST | `/credentials/migrate-from-provider-config` | Migrate from legacy ProviderConfig |
**Supported Providers** (13 total):
- Simple API key: `openai`, `anthropic`, `google`, `groq`, `mistral`, `deepseek`, `xai`, `openrouter`, `voyage`, `elevenlabs`
- URL-based: `ollama`
- Multi-field: `azure`, `vertex`, `openai_compatible`
**Security Features**:
- NEVER returns actual API key values (only metadata)
- URL validation (SSRF protection) on all URL fields via `_validate_url()`
- Allows private IPs and localhost for self-hosted services (Ollama, LM Studio)
- Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` to be set for storing credentials
### Domain Model: `Credential` (`open_notebook/domain/credential.py`)
Individual credential records replacing the old `ProviderConfig` singleton. Each credential stores:
- Provider name, display name, modalities
- Encrypted API key (via Fernet)
- Provider-specific config (base_url, endpoint, api_version, etc.)
### Integration with Key Provider (`open_notebook/ai/key_provider.py`)
The `key_provider` module provisions DB-stored credentials into environment variables for Esperanto compatibility:
**Database-first Pattern**:
1. API endpoint saves keys to `Credential` records (encrypted in SurrealDB)
2. Before model provisioning, `provision_provider_keys(provider)` checks DB, then env vars
3. Keys from DB are set as environment variables for Esperanto compatibility
4. Existing env vars remain unchanged if no DB config exists
**Key Functions**:
- `get_api_key(provider)`: Get API key (DB first, env fallback)
- `provision_provider_keys(provider)`: Set env vars from DB for a provider
- `provision_all_keys()`: Load all provider keys from DB into env vars
### Authentication
No changes to authentication. The `credentials` router uses the same `PasswordAuthMiddleware` as all other endpoints. Keys are protected by the same password-based auth.
**Auth Flow** (unchanged from `api/auth.py`):
- `PasswordAuthMiddleware`: Global middleware checking `Authorization: Bearer {password}` header
- Default password: `open-notebook-change-me` (set `OPEN_NOTEBOOK_PASSWORD` in production)
- Docker secrets support via `OPEN_NOTEBOOK_PASSWORD_FILE`
### Connection Testing (`open_notebook/ai/connection_tester.py`)
The `/credentials/{credential_id}/test` endpoint uses minimal API calls to verify credentials:
- Loads Credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()`
- Uses cheapest/smallest models per provider (TEST_MODELS map)
- Returns success status and descriptive message
- Special handlers for ollama, openai_compatible, and azure providers
### Migration Workflows
Two migration endpoints help users transition to the credential system:
**From environment variables** (`POST /credentials/migrate-from-env`):
1. Checks each provider for env var presence
2. Creates Credential records from env var values
3. Returns summary: migrated, skipped, errors
**From legacy ProviderConfig** (`POST /credentials/migrate-from-provider-config`):
1. Reads old ProviderConfig records from database
2. Converts each to individual Credential records
3. Returns summary: migrated, skipped, errors
### Example Usage
```python
# Check status
GET /credentials/status
# Response: {"configured": {"openai": true, "anthropic": false}, "source": {"openai": "database", "anthropic": "none"}, "encryption_configured": true}
# Create credential
POST /credentials
{"name": "My OpenAI Key", "provider": "openai", "modalities": ["language", "embedding"], "api_key": "sk-proj-..."}
# Test connection
POST /credentials/{credential_id}/test
# Response: {"provider": "openai", "success": true, "message": "Connection successful"}
# Discover models
POST /credentials/{credential_id}/discover
# Response: {"provider": "openai", "models": [{"model_id": "gpt-4", "name": "gpt-4", ...}], "credential_id": "..."}
# Migrate from env
POST /credentials/migrate-from-env
# Response: {"message": "Migration complete. Migrated 3 providers.", "migrated": ["openai", "anthropic", "groq"], "skipped": [], "errors": []}
```

View file

@ -1,21 +1,24 @@
import os
from typing import Optional
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from open_notebook.utils.encryption import get_secret_from_env
class PasswordAuthMiddleware(BaseHTTPMiddleware):
"""
Middleware to check password authentication for all API requests.
Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
Always active with default password if OPEN_NOTEBOOK_PASSWORD is not set.
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
"""
def __init__(self, app, excluded_paths: Optional[list] = None):
super().__init__(app)
self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
self.password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
self.excluded_paths = excluded_paths or [
"/",
"/health",
@ -82,10 +85,13 @@ def check_api_password(
"""
Utility function to check API password.
Can be used as a dependency in individual routes if needed.
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
Returns True without checking credentials if OPEN_NOTEBOOK_PASSWORD is not configured.
Raises 401 if credentials are missing or don't match the configured password.
"""
password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
# No password set, allow access
# No password configured - skip authentication
if not password:
return True

883
api/credentials_service.py Normal file
View file

@ -0,0 +1,883 @@
"""
Credentials Service
Business logic for managing AI provider credentials.
Extracted from the credentials router to follow the service layer pattern.
All functions raise ValueError for business errors (router converts to HTTPException).
"""
import ipaddress
import os
import socket
from typing import Dict, List, Optional
from urllib.parse import urlparse
import httpx
from loguru import logger
from pydantic import SecretStr
from api.models import CredentialResponse
from open_notebook.domain.credential import Credential
from open_notebook.utils.encryption import get_secret_from_env
# =============================================================================
# Constants
# =============================================================================
# Provider environment variable configuration.
# - "required": ALL listed env vars must be set for the provider to be considered configured.
# - "required_any": at least ONE of the listed env vars must be set.
# - "optional": additional env vars used during migration but not required.
PROVIDER_ENV_CONFIG: Dict[str, dict] = {
"openai": {"required": ["OPENAI_API_KEY"]},
"anthropic": {"required": ["ANTHROPIC_API_KEY"]},
"google": {"required_any": ["GOOGLE_API_KEY", "GEMINI_API_KEY"]},
"groq": {"required": ["GROQ_API_KEY"]},
"mistral": {"required": ["MISTRAL_API_KEY"]},
"deepseek": {"required": ["DEEPSEEK_API_KEY"]},
"xai": {"required": ["XAI_API_KEY"]},
"openrouter": {"required": ["OPENROUTER_API_KEY"]},
"voyage": {"required": ["VOYAGE_API_KEY"]},
"elevenlabs": {"required": ["ELEVENLABS_API_KEY"]},
"ollama": {"required": ["OLLAMA_API_BASE"]},
"vertex": {
"required": ["VERTEX_PROJECT", "VERTEX_LOCATION"],
"optional": ["GOOGLE_APPLICATION_CREDENTIALS"],
},
"azure": {
"required": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION"],
"optional": [
"AZURE_OPENAI_ENDPOINT_LLM",
"AZURE_OPENAI_ENDPOINT_EMBEDDING",
"AZURE_OPENAI_ENDPOINT_STT",
"AZURE_OPENAI_ENDPOINT_TTS",
],
},
"openai_compatible": {
"required_any": ["OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"],
},
}
PROVIDER_MODALITIES: Dict[str, List[str]] = {
"openai": ["language", "embedding", "speech_to_text", "text_to_speech"],
"anthropic": ["language"],
"google": ["language", "embedding"],
"groq": ["language", "speech_to_text"],
"mistral": ["language", "embedding"],
"deepseek": ["language"],
"xai": ["language"],
"openrouter": ["language"],
"voyage": ["embedding"],
"elevenlabs": ["text_to_speech"],
"ollama": ["language", "embedding"],
"vertex": ["language", "embedding"],
"azure": ["language", "embedding", "speech_to_text", "text_to_speech"],
"openai_compatible": ["language", "embedding", "speech_to_text", "text_to_speech"],
}
# =============================================================================
# URL Validation (SSRF protection)
# =============================================================================
def validate_url(url: str, provider: str) -> None:
"""
Validate URL format for API endpoints.
This is a self-hosted application, so we allow:
- Private IPs (10.x, 172.16-31.x, 192.168.x) for self-hosted services
- Localhost for local services (Ollama, LM Studio, etc.)
We only block:
- Invalid schemes (must be http or https)
- Malformed URLs
- Link-local addresses (169.254.x.x) - used for cloud metadata endpoints
- Hostnames that resolve to link-local addresses
Args:
url: The URL to validate
provider: The provider name (for logging/context)
Raises:
ValueError: If the URL is invalid
"""
if not url or not url.strip():
return # Empty URLs handled elsewhere
try:
parsed = urlparse(url.strip())
# Validate scheme - only http/https allowed
if parsed.scheme not in ("http", "https"):
raise ValueError(
f"Invalid URL scheme: '{parsed.scheme}'. Only http and https are allowed."
)
# Extract hostname
hostname = parsed.hostname
if not hostname:
raise ValueError("Invalid URL: hostname could not be determined.")
# Try to parse as IP address to check for dangerous addresses
try:
ip = ipaddress.ip_address(hostname)
# Block link-local addresses (169.254.x.x) - used for cloud metadata
# These are dangerous as they can expose cloud instance credentials
if ip.is_link_local:
raise ValueError(
"Link-local addresses (169.254.x.x) are not allowed for security reasons. "
"These addresses are used for cloud metadata endpoints."
)
# Block IPv4-mapped IPv6 addresses pointing to link-local
# e.g. ::ffff:169.254.169.254 bypasses IPv6 is_link_local check
if hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped and ip.ipv4_mapped.is_link_local:
raise ValueError(
"Link-local addresses (169.254.x.x) are not allowed for security reasons. "
"These addresses are used for cloud metadata endpoints."
)
except ValueError as ve:
# Re-raise our own ValueErrors
if "Link-local" in str(ve) or "Invalid URL" in str(ve):
raise
# Not an IP address, it's a hostname - need to resolve and check
try:
# Resolve hostname to IP address
resolved_ips = socket.getaddrinfo(hostname, None)
for family, _, _, _, sockaddr in resolved_ips:
ip_addr = sockaddr[0]
try:
parsed_ip = ipaddress.ip_address(ip_addr)
if parsed_ip.is_link_local:
raise ValueError(
f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
"These addresses are used for cloud metadata endpoints."
)
# Block IPv4-mapped IPv6 addresses pointing to link-local
if (
hasattr(parsed_ip, "ipv4_mapped")
and parsed_ip.ipv4_mapped
and parsed_ip.ipv4_mapped.is_link_local
):
raise ValueError(
f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
"These addresses are used for cloud metadata endpoints."
)
except ValueError as inner_ve:
if "link-local" in str(inner_ve).lower() or "Link-local" in str(inner_ve):
raise
# Skip non-IP addresses (e.g., IPv6 zones)
continue
except socket.gaierror:
# Could not resolve hostname - allow it since the URL may be
# valid in the deployment environment (e.g., Azure endpoints,
# internal DNS names). We only block link-local addresses.
pass
except ValueError:
raise
except Exception:
raise ValueError("Invalid URL format. Check server logs for details.")
# =============================================================================
# Helpers
# =============================================================================
def require_encryption_key() -> None:
"""Raise ValueError if encryption key is not configured."""
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
raise ValueError(
"Encryption key not configured. "
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to enable storing API keys."
)
def credential_to_response(cred: Credential, model_count: int = 0) -> CredentialResponse:
"""Convert a Credential domain object to API response."""
return CredentialResponse(
id=cred.id or "",
name=cred.name,
provider=cred.provider,
modalities=cred.modalities,
base_url=cred.base_url,
endpoint=cred.endpoint,
api_version=cred.api_version,
endpoint_llm=cred.endpoint_llm,
endpoint_embedding=cred.endpoint_embedding,
endpoint_stt=cred.endpoint_stt,
endpoint_tts=cred.endpoint_tts,
project=cred.project,
location=cred.location,
credentials_path=cred.credentials_path,
has_api_key=cred.api_key is not None,
created=str(cred.created) if cred.created else "",
updated=str(cred.updated) if cred.updated else "",
model_count=model_count,
)
def check_env_configured(provider: str) -> bool:
"""Check if a provider has sufficient env vars configured for migration."""
config = PROVIDER_ENV_CONFIG.get(provider)
if not config:
return False
if "required_any" in config:
return any(bool(os.environ.get(v, "").strip()) for v in config["required_any"])
elif "required" in config:
return all(bool(os.environ.get(v, "").strip()) for v in config["required"])
return False
def get_default_modalities(provider: str) -> List[str]:
"""Get default modalities for a provider."""
return PROVIDER_MODALITIES.get(provider.lower(), ["language"])
def create_credential_from_env(provider: str) -> Credential:
"""Create a Credential from environment variables for a given provider."""
modalities = get_default_modalities(provider)
name = "Default (Migrated from env)"
if provider == "ollama":
return Credential(
name=name,
provider=provider,
modalities=modalities,
base_url=os.environ.get("OLLAMA_API_BASE"),
)
elif provider == "vertex":
return Credential(
name=name,
provider=provider,
modalities=modalities,
project=os.environ.get("VERTEX_PROJECT"),
location=os.environ.get("VERTEX_LOCATION"),
credentials_path=os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"),
)
elif provider == "azure":
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(os.environ["AZURE_OPENAI_API_KEY"]),
endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
endpoint_llm=os.environ.get("AZURE_OPENAI_ENDPOINT_LLM"),
endpoint_embedding=os.environ.get("AZURE_OPENAI_ENDPOINT_EMBEDDING"),
endpoint_stt=os.environ.get("AZURE_OPENAI_ENDPOINT_STT"),
endpoint_tts=os.environ.get("AZURE_OPENAI_ENDPOINT_TTS"),
)
elif provider == "openai_compatible":
api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY")
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(api_key) if api_key else None,
base_url=os.environ.get("OPENAI_COMPATIBLE_BASE_URL"),
)
elif provider == "google":
# Support both GOOGLE_API_KEY and GEMINI_API_KEY (fallback)
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(api_key) if api_key else None,
)
else:
# Simple API key providers
config = PROVIDER_ENV_CONFIG.get(provider, {})
required = config.get("required", [])
env_var = required[0] if required else None
api_key = os.environ.get(env_var) if env_var else None
return Credential(
name=name,
provider=provider,
modalities=modalities,
api_key=SecretStr(api_key) if api_key else None,
)
# =============================================================================
# Service Functions
# =============================================================================
async def get_provider_status() -> dict:
"""
Get configuration status: encryption key status, and per-provider
configured/source information.
"""
encryption_configured = bool(get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"))
configured: Dict[str, bool] = {}
source: Dict[str, str] = {}
for provider in PROVIDER_ENV_CONFIG:
env_configured = check_env_configured(provider)
try:
db_credentials = await Credential.get_by_provider(provider)
db_configured = len(db_credentials) > 0
except Exception:
db_configured = False
configured[provider] = db_configured or env_configured
if db_configured:
source[provider] = "database"
elif env_configured:
source[provider] = "environment"
else:
source[provider] = "none"
return {
"configured": configured,
"source": source,
"encryption_configured": encryption_configured,
}
async def get_env_status() -> Dict[str, bool]:
"""Check what's configured via environment variables."""
env_status: Dict[str, bool] = {}
for provider in PROVIDER_ENV_CONFIG:
env_status[provider] = check_env_configured(provider)
return env_status
async def test_credential(credential_id: str) -> dict:
"""
Test connection using a credential's configuration.
Returns dict with provider, success, message keys.
"""
provider = "unknown"
try:
cred = await Credential.get(credential_id)
config = cred.to_esperanto_config()
from open_notebook.ai.connection_tester import (
_test_azure_connection,
_test_ollama_connection,
_test_openai_compatible_connection,
)
provider = cred.provider.lower()
# Handle special providers
if provider == "ollama":
base_url = config.get("base_url", "http://localhost:11434")
success, message = await _test_ollama_connection(base_url)
return {"provider": provider, "success": success, "message": message}
if provider == "openai_compatible":
base_url = config.get("base_url")
api_key = config.get("api_key")
if not base_url:
return {
"provider": provider,
"success": False,
"message": "No base URL configured",
}
success, message = await _test_openai_compatible_connection(
base_url, api_key
)
return {"provider": provider, "success": success, "message": message}
if provider == "azure":
success, message = await _test_azure_connection(
endpoint=config.get("endpoint"),
api_key=config.get("api_key"),
api_version=config.get("api_version"),
)
return {"provider": provider, "success": success, "message": message}
# Standard provider: use Esperanto to create and test
from esperanto.factory import AIFactory
from open_notebook.ai.connection_tester import TEST_MODELS
if provider not in TEST_MODELS:
return {
"provider": provider,
"success": False,
"message": f"Unknown provider: {provider}",
}
test_model, test_type = TEST_MODELS[provider]
if not test_model:
return {
"provider": provider,
"success": False,
"message": f"No test model configured for {provider}",
}
if test_type == "language":
model = AIFactory.create_language(
model_name=test_model, provider=provider, config=config
)
lc_model = model.to_langchain()
await lc_model.ainvoke("Hi")
return {"provider": provider, "success": True, "message": "Connection successful"}
elif test_type == "embedding":
model = AIFactory.create_embedding(
model_name=test_model, provider=provider, config=config
)
await model.aembed(["test"])
return {"provider": provider, "success": True, "message": "Connection successful"}
elif test_type == "text_to_speech":
AIFactory.create_text_to_speech(model_name=test_model, provider=provider, config=config)
return {
"provider": provider,
"success": True,
"message": "Connection successful (key format valid)",
}
return {
"provider": provider,
"success": False,
"message": f"Unsupported test type: {test_type}",
}
except Exception as e:
error_msg = str(e)
if "401" in error_msg or "unauthorized" in error_msg.lower():
return {"provider": provider, "success": False, "message": "Invalid API key"}
elif "403" in error_msg or "forbidden" in error_msg.lower():
return {"provider": provider, "success": False, "message": "API key lacks required permissions"}
elif "rate" in error_msg.lower() and "limit" in error_msg.lower():
return {"provider": provider, "success": True, "message": "Rate limited - but connection works"}
elif "not found" in error_msg.lower() and "model" in error_msg.lower():
return {"provider": provider, "success": True, "message": "API key valid (test model not available)"}
else:
logger.debug(f"Test connection error for credential {credential_id}: {e}")
truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg
return {"provider": provider, "success": False, "message": f"Error: {truncated}"}
async def discover_with_config(provider: str, config: dict) -> List[dict]:
"""
Discover models using explicit config instead of env vars.
Returns model names only no type classification.
The user chooses the model type when registering.
"""
api_key = config.get("api_key")
base_url = config.get("base_url")
# Static model lists for providers without a listing API
STATIC_MODELS: Dict[str, List[str]] = {
"anthropic": [
"claude-opus-4-20250514",
"claude-sonnet-4-20250514",
"claude-3-5-sonnet-20241022",
"claude-3-5-haiku-20241022",
"claude-3-opus-20240229",
"claude-3-sonnet-20240229",
"claude-3-haiku-20240307",
],
"voyage": [
"voyage-3", "voyage-3-lite", "voyage-code-3",
"voyage-finance-2", "voyage-law-2", "voyage-multilingual-2",
],
"elevenlabs": [
"eleven_multilingual_v2", "eleven_turbo_v2_5",
"eleven_turbo_v2", "eleven_monolingual_v1",
],
}
if provider in STATIC_MODELS:
if not api_key and provider != "ollama":
return []
return [
{"name": m, "provider": provider}
for m in STATIC_MODELS[provider]
]
# API-based discovery URLs (OpenAI-style /models endpoints)
url_map = {
"openai": "https://api.openai.com/v1/models",
"groq": "https://api.groq.com/openai/v1/models",
"mistral": "https://api.mistral.ai/v1/models",
"deepseek": "https://api.deepseek.com/models",
"xai": "https://api.x.ai/v1/models",
"openrouter": "https://openrouter.ai/api/v1/models",
}
if provider == "ollama":
ollama_url = base_url or "http://localhost:11434"
try:
async with httpx.AsyncClient() as client:
response = await client.get(f"{ollama_url}/api/tags", timeout=10.0)
response.raise_for_status()
data = response.json()
return [
{"name": m.get("name", ""), "provider": "ollama"}
for m in data.get("models", [])
if m.get("name")
]
except Exception as e:
logger.warning(f"Failed to discover Ollama models: {e}")
return []
if provider == "openai_compatible":
if not base_url:
return []
try:
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with httpx.AsyncClient() as client:
response = await client.get(
f"{base_url.rstrip('/')}/models", headers=headers, timeout=30.0,
)
response.raise_for_status()
data = response.json()
return [
{"name": m.get("id", ""), "provider": "openai_compatible"}
for m in data.get("data", [])
if m.get("id")
]
except Exception as e:
logger.warning(f"Failed to discover openai_compatible models: {e}")
return []
if provider == "azure":
endpoint = config.get("endpoint")
api_version = config.get("api_version", "2024-06-01")
if not endpoint or not api_key:
return []
try:
url = f"{endpoint.rstrip('/')}/openai/models?api-version={api_version}"
headers = {"api-key": api_key}
async with httpx.AsyncClient() as client:
response = await client.get(url, headers=headers, timeout=30.0)
response.raise_for_status()
data = response.json()
return [
{"name": m.get("id", ""), "provider": "azure"}
for m in data.get("data", [])
if m.get("id")
]
except Exception as e:
logger.warning(f"Failed to discover Azure models: {e}")
return []
if provider == "vertex":
# Vertex AI requires service-account OAuth2 for model listing.
# Return a curated static list of well-known Vertex models instead.
VERTEX_MODELS = [
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-1.5-pro",
"gemini-1.5-flash",
"text-embedding-005",
]
return [{"name": m, "provider": "vertex"} for m in VERTEX_MODELS]
if provider == "google":
try:
headers = {"X-Goog-Api-Key": api_key} if api_key else {}
async with httpx.AsyncClient() as client:
response = await client.get(
"https://generativelanguage.googleapis.com/v1/models",
headers=headers,
timeout=30.0,
)
response.raise_for_status()
data = response.json()
return [
{
"name": model.get("name", "").replace("models/", ""),
"provider": "google",
"description": model.get("displayName"),
}
for model in data.get("models", [])
if model.get("name")
]
except Exception as e:
logger.warning(f"Failed to discover Google models: {e}")
return []
# Standard OpenAI-style API discovery
discovery_url = url_map.get(provider)
if not discovery_url or not api_key:
return []
try:
async with httpx.AsyncClient() as client:
response = await client.get(
discovery_url,
headers={"Authorization": f"Bearer {api_key}"},
timeout=30.0,
)
response.raise_for_status()
data = response.json()
return [
{
"name": m.get("id", ""),
"provider": provider,
"description": m.get("name"),
}
for m in data.get("data", [])
if m.get("id")
]
except Exception as e:
logger.warning(f"Failed to discover {provider} models: {e}")
return []
async def register_models(credential_id: str, models_data: list) -> dict:
"""
Register discovered models and link them to a credential.
Args:
credential_id: The credential ID to link models to
models_data: List of dicts with name, provider, model_type
Returns:
dict with created and existing counts
"""
cred = await Credential.get(credential_id)
from open_notebook.ai.models import Model
from open_notebook.database.repository import repo_query
# Batch fetch existing models for this provider
existing_models = await repo_query(
"SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model "
"WHERE string::lowercase(provider) = $provider",
{"provider": cred.provider.lower()},
)
existing_keys = {(m["name"], m["type"]) for m in existing_models}
created = 0
existing = 0
for model_data in models_data:
key = (model_data.name.lower(), model_data.model_type.lower())
if key in existing_keys:
existing += 1
continue
new_model = Model(
name=model_data.name,
provider=model_data.provider or cred.provider,
type=model_data.model_type,
credential=cred.id,
)
await new_model.save()
created += 1
return {"created": created, "existing": existing}
async def migrate_from_provider_config() -> dict:
"""
Migrate existing ProviderConfig data to individual credential records.
Returns dict with message, migrated, skipped, errors.
"""
logger.info("=== Starting ProviderConfig migration ===")
require_encryption_key()
logger.info("Encryption key verified")
from open_notebook.domain.provider_config import ProviderConfig
config = await ProviderConfig.get_instance()
logger.info(
f"Found ProviderConfig with {len(config.credentials)} provider(s): "
f"{', '.join(config.credentials.keys())}"
)
migrated = []
skipped = []
errors = []
for provider, credentials_list in config.credentials.items():
for old_cred in credentials_list:
try:
# Check if a credential already exists for this provider with same name
existing = await Credential.get_by_provider(provider)
names = [c.name for c in existing]
if old_cred.name in names:
logger.info(
f"[{provider}/{old_cred.name}] Already exists in DB, skipping"
)
skipped.append(f"{provider}/{old_cred.name}")
continue
# Determine modalities from the provider type
modalities = get_default_modalities(provider)
logger.info(f"[{provider}/{old_cred.name}] Creating credential")
new_cred = Credential(
name=old_cred.name,
provider=provider,
modalities=modalities,
api_key=old_cred.api_key,
base_url=old_cred.base_url,
endpoint=old_cred.endpoint,
api_version=old_cred.api_version,
endpoint_llm=old_cred.endpoint_llm,
endpoint_embedding=old_cred.endpoint_embedding,
endpoint_stt=old_cred.endpoint_stt,
endpoint_tts=old_cred.endpoint_tts,
project=old_cred.project,
location=old_cred.location,
credentials_path=old_cred.credentials_path,
)
await new_cred.save()
logger.info(
f"[{provider}/{old_cred.name}] Credential saved (id={new_cred.id})"
)
# Link existing models for this provider to the new credential
from open_notebook.ai.models import Model
from open_notebook.database.repository import repo_query
provider_models = await repo_query(
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
{"provider": provider.lower()},
)
if provider_models:
logger.info(
f"[{provider}/{old_cred.name}] Linking {len(provider_models)} "
f"unassigned model(s)"
)
for model_data in provider_models:
model = Model(**model_data)
model.credential = new_cred.id
await model.save()
migrated.append(f"{provider}/{old_cred.name}")
except Exception as e:
logger.error(
f"[{provider}/{old_cred.name}] Migration FAILED: "
f"{type(e).__name__}: {e}",
exc_info=True,
)
errors.append(f"{provider}/{old_cred.name}: {e}")
logger.info(
f"=== ProviderConfig migration complete === "
f"migrated={len(migrated)} skipped={len(skipped)} errors={len(errors)}"
)
if migrated:
logger.info(f" Migrated: {', '.join(migrated)}")
if skipped:
logger.info(f" Skipped: {', '.join(skipped)}")
if errors:
logger.error(f" Errors: {'; '.join(errors)}")
return {
"message": f"Migration complete. Migrated {len(migrated)} credentials.",
"migrated": migrated,
"skipped": skipped,
"errors": errors,
}
async def migrate_from_env() -> dict:
"""
Migrate API keys from environment variables to credential records.
Returns dict with message, migrated, skipped, not_configured, errors.
"""
logger.info("=== Starting environment variable migration ===")
logger.info(
f"Checking {len(PROVIDER_ENV_CONFIG)} providers: "
f"{', '.join(PROVIDER_ENV_CONFIG.keys())}"
)
require_encryption_key()
logger.info("Encryption key verified")
from open_notebook.ai.models import Model
from open_notebook.database.repository import repo_query
migrated = []
skipped = []
not_configured = []
errors = []
for provider in PROVIDER_ENV_CONFIG:
try:
if not check_env_configured(provider):
logger.debug(f"[{provider}] No env vars configured, skipping")
not_configured.append(provider)
continue
logger.info(f"[{provider}] Env vars detected, checking for existing credentials")
existing = await Credential.get_by_provider(provider)
if existing:
logger.info(
f"[{provider}] Already has {len(existing)} credential(s) in DB, skipping"
)
skipped.append(provider)
continue
logger.info(f"[{provider}] Creating credential from env vars")
cred = create_credential_from_env(provider)
await cred.save()
logger.info(f"[{provider}] Credential saved successfully (id={cred.id})")
# Link unassigned models to this credential
provider_models = await repo_query(
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
{"provider": provider.lower()},
)
if provider_models:
logger.info(
f"[{provider}] Linking {len(provider_models)} unassigned model(s) "
f"to credential {cred.id}"
)
for model_data in provider_models:
model = Model(**model_data)
model.credential = cred.id
await model.save()
else:
logger.info(f"[{provider}] No unassigned models to link")
migrated.append(provider)
except Exception as e:
logger.error(
f"[{provider}] Migration FAILED: {type(e).__name__}: {e}",
exc_info=True,
)
errors.append(f"{provider}: {e}")
logger.info(
f"=== Environment variable migration complete === "
f"migrated={len(migrated)} skipped={len(skipped)} "
f"not_configured={len(not_configured)} errors={len(errors)}"
)
if migrated:
logger.info(f" Migrated: {', '.join(migrated)}")
if skipped:
logger.info(f" Skipped (already in DB): {', '.join(skipped)}")
if errors:
logger.error(f" Errors: {'; '.join(errors)}")
return {
"message": f"Migration complete. Migrated {len(migrated)} providers.",
"migrated": migrated,
"skipped": skipped,
"not_configured": not_configured,
"errors": errors,
}

View file

@ -12,11 +12,22 @@ from loguru import logger
from starlette.exceptions import HTTPException as StarletteHTTPException
from api.auth import PasswordAuthMiddleware
from open_notebook.exceptions import (
AuthenticationError,
ConfigurationError,
ExternalServiceError,
InvalidInputError,
NetworkError,
NotFoundError,
OpenNotebookError,
RateLimitError,
)
from api.routers import (
auth,
chat,
config,
context,
credentials,
embedding,
embedding_rebuild,
episode_profiles,
@ -34,6 +45,7 @@ from api.routers import (
)
from api.routers import commands as commands_router
from open_notebook.database.async_migrate import AsyncMigrationManager
from open_notebook.utils.encryption import get_secret_from_env
# Import commands to register them in the API process
try:
@ -48,9 +60,21 @@ async def lifespan(app: FastAPI):
Lifespan event handler for the FastAPI application.
Runs database migrations automatically on startup.
"""
# Startup: Run database migrations
import os
# Startup: Security checks
logger.info("Starting API initialization...")
# Security check: Encryption key
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
logger.warning(
"OPEN_NOTEBOOK_ENCRYPTION_KEY not set. "
"API key encryption will fail until this is configured. "
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string."
)
# Run database migrations
try:
migration_manager = AsyncMigrationManager()
current_version = await migration_manager.get_current_version()
@ -140,6 +164,88 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
)
def _cors_headers(request: Request) -> dict[str, str]:
origin = request.headers.get("origin", "*")
return {
"Access-Control-Allow-Origin": origin,
"Access-Control-Allow-Credentials": "true",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}
@app.exception_handler(NotFoundError)
async def not_found_error_handler(request: Request, exc: NotFoundError):
return JSONResponse(
status_code=404,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(InvalidInputError)
async def invalid_input_error_handler(request: Request, exc: InvalidInputError):
return JSONResponse(
status_code=400,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(AuthenticationError)
async def authentication_error_handler(request: Request, exc: AuthenticationError):
return JSONResponse(
status_code=401,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(RateLimitError)
async def rate_limit_error_handler(request: Request, exc: RateLimitError):
return JSONResponse(
status_code=429,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(ConfigurationError)
async def configuration_error_handler(request: Request, exc: ConfigurationError):
return JSONResponse(
status_code=422,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(NetworkError)
async def network_error_handler(request: Request, exc: NetworkError):
return JSONResponse(
status_code=502,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(ExternalServiceError)
async def external_service_error_handler(request: Request, exc: ExternalServiceError):
return JSONResponse(
status_code=502,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
@app.exception_handler(OpenNotebookError)
async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
return JSONResponse(
status_code=500,
content={"detail": str(exc)},
headers=_cors_headers(request),
)
# Include routers
app.include_router(auth.router, prefix="/api", tags=["auth"])
app.include_router(config.router, prefix="/api", tags=["config"])
@ -162,6 +268,7 @@ app.include_router(episode_profiles.router, prefix="/api", tags=["episode-profil
app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profiles"])
app.include_router(chat.router, prefix="/api", tags=["chat"])
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
app.include_router(credentials.router, prefix="/api", tags=["credentials"])
@app.get("/")

View file

@ -1,6 +1,6 @@
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Notebook models
@ -68,6 +68,9 @@ class ModelCreate(BaseModel):
...,
description="Model type (language, embedding, text_to_speech, speech_to_text)",
)
credential: Optional[str] = Field(
None, description="Credential ID to link this model to"
)
class ModelResponse(BaseModel):
@ -75,6 +78,7 @@ class ModelResponse(BaseModel):
name: str
provider: str
type: str
credential: Optional[str] = None
created: str
updated: str
@ -189,6 +193,7 @@ class NoteResponse(BaseModel):
note_type: Optional[str]
created: str
updated: str
command_id: Optional[str] = None
# Embedding API models
@ -434,7 +439,231 @@ class ErrorResponse(BaseModel):
message: str
# API Key Configuration models
class SetApiKeyRequest(BaseModel):
"""Request to set an API key for a provider."""
api_key: Optional[str] = Field(None, description="API key for the provider")
base_url: Optional[str] = Field(
None, description="Base URL for URL-based providers (Ollama, OpenAI-compatible)"
)
endpoint: Optional[str] = Field(
None, description="Endpoint URL for Azure OpenAI"
)
api_version: Optional[str] = Field(
None, description="API version for Azure OpenAI"
)
endpoint_llm: Optional[str] = Field(
None, description="Service-specific endpoint for LLM (Azure)"
)
endpoint_embedding: Optional[str] = Field(
None, description="Service-specific endpoint for embedding (Azure)"
)
endpoint_stt: Optional[str] = Field(
None, description="Service-specific endpoint for STT (Azure)"
)
endpoint_tts: Optional[str] = Field(
None, description="Service-specific endpoint for TTS (Azure)"
)
service_type: Optional[Literal["llm", "embedding", "stt", "tts"]] = Field(
None,
description="Service type for OpenAI-compatible providers (llm, embedding, stt, tts)",
)
# Vertex AI specific fields
vertex_project: Optional[str] = Field(
None, description="Google Cloud Project ID for Vertex AI"
)
vertex_location: Optional[str] = Field(
None, description="Google Cloud Region for Vertex AI (e.g., us-central1)"
)
vertex_credentials_path: Optional[str] = Field(
None, description="Path to Google Cloud service account JSON file"
)
@field_validator(
"api_key",
"base_url",
"endpoint",
"api_version",
"endpoint_llm",
"endpoint_embedding",
"endpoint_stt",
"endpoint_tts",
"vertex_project",
"vertex_location",
"vertex_credentials_path",
mode="before",
)
@classmethod
def validate_not_empty_string(cls, v: Optional[str]) -> Optional[str]:
"""Reject empty strings - convert to None or raise error."""
if v is not None:
stripped = v.strip()
if not stripped:
return None # Treat empty/whitespace-only as None
return stripped
return v
class ApiKeyStatusResponse(BaseModel):
"""Response showing which providers are configured and their source."""
configured: Dict[str, bool] = Field(
..., description="Map of provider name to whether it is configured"
)
source: Dict[str, Literal["database", "environment", "none"]] = Field(
...,
description="Map of provider name to configuration source (database, environment, or none)",
)
encryption_configured: bool = Field(
...,
description="Whether OPEN_NOTEBOOK_ENCRYPTION_KEY is set (required to store keys in database)",
)
class TestConnectionResponse(BaseModel):
"""Response from testing a provider connection."""
provider: str = Field(..., description="Provider name that was tested")
success: bool = Field(..., description="Whether connection test succeeded")
message: str = Field(..., description="Result message with details")
class MigrateFromEnvRequest(BaseModel):
"""Request to migrate API keys from environment variables to database."""
force: bool = Field(
False, description="Force overwrite existing database configurations"
)
class MigrationResult(BaseModel):
"""Response from migrating API keys from environment to database."""
message: str = Field(..., description="Summary message")
migrated: List[str] = Field(
default_factory=list, description="Providers successfully migrated"
)
skipped: List[str] = Field(
default_factory=list, description="Providers skipped (already in DB)"
)
errors: List[str] = Field(
default_factory=list, description="Migration errors by provider"
)
# Notebook delete cascade models
# Credential models
class CreateCredentialRequest(BaseModel):
"""Request to create a new credential."""
name: str = Field(..., description="Credential name")
provider: str = Field(..., description="Provider name (openai, anthropic, etc.)")
modalities: List[str] = Field(
default_factory=list,
description="Supported modalities (language, embedding, text_to_speech, speech_to_text)",
)
api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
base_url: Optional[str] = Field(None, description="Base URL")
endpoint: Optional[str] = Field(None, description="Endpoint URL (Azure)")
api_version: Optional[str] = Field(None, description="API version (Azure)")
endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
project: Optional[str] = Field(None, description="Project ID (Vertex)")
location: Optional[str] = Field(None, description="Location (Vertex)")
credentials_path: Optional[str] = Field(
None, description="Credentials file path (Vertex)"
)
class UpdateCredentialRequest(BaseModel):
"""Request to update an existing credential."""
name: Optional[str] = Field(None, description="Credential name")
modalities: Optional[List[str]] = Field(None, description="Supported modalities")
api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
base_url: Optional[str] = Field(None, description="Base URL")
endpoint: Optional[str] = Field(None, description="Endpoint URL")
api_version: Optional[str] = Field(None, description="API version")
endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
project: Optional[str] = Field(None, description="Project ID")
location: Optional[str] = Field(None, description="Location")
credentials_path: Optional[str] = Field(None, description="Credentials path")
class CredentialResponse(BaseModel):
"""Response for a credential (never includes api_key)."""
id: str
name: str
provider: str
modalities: List[str]
base_url: Optional[str] = None
endpoint: Optional[str] = None
api_version: Optional[str] = None
endpoint_llm: Optional[str] = None
endpoint_embedding: Optional[str] = None
endpoint_stt: Optional[str] = None
endpoint_tts: Optional[str] = None
project: Optional[str] = None
location: Optional[str] = None
credentials_path: Optional[str] = None
has_api_key: bool = False
created: str
updated: str
model_count: int = 0
class CredentialDeleteResponse(BaseModel):
"""Response for credential deletion."""
message: str
deleted_models: int = 0
class DiscoveredModelResponse(BaseModel):
"""A model discovered from a provider."""
name: str
provider: str
model_type: Optional[str] = None
description: Optional[str] = None
class DiscoverModelsResponse(BaseModel):
"""Response from model discovery."""
credential_id: str
provider: str
discovered: List[DiscoveredModelResponse]
class RegisterModelData(BaseModel):
"""A model to register with user-specified type."""
name: str
provider: str
model_type: str # Required: user specifies the type
class RegisterModelsRequest(BaseModel):
"""Request to register discovered models."""
models: List[RegisterModelData]
class RegisterModelsResponse(BaseModel):
"""Response from model registration."""
created: int
existing: int
class NotebookDeletePreview(BaseModel):
notebook_id: str = Field(..., description="ID of the notebook")
notebook_name: str = Field(..., description="Name of the notebook")

View file

@ -3,10 +3,10 @@ Authentication router for Open Notebook API.
Provides endpoints to check authentication status.
"""
import os
from fastapi import APIRouter
from open_notebook.utils.encryption import get_secret_from_env
router = APIRouter(prefix="/auth", tags=["auth"])
@ -15,12 +15,13 @@ async def get_auth_status():
"""
Check if authentication is enabled.
Returns whether a password is required to access the API.
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
"""
auth_enabled = bool(os.environ.get("OPEN_NOTEBOOK_PASSWORD"))
auth_enabled = bool(get_secret_from_env("OPEN_NOTEBOOK_PASSWORD"))
return {
"auth_enabled": auth_enabled,
"message": "Authentication is required"
if auth_enabled
else "Authentication is disabled",
}
}

386
api/routers/credentials.py Normal file
View file

@ -0,0 +1,386 @@
"""
Credentials Router
Thin HTTP layer for managing individual AI provider credentials.
Business logic lives in api.credentials_service.
Endpoints:
- GET /credentials - List all credentials
- GET /credentials/by-provider/{provider} - List credentials for a provider
- POST /credentials - Create a new credential
- GET /credentials/{credential_id} - Get a specific credential
- PUT /credentials/{credential_id} - Update a credential
- DELETE /credentials/{credential_id} - Delete a credential
- POST /credentials/{credential_id}/test - Test connection
- POST /credentials/{credential_id}/discover - Discover models
- POST /credentials/{credential_id}/register-models - Register models
NEVER returns actual API key values - only metadata.
"""
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import SecretStr
from api.credentials_service import (
credential_to_response,
discover_with_config,
migrate_from_env as svc_migrate_from_env,
migrate_from_provider_config as svc_migrate_from_provider_config,
register_models,
require_encryption_key,
test_credential as svc_test_credential,
validate_url,
)
from api.credentials_service import (
get_env_status as svc_get_env_status,
get_provider_status,
)
from api.models import (
CreateCredentialRequest,
CredentialDeleteResponse,
CredentialResponse,
DiscoveredModelResponse,
DiscoverModelsResponse,
RegisterModelsRequest,
RegisterModelsResponse,
UpdateCredentialRequest,
)
from open_notebook.domain.credential import Credential
router = APIRouter(prefix="/credentials", tags=["credentials"])
def _handle_value_error(e: ValueError, status_code: int = 400) -> HTTPException:
"""Convert a ValueError from the service layer to an HTTPException."""
return HTTPException(status_code=status_code, detail=str(e))
# =============================================================================
# Status endpoints
# =============================================================================
@router.get("/status")
async def get_status():
"""
Get configuration status: encryption key status, and per-provider
configured/source information.
"""
try:
return await get_provider_status()
except Exception as e:
logger.error(f"Error fetching status: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch credential status")
@router.get("/env-status")
async def get_env_status():
"""Check what's configured via environment variables."""
try:
return await svc_get_env_status()
except Exception as e:
logger.error(f"Error checking env status: {e}")
raise HTTPException(status_code=500, detail="Failed to check environment status")
# =============================================================================
# CRUD endpoints
# =============================================================================
@router.get("", response_model=List[CredentialResponse])
async def list_credentials(
provider: Optional[str] = Query(None, description="Filter by provider"),
):
"""List all credentials, optionally filtered by provider."""
try:
if provider:
credentials = await Credential.get_by_provider(provider)
else:
credentials = await Credential.get_all(order_by="provider, created")
result = []
for cred in credentials:
models = await cred.get_linked_models()
result.append(credential_to_response(cred, len(models)))
return result
except Exception as e:
logger.error(f"Error listing credentials: {e}")
raise HTTPException(status_code=500, detail="Failed to list credentials")
@router.get("/by-provider/{provider}", response_model=List[CredentialResponse])
async def list_credentials_by_provider(provider: str):
"""List all credentials for a specific provider."""
try:
credentials = await Credential.get_by_provider(provider.lower())
result = []
for cred in credentials:
models = await cred.get_linked_models()
result.append(credential_to_response(cred, len(models)))
return result
except Exception as e:
logger.error(f"Error listing credentials for {provider}: {e}")
raise HTTPException(status_code=500, detail="Failed to list credentials for provider")
@router.post("", response_model=CredentialResponse, status_code=201)
async def create_credential(request: CreateCredentialRequest):
"""Create a new credential."""
try:
require_encryption_key()
except ValueError as e:
raise _handle_value_error(e)
# Validate all URL fields
for url_field in [
request.base_url, request.endpoint, request.endpoint_llm,
request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
]:
if url_field:
try:
validate_url(url_field, request.provider)
except ValueError as e:
raise _handle_value_error(e)
try:
cred = Credential(
name=request.name,
provider=request.provider.lower(),
modalities=request.modalities,
api_key=SecretStr(request.api_key) if request.api_key else None,
base_url=request.base_url,
endpoint=request.endpoint,
api_version=request.api_version,
endpoint_llm=request.endpoint_llm,
endpoint_embedding=request.endpoint_embedding,
endpoint_stt=request.endpoint_stt,
endpoint_tts=request.endpoint_tts,
project=request.project,
location=request.location,
credentials_path=request.credentials_path,
)
await cred.save()
return credential_to_response(cred, 0)
except Exception as e:
logger.error(f"Error creating credential: {e}")
raise HTTPException(status_code=500, detail="Failed to create credential")
@router.get("/{credential_id}", response_model=CredentialResponse)
async def get_credential(credential_id: str):
"""Get a specific credential by ID. Never returns api_key."""
try:
cred = await Credential.get(credential_id)
models = await cred.get_linked_models()
return credential_to_response(cred, len(models))
except Exception as e:
logger.error(f"Error fetching credential {credential_id}: {e}")
raise HTTPException(status_code=404, detail="Credential not found")
@router.put("/{credential_id}", response_model=CredentialResponse)
async def update_credential(credential_id: str, request: UpdateCredentialRequest):
"""Update an existing credential."""
try:
require_encryption_key()
except ValueError as e:
raise _handle_value_error(e)
# Validate all URL fields being updated
for url_field in [
request.base_url, request.endpoint, request.endpoint_llm,
request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
]:
if url_field:
try:
validate_url(url_field, "update")
except ValueError as e:
raise _handle_value_error(e)
try:
cred = await Credential.get(credential_id)
if request.name is not None:
cred.name = request.name
if request.modalities is not None:
cred.modalities = request.modalities
if request.api_key is not None:
cred.api_key = SecretStr(request.api_key)
if request.base_url is not None:
cred.base_url = request.base_url or None
if request.endpoint is not None:
cred.endpoint = request.endpoint or None
if request.api_version is not None:
cred.api_version = request.api_version or None
if request.endpoint_llm is not None:
cred.endpoint_llm = request.endpoint_llm or None
if request.endpoint_embedding is not None:
cred.endpoint_embedding = request.endpoint_embedding or None
if request.endpoint_stt is not None:
cred.endpoint_stt = request.endpoint_stt or None
if request.endpoint_tts is not None:
cred.endpoint_tts = request.endpoint_tts or None
if request.project is not None:
cred.project = request.project or None
if request.location is not None:
cred.location = request.location or None
if request.credentials_path is not None:
cred.credentials_path = request.credentials_path or None
await cred.save()
models = await cred.get_linked_models()
return credential_to_response(cred, len(models))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to update credential")
@router.delete("/{credential_id}", response_model=CredentialDeleteResponse)
async def delete_credential(
credential_id: str,
delete_models: bool = Query(False, description="Also delete linked models"),
migrate_to: Optional[str] = Query(
None, description="Migrate linked models to this credential ID"
),
):
"""
Delete a credential.
If the credential has linked models:
- Pass delete_models=true to delete them
- Pass migrate_to=<credential_id> to reassign them
- Without either, returns 409 with linked model info
"""
try:
cred = await Credential.get(credential_id)
linked_models = await cred.get_linked_models()
if linked_models and not delete_models and not migrate_to:
raise HTTPException(
status_code=409,
detail={
"message": f"Credential has {len(linked_models)} linked model(s)",
"model_ids": [m.id for m in linked_models],
"model_names": [f"{m.provider}/{m.name}" for m in linked_models],
},
)
deleted_models = 0
if linked_models and migrate_to:
# Migrate models to another credential
target_cred = await Credential.get(migrate_to)
for model in linked_models:
model.credential = target_cred.id
await model.save()
elif linked_models and delete_models:
# Delete linked models
for model in linked_models:
await model.delete()
deleted_models += 1
# Delete the credential
await cred.delete()
return CredentialDeleteResponse(
message="Credential deleted successfully",
deleted_models=deleted_models,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to delete credential")
# =============================================================================
# Test / Discover / Register endpoints
# =============================================================================
@router.post("/{credential_id}/test")
async def test_credential(credential_id: str):
"""Test connection using this credential's configuration."""
return await svc_test_credential(credential_id)
@router.post("/{credential_id}/discover", response_model=DiscoverModelsResponse)
async def discover_models_for_credential(credential_id: str):
"""Discover available models using this credential's API key."""
try:
cred = await Credential.get(credential_id)
config = cred.to_esperanto_config()
provider = cred.provider.lower()
discovered = await discover_with_config(provider, config)
return DiscoverModelsResponse(
credential_id=cred.id or "",
provider=provider,
discovered=[
DiscoveredModelResponse(
name=d["name"],
provider=d["provider"],
description=d.get("description"),
)
for d in discovered
],
)
except Exception as e:
logger.error(f"Error discovering models for credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to discover models")
@router.post("/{credential_id}/register-models", response_model=RegisterModelsResponse)
async def register_models_for_credential(
credential_id: str, request: RegisterModelsRequest
):
"""Register discovered models and link them to this credential."""
try:
result = await register_models(credential_id, request.models)
return RegisterModelsResponse(**result)
except Exception as e:
logger.error(f"Error registering models for credential {credential_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to register models")
# =============================================================================
# Migration endpoints
# =============================================================================
@router.post("/migrate-from-provider-config")
async def migrate_from_provider_config():
"""Migrate existing ProviderConfig data to individual credential records."""
try:
return await svc_migrate_from_provider_config()
except ValueError as e:
raise _handle_value_error(e)
except Exception as e:
logger.error(f"ProviderConfig migration FAILED: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Migration from provider config failed")
@router.post("/migrate-from-env")
async def migrate_from_env():
"""Migrate API keys from environment variables to credential records."""
try:
return await svc_migrate_from_env()
except ValueError as e:
raise _handle_value_error(e)
except Exception as e:
logger.error(f"Env migration FAILED: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail="Migration from environment variables failed")

View file

@ -1,9 +1,11 @@
import os
from typing import List, Optional
import traceback
from typing import Dict, List, Optional
from esperanto import AIFactory
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from pydantic import BaseModel
from api.models import (
DefaultModelsResponse,
@ -11,25 +13,109 @@ from api.models import (
ModelResponse,
ProviderAvailabilityResponse,
)
from open_notebook.domain.credential import Credential
from open_notebook.ai.connection_tester import test_individual_model
from open_notebook.ai.key_provider import provision_provider_keys
from open_notebook.ai.model_discovery import (
discover_provider_models,
get_provider_model_count,
sync_all_providers,
sync_provider_models,
)
from open_notebook.ai.models import DefaultModels, Model
from open_notebook.exceptions import InvalidInputError
router = APIRouter()
def _check_openai_compatible_support(mode: str) -> bool:
"""
Check if OpenAI-compatible provider is available for a specific mode.
# =============================================================================
# Model Discovery Response Models
# =============================================================================
Args:
mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
Returns:
bool: True if either generic or mode-specific env var is set
"""
generic = os.environ.get("OPENAI_COMPATIBLE_BASE_URL") is not None
specific = os.environ.get(f"OPENAI_COMPATIBLE_BASE_URL_{mode}") is not None
return generic or specific
class DiscoveredModelResponse(BaseModel):
"""Response model for a discovered model."""
name: str
provider: str
model_type: str
description: Optional[str] = None
class ProviderSyncResponse(BaseModel):
"""Response model for provider sync operation."""
provider: str
discovered: int
new: int
existing: int
class AllProvidersSyncResponse(BaseModel):
"""Response model for syncing all providers."""
results: Dict[str, ProviderSyncResponse]
total_discovered: int
total_new: int
class ProviderModelCountResponse(BaseModel):
"""Response model for provider model counts."""
provider: str
counts: Dict[str, int]
total: int
class AutoAssignResult(BaseModel):
"""Response model for auto-assign operation."""
assigned: Dict[str, str] # slot_name -> model_id
skipped: List[str] # slots already assigned
missing: List[str] # slots with no available models
class ModelTestResponse(BaseModel):
"""Response model for individual model test."""
success: bool
message: str
details: Optional[str] = None
# Provider priority for auto-assignment (higher priority first)
PROVIDER_PRIORITY = [
"openai",
"anthropic",
"google",
"mistral",
"groq",
"deepseek",
"xai",
"openrouter",
"ollama",
"azure",
"openai_compatible",
]
# Model preference patterns (preferred models within each provider)
MODEL_PREFERENCES = {
"openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo"],
"anthropic": ["claude-3-5-sonnet", "claude-3-opus", "claude-3-sonnet"],
"google": ["gemini-2.0", "gemini-1.5-pro", "gemini-pro"],
"mistral": ["mistral-large", "mixtral"],
"groq": ["llama-3.3", "llama-3.1", "mixtral"],
}
async def _check_provider_has_credential(provider: str) -> bool:
"""Check if a provider has any credentials configured in the database."""
try:
credentials = await Credential.get_by_provider(provider)
return len(credentials) > 0
except Exception:
pass
return False
def _check_azure_support(mode: str) -> bool:
@ -59,6 +145,23 @@ def _check_azure_support(mode: str) -> bool:
return generic or specific
def _check_openai_compatible_support(mode: str) -> bool:
"""
Check if OpenAI-compatible provider is available for a specific mode.
Args:
mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
Returns:
bool: True if either generic or mode-specific env var is set
"""
generic = os.environ.get("OPENAI_COMPATIBLE_BASE_URL") is not None
specific = os.environ.get(f"OPENAI_COMPATIBLE_BASE_URL_{mode}") is not None
generic_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY") is not None
specific_key = os.environ.get(f"OPENAI_COMPATIBLE_API_KEY_{mode}") is not None
return generic or specific or generic_key or specific_key
@router.get("/models", response_model=List[ModelResponse])
async def get_models(
type: Optional[str] = Query(None, description="Filter by model type"),
@ -76,6 +179,7 @@ async def get_models(
name=model.name,
provider=model.provider,
type=model.type,
credential=model.credential,
created=str(model.created),
updated=str(model.updated),
)
@ -119,6 +223,7 @@ async def create_model(model_data: ModelCreate):
name=model_data.name,
provider=model_data.provider,
type=model_data.type,
credential=model_data.credential,
)
await new_model.save()
@ -127,6 +232,7 @@ async def create_model(model_data: ModelCreate):
name=new_model.name,
provider=new_model.provider,
type=new_model.type,
credential=new_model.credential,
created=str(new_model.created),
updated=str(new_model.updated),
)
@ -157,6 +263,29 @@ async def delete_model(model_id: str):
raise HTTPException(status_code=500, detail=f"Error deleting model: {str(e)}")
@router.post("/models/{model_id}/test", response_model=ModelTestResponse)
async def test_model(model_id: str):
"""Test if a specific model is correctly configured and functional."""
try:
model = await Model.get(model_id)
if not model:
raise HTTPException(status_code=404, detail="Model not found")
except HTTPException:
raise
except Exception:
raise HTTPException(status_code=404, detail="Model not found")
try:
success, message = await test_individual_model(model)
return ModelTestResponse(success=success, message=message)
except Exception as e:
logger.error(f"Error testing model {model_id}: {traceback.format_exc()}")
return ModelTestResponse(
success=False,
message=str(e)[:200],
)
@router.get("/models/defaults", response_model=DefaultModelsResponse)
async def get_default_models():
"""Get default model assignments."""
@ -231,43 +360,62 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
@router.get("/models/providers", response_model=ProviderAvailabilityResponse)
async def get_provider_availability():
"""Get provider availability based on environment variables."""
"""Get provider availability based on database config and environment variables."""
try:
# Check which providers have API keys configured
provider_status = {
"ollama": os.environ.get("OLLAMA_API_BASE") is not None,
"openai": os.environ.get("OPENAI_API_KEY") is not None,
"groq": os.environ.get("GROQ_API_KEY") is not None,
"xai": os.environ.get("XAI_API_KEY") is not None,
"vertex": (
os.environ.get("VERTEX_PROJECT") is not None
and os.environ.get("VERTEX_LOCATION") is not None
and os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") is not None
),
"google": (
os.environ.get("GOOGLE_API_KEY") is not None
or os.environ.get("GEMINI_API_KEY") is not None
),
"openrouter": os.environ.get("OPENROUTER_API_KEY") is not None,
"anthropic": os.environ.get("ANTHROPIC_API_KEY") is not None,
"elevenlabs": os.environ.get("ELEVENLABS_API_KEY") is not None,
"voyage": os.environ.get("VOYAGE_API_KEY") is not None,
"azure": (
_check_azure_support("LLM")
or _check_azure_support("EMBEDDING")
or _check_azure_support("STT")
or _check_azure_support("TTS")
),
"mistral": os.environ.get("MISTRAL_API_KEY") is not None,
"deepseek": os.environ.get("DEEPSEEK_API_KEY") is not None,
"openai-compatible": (
_check_openai_compatible_support("LLM")
or _check_openai_compatible_support("EMBEDDING")
or _check_openai_compatible_support("STT")
or _check_openai_compatible_support("TTS")
),
# Check which providers have credentials in the database or env vars
# For each provider, check DB credentials first, then env vars as fallback
# Simple env var mapping for backward compatibility
env_var_map = {
"openai": "OPENAI_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"google": "GOOGLE_API_KEY",
"groq": "GROQ_API_KEY",
"mistral": "MISTRAL_API_KEY",
"deepseek": "DEEPSEEK_API_KEY",
"xai": "XAI_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"voyage": "VOYAGE_API_KEY",
"elevenlabs": "ELEVENLABS_API_KEY",
"ollama": "OLLAMA_API_BASE",
}
provider_status = {}
# Check simple providers: credential in DB or env var
for provider, env_var in env_var_map.items():
has_cred = await _check_provider_has_credential(provider)
has_env = os.environ.get(env_var) is not None
provider_status[provider] = has_cred or has_env
# Google also supports GEMINI_API_KEY
if not provider_status.get("google"):
provider_status["google"] = os.environ.get("GEMINI_API_KEY") is not None
# Vertex: DB credential or env vars
provider_status["vertex"] = (
await _check_provider_has_credential("vertex")
or os.environ.get("VERTEX_PROJECT") is not None
)
# Azure: DB credential or env vars
provider_status["azure"] = (
await _check_provider_has_credential("azure")
or _check_azure_support("LLM")
or _check_azure_support("EMBEDDING")
or _check_azure_support("STT")
or _check_azure_support("TTS")
)
# OpenAI-compatible: DB credential or env vars
provider_status["openai-compatible"] = (
await _check_provider_has_credential("openai_compatible")
or _check_openai_compatible_support("LLM")
or _check_openai_compatible_support("EMBEDDING")
or _check_openai_compatible_support("STT")
or _check_openai_compatible_support("TTS")
)
available_providers = [k for k, v in provider_status.items() if v]
unavailable_providers = [k for k, v in provider_status.items() if not v]
@ -289,21 +437,23 @@ async def get_provider_availability():
# Special handling for openai-compatible to check mode-specific availability
if provider == "openai-compatible":
has_db_cred = await _check_provider_has_credential("openai_compatible")
for model_type, mode in mode_mapping.items():
if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
if _check_openai_compatible_support(mode):
if has_db_cred or _check_openai_compatible_support(mode):
supported_types[provider].append(model_type)
# Special handling for azure to check mode-specific availability
elif provider == "azure":
has_db_cred = await _check_provider_has_credential("azure")
for model_type, mode in mode_mapping.items():
if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
if _check_azure_support(mode):
if has_db_cred or _check_azure_support(mode):
supported_types[provider].append(model_type)
else:
# Standard provider detection
@ -321,3 +471,300 @@ async def get_provider_availability():
raise HTTPException(
status_code=500, detail=f"Error checking provider availability: {str(e)}"
)
# =============================================================================
# Model Discovery Endpoints
# =============================================================================
@router.get(
"/models/discover/{provider}", response_model=List[DiscoveredModelResponse]
)
async def discover_models(provider: str):
"""
Discover available models from a provider without registering them.
This endpoint queries the provider's API to list available models
but does not save them to the database. Use the sync endpoint
to both discover and register models.
"""
try:
# Provision DB-stored credentials into env vars before discovery
await provision_provider_keys(provider)
discovered = await discover_provider_models(provider)
return [
DiscoveredModelResponse(
name=m.name,
provider=m.provider,
model_type=m.model_type,
description=m.description,
)
for m in discovered
]
except Exception as e:
logger.error(f"Error discovering models for {provider}: {str(e)}")
raise HTTPException(
status_code=500, detail="Error discovering models. Check server logs for details."
)
@router.post("/models/sync/{provider}", response_model=ProviderSyncResponse)
async def sync_models(provider: str):
"""
Sync models for a specific provider.
Discovers available models from the provider's API and registers
any new models in the database. Existing models are skipped.
Returns counts of discovered, new, and existing models.
"""
try:
# Provision DB-stored credentials into env vars before discovery
await provision_provider_keys(provider)
discovered, new, existing = await sync_provider_models(
provider, auto_register=True
)
return ProviderSyncResponse(
provider=provider,
discovered=discovered,
new=new,
existing=existing,
)
except Exception as e:
logger.error(f"Error syncing models for {provider}: {str(e)}")
raise HTTPException(status_code=500, detail="Error syncing models. Check server logs for details.")
@router.post("/models/sync", response_model=AllProvidersSyncResponse)
async def sync_all_models():
"""
Sync models for all configured providers.
Discovers and registers models from all providers that have
valid API keys configured. This is useful for initial setup
or periodic refresh of available models.
"""
try:
results = await sync_all_providers()
response_results = {}
total_discovered = 0
total_new = 0
for provider, (discovered, new, existing) in results.items():
response_results[provider] = ProviderSyncResponse(
provider=provider,
discovered=discovered,
new=new,
existing=existing,
)
total_discovered += discovered
total_new += new
return AllProvidersSyncResponse(
results=response_results,
total_discovered=total_discovered,
total_new=total_new,
)
except Exception as e:
logger.error(f"Error syncing all models: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error syncing all models: {str(e)}"
)
@router.get("/models/count/{provider}", response_model=ProviderModelCountResponse)
async def get_model_count(provider: str):
"""
Get count of registered models for a provider, grouped by type.
Returns counts for each model type (language, embedding,
speech_to_text, text_to_speech) as well as total count.
"""
try:
counts = await get_provider_model_count(provider)
total = sum(counts.values())
return ProviderModelCountResponse(
provider=provider,
counts=counts,
total=total,
)
except Exception as e:
logger.error(f"Error getting model count for {provider}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error getting model count: {str(e)}"
)
@router.get("/models/by-provider/{provider}", response_model=List[ModelResponse])
async def get_models_by_provider(provider: str):
"""
Get all registered models for a specific provider.
Returns models from the database that belong to the specified provider.
"""
try:
from open_notebook.database.repository import repo_query
models = await repo_query(
"SELECT * FROM model WHERE provider = $provider ORDER BY type, name",
{"provider": provider},
)
return [
ModelResponse(
id=model.get("id", ""),
name=model.get("name", ""),
provider=model.get("provider", ""),
type=model.get("type", ""),
credential=model.get("credential"),
created=str(model.get("created", "")),
updated=str(model.get("updated", "")),
)
for model in models
]
except Exception as e:
logger.error(f"Error fetching models for {provider}: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error fetching models: {str(e)}"
)
def _get_preferred_model(
models: List[Dict], provider_priority: List[str], model_preferences: Dict
) -> Optional[Dict]:
"""
Select the best model from a list based on provider priority and model preferences.
Args:
models: List of model dictionaries with 'provider', 'name', 'id' keys
provider_priority: List of providers in preference order
model_preferences: Dict mapping provider to list of preferred model name patterns
Returns:
The best model dict, or None if no models available
"""
if not models:
return None
# Group models by provider
by_provider: Dict[str, List[Dict]] = {}
for model in models:
provider = model.get("provider", "")
if provider not in by_provider:
by_provider[provider] = []
by_provider[provider].append(model)
# Find first provider with models (in priority order)
for provider in provider_priority:
if provider in by_provider:
provider_models = by_provider[provider]
# Check for preferred models within this provider
if provider in model_preferences:
for preference in model_preferences[provider]:
for model in provider_models:
if preference.lower() in model.get("name", "").lower():
return model
# Fall back to first model from this provider
return provider_models[0]
# Fall back to first model from any provider
return models[0] if models else None
@router.post("/models/auto-assign", response_model=AutoAssignResult)
async def auto_assign_defaults():
"""
Auto-assign default models based on available models.
This endpoint intelligently assigns the first available model of each
required type to the corresponding default slot. It uses provider
priority (preferring premium providers like OpenAI, Anthropic) and
model preferences within each provider.
Returns:
- assigned: Dict of slot names to assigned model IDs
- skipped: List of slots that already have models assigned
- missing: List of slots with no available models
"""
try:
from open_notebook.database.repository import repo_query
# Get current defaults
defaults = await DefaultModels.get_instance()
# Get all models grouped by type
all_models = await repo_query(
"SELECT * FROM model ORDER BY provider, name",
{},
)
# Group models by type
models_by_type: Dict[str, List[Dict]] = {
"language": [],
"embedding": [],
"text_to_speech": [],
"speech_to_text": [],
}
for model in all_models:
model_type = model.get("type", "")
if model_type in models_by_type:
models_by_type[model_type].append(model)
# Define slot configuration: (slot_name, model_type, current_value)
slot_configs = [
("default_chat_model", "language", defaults.default_chat_model), # type: ignore[attr-defined]
("default_transformation_model", "language", defaults.default_transformation_model), # type: ignore[attr-defined]
("default_tools_model", "language", defaults.default_tools_model), # type: ignore[attr-defined]
("large_context_model", "language", defaults.large_context_model), # type: ignore[attr-defined]
("default_embedding_model", "embedding", defaults.default_embedding_model), # type: ignore[attr-defined]
("default_text_to_speech_model", "text_to_speech", defaults.default_text_to_speech_model), # type: ignore[attr-defined]
("default_speech_to_text_model", "speech_to_text", defaults.default_speech_to_text_model), # type: ignore[attr-defined]
]
assigned: Dict[str, str] = {}
skipped: List[str] = []
missing: List[str] = []
for slot_name, model_type, current_value in slot_configs:
if current_value:
# Slot already has a value
skipped.append(slot_name)
continue
available_models = models_by_type.get(model_type, [])
if not available_models:
# No models of this type available
missing.append(slot_name)
continue
# Select best model for this slot
best_model = _get_preferred_model(
available_models, PROVIDER_PRIORITY, MODEL_PREFERENCES
)
if best_model:
model_id = best_model.get("id", "")
assigned[slot_name] = model_id
# Update the defaults object
setattr(defaults, slot_name, model_id)
# Save updated defaults if any assignments were made
if assigned:
await defaults.update()
return AutoAssignResult(
assigned=assigned,
skipped=skipped,
missing=missing,
)
except Exception as e:
logger.error(f"Error auto-assigning defaults: {str(e)}")
raise HTTPException(
status_code=500, detail=f"Error auto-assigning defaults: {str(e)}"
)

View file

@ -78,7 +78,7 @@ async def create_note(note_data: NoteCreate):
content=note_data.content,
note_type=note_type,
)
await new_note.save()
command_id = await new_note.save()
# Add to notebook if specified
if note_data.notebook_id:
@ -96,6 +96,7 @@ async def create_note(note_data: NoteCreate):
note_type=new_note.note_type,
created=str(new_note.created),
updated=str(new_note.updated),
command_id=str(command_id) if command_id else None,
)
except HTTPException:
raise
@ -150,7 +151,7 @@ async def update_note(note_id: str, note_update: NoteUpdate):
status_code=400, detail="note_type must be 'human' or 'ai'"
)
await note.save()
command_id = await note.save()
return NoteResponse(
id=note.id or "",
@ -159,6 +160,7 @@ async def update_note(note_id: str, note_update: NoteUpdate):
note_type=note.note_type,
created=str(note.created),
updated=str(note.updated),
command_id=str(command_id) if command_id else None,
)
except HTTPException:
raise

View file

@ -102,8 +102,11 @@ async def stream_ask_response(
yield f"data: {json.dumps(completion_data)}\n\n"
except Exception as e:
from open_notebook.utils.error_classifier import classify_error
_, user_message = classify_error(e)
logger.error(f"Error in ask streaming: {str(e)}")
error_data = {"type": "error", "message": str(e)}
error_data = {"type": "error", "message": user_message}
yield f"data: {json.dumps(error_data)}\n\n"

View file

@ -470,8 +470,11 @@ async def stream_source_chat_response(
yield f"data: {json.dumps(completion_event)}\n\n"
except Exception as e:
from open_notebook.utils.error_classifier import classify_error
_, user_message = classify_error(e)
logger.error(f"Error in source chat streaming: {str(e)}")
error_event = {"type": "error", "message": str(e)}
error_event = {"type": "error", "message": user_message}
yield f"data: {json.dumps(error_event)}\n\n"

View file

@ -14,7 +14,7 @@ from api.models import (
)
from open_notebook.ai.models import Model
from open_notebook.domain.transformation import DefaultPrompts, Transformation
from open_notebook.exceptions import InvalidInputError
from open_notebook.exceptions import InvalidInputError, OpenNotebookError
from open_notebook.graphs.transformation import graph as transformation_graph
router = APIRouter()
@ -109,6 +109,8 @@ async def execute_transformation(execute_request: TransformationExecuteRequest):
except HTTPException:
raise
except OpenNotebookError:
raise # Let global exception handlers return proper status codes
except Exception as e:
logger.error(f"Error executing transformation: {str(e)}")
raise HTTPException(

View file

@ -7,6 +7,7 @@ from surreal_commands import CommandInput, CommandOutput, command, submit_comman
from open_notebook.ai.models import model_manager
from open_notebook.database.repository import ensure_record_id, repo_insert, repo_query
from open_notebook.exceptions import ConfigurationError
from open_notebook.domain.notebook import Note, Source, SourceInsight
from open_notebook.utils.chunking import ContentType, chunk_text, detect_content_type
from open_notebook.utils.embedding import generate_embedding, generate_embeddings
@ -125,7 +126,7 @@ class EmbedSourceOutput(CommandOutput):
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"stop_on": [ValueError], # Don't retry validation errors
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
"retry_log_level": "debug",
},
)
@ -217,7 +218,7 @@ async def embed_note_command(input_data: EmbedNoteInput) -> EmbedNoteOutput:
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"stop_on": [ValueError], # Don't retry validation errors
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
"retry_log_level": "debug",
},
)
@ -311,7 +312,7 @@ async def embed_insight_command(input_data: EmbedInsightInput) -> EmbedInsightOu
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"stop_on": [ValueError], # Don't retry validation errors
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
"retry_log_level": "debug",
},
)
@ -447,7 +448,7 @@ async def embed_source_command(input_data: EmbedSourceInput) -> EmbedSourceOutpu
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"stop_on": [ValueError], # Don't retry validation errors
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
"retry_log_level": "debug",
},
)

View file

@ -8,6 +8,7 @@ from surreal_commands import CommandInput, CommandOutput, command
from open_notebook.database.repository import ensure_record_id
from open_notebook.domain.notebook import Source
from open_notebook.domain.transformation import Transformation
from open_notebook.exceptions import ConfigurationError
try:
from open_notebook.graphs.source import source_graph
@ -53,7 +54,7 @@ class SourceProcessingOutput(CommandOutput):
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 120, # Allow queue to drain
"stop_on": [ValueError], # Don't retry validation errors
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
"retry_log_level": "debug", # Avoid log noise during transaction conflicts
},
)
@ -184,7 +185,7 @@ class RunTransformationOutput(CommandOutput):
"wait_strategy": "exponential_jitter",
"wait_min": 1,
"wait_max": 60,
"stop_on": [ValueError], # Don't retry validation errors
"stop_on": [ValueError, ConfigurationError], # Don't retry validation/config errors
"retry_log_level": "debug",
},
)

View file

@ -1,28 +0,0 @@
services:
surrealdb:
image: surrealdb/surrealdb:v2
volumes:
- ./surreal_data:/mydata
environment:
- SURREAL_EXPERIMENTAL_GRAPHQL=true
ports:
- "8000:8000"
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
pull_policy: always
user: root
restart: always
open_notebook:
image: lfnovo/open_notebook:v1-latest
# build:
# context: .
# dockerfile: Dockerfile
ports:
- "8502:8502"
- "5055:5055"
env_file:
- ./docker.env
depends_on:
- surrealdb
volumes:
- ./notebook_data:/app/data
restart: always

36
docker-compose.yml Normal file
View file

@ -0,0 +1,36 @@
services:
surrealdb:
image: surrealdb/surrealdb:v2
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
user: root # Required for bind mounts on Linux
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
environment:
- SURREAL_EXPERIMENTAL_GRAPHQL=true
restart: always
pull_policy: always
open_notebook:
image: lfnovo/open_notebook:v1-latest
ports:
- "8502:8502" # Web UI
- "5055:5055" # REST API
environment:
# REQUIRED: Change this to your own secret string
# This encrypts your API keys in the database
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database connection (default values - no need to change)
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=open_notebook
volumes:
- ./notebook_data:/app/data
depends_on:
- surrealdb
restart: always
pull_policy: always

View file

@ -39,22 +39,8 @@ services:
- "8502:8502" # Web UI
- "5055:5055" # API
environment:
# Choose ONE provider (uncomment your choice):
# OpenRouter - 100+ models with one API key
- OPENROUTER_API_KEY=sk-or-...
# Anthropic (Claude) - Excellent reasoning
# - ANTHROPIC_API_KEY=sk-ant-...
# Google (Gemini) - Large context, cost-effective
# - GOOGLE_API_KEY=...
# Groq - Ultra-fast inference, free tier available
# - GROQ_API_KEY=gsk_...
# Mistral - European provider, good quality
# - MISTRAL_API_KEY=...
# Encryption key for credential storage (required)
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database (required)
- SURREAL_URL=ws://surrealdb:8000/rpc
@ -71,8 +57,7 @@ services:
```
**Edit the file:**
- Uncomment ONE provider and add your API key
- Comment out or remove the others
- Replace `change-me-to-a-secret-string` with your own secret (any string works)
---
@ -99,7 +84,23 @@ You should see the Open Notebook interface!
---
## Step 4: Configure Your Model (1 min)
## Step 4: Configure Your AI Provider (1 min)
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select your provider (e.g., Anthropic, Google, Groq, OpenRouter)
4. Give it a name, paste your API key
5. Click **Save**
6. Click **Test Connection** — should show success
7. Click **Discover Models** → **Register Models**
Your provider's models are now available!
> **Multiple providers**: You can add credentials for as many providers as you want. Just repeat this step for each provider.
---
## Step 5: Configure Your Model (1 min)
1. Go to **Settings** (gear icon)
2. Navigate to **Models**
@ -117,7 +118,7 @@ You should see the Open Notebook interface!
---
## Step 5: Create Your First Notebook (1 min)
## Step 6: Create Your First Notebook (1 min)
1. Click **New Notebook**
2. Name: "My Research"
@ -125,7 +126,7 @@ You should see the Open Notebook interface!
---
## Step 6: Add Content & Chat (2 min)
## Step 7: Add Content & Chat (2 min)
1. Click **Add Source**
2. Choose **Web Link**
@ -139,7 +140,8 @@ You should see the Open Notebook interface!
- [ ] Docker is running
- [ ] You can access `http://localhost:8502`
- [ ] Models are configured for your provider
- [ ] Provider credential is configured and tested
- [ ] Models are registered
- [ ] You created a notebook
- [ ] Chat works
@ -160,29 +162,14 @@ You should see the Open Notebook interface!
---
## Using Multiple Providers
You can enable multiple providers simultaneously:
```yaml
environment:
- OPENROUTER_API_KEY=sk-or-...
- ANTHROPIC_API_KEY=sk-ant-...
- GOOGLE_API_KEY=...
- GROQ_API_KEY=gsk_...
```
Then switch between them in **Settings** > **Models** as needed.
---
## Troubleshooting
### "Model not found" Error
1. Verify your API key is correct (no extra spaces)
2. Check you have credits/access for the model
3. Restart: `docker compose restart api`
1. Go to **Settings** → **API Keys**
2. Click **Test Connection** on your credential
3. If valid, click **Discover Models** → **Register Models**
4. Check you have credits/access for the model
### "Cannot connect to server"

View file

@ -14,10 +14,10 @@ Get Open Notebook running with **100% local AI** using Ollama. No cloud API keys
## Step 1: Choose Your Setup (1 min)
### 🏠 Local Machine (Same Computer)
### Local Machine (Same Computer)
Everything runs on your machine. Recommended for testing/learning.
### 🌐 Remote Server (Raspberry Pi, NAS, Cloud VM)
### Remote Server (Raspberry Pi, NAS, Cloud VM)
Run on a different computer, access from another. Needs network configuration.
---
@ -44,8 +44,8 @@ services:
- "8502:8502" # Web UI (React frontend)
- "5055:5055" # API (required!)
environment:
# NO API KEYS NEEDED - Using Ollama (free, local)
- OLLAMA_API_BASE=http://ollama:11434
# Encryption key for credential storage (required)
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database (required)
- SURREAL_URL=ws://surrealdb:8000/rpc
@ -73,7 +73,8 @@ services:
```
**That's it!** No API keys, no secrets, completely private.
**Edit the file:**
- Replace `change-me-to-a-secret-string` with your own secret (any string works)
---
@ -95,13 +96,13 @@ Ollama needs at least one language model. Pick one:
```bash
# Fastest & smallest (recommended for testing)
docker exec open_notebook-ollama-1 ollama pull mistral
docker exec open-notebook-local-ollama-1 ollama pull mistral
# OR: Better quality but slower
docker exec open_notebook-ollama-1 ollama pull neural-chat
docker exec open-notebook-local-ollama-1 ollama pull neural-chat
# OR: Even better quality, more VRAM needed
docker exec open_notebook-ollama-1 ollama pull llama2
docker exec open-notebook-local-ollama-1 ollama pull llama2
```
This downloads the model (will take 1-5 minutes depending on your internet).
@ -119,9 +120,22 @@ You should see the Open Notebook interface.
---
## Step 6: Configure Local Model (1 min)
## Step 6: Configure Ollama Provider (1 min)
1. Click **Settings** (top right) → **Models**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **Ollama**
4. Give it a name (e.g., "Local Ollama")
5. Enter the base URL: `http://ollama:11434`
6. Click **Save**
7. Click **Test Connection** — should show success
8. Click **Discover Models** → **Register Models**
---
## Step 7: Configure Local Model (1 min)
1. Go to **Settings** → **Models**
2. Set:
- **Language Model**: `ollama/mistral` (or whichever model you downloaded)
- **Embedding Model**: `ollama/nomic-embed-text` (auto-downloads if missing)
@ -129,7 +143,7 @@ You should see the Open Notebook interface.
---
## Step 7: Create Your First Notebook (1 min)
## Step 8: Create Your First Notebook (1 min)
1. Click **New Notebook**
2. Name: "My Private Research"
@ -137,7 +151,7 @@ You should see the Open Notebook interface.
---
## Step 8: Add Local Content (1 min)
## Step 9: Add Local Content (1 min)
1. Click **Add Source**
2. Choose **Text**
@ -146,7 +160,7 @@ You should see the Open Notebook interface.
---
## Step 9: Chat With Your Content (1 min)
## Step 10: Chat With Your Content (1 min)
1. Go to **Chat**
2. Type: "What did you learn from this?"
@ -159,20 +173,21 @@ You should see the Open Notebook interface.
- [ ] Docker is running
- [ ] You can access `http://localhost:8502`
- [ ] Models are configured
- [ ] Ollama credential is configured and tested
- [ ] Models are registered
- [ ] You created a notebook
- [ ] Chat works with local model
**All checked?** 🎉 You have a completely **private, offline** research assistant!
**All checked?** You have a completely **private, offline** research assistant!
---
## Advantages of Local Setup
**No API costs** - Free forever
**No internet required** - True offline capability
**Privacy first** - Your data never leaves your machine
**No subscriptions** - No monthly bills
- **No API costs** - Free forever
- **No internet required** - True offline capability
- **Privacy first** - Your data never leaves your machine
- **No subscriptions** - No monthly bills
**Trade-off:** Slower than cloud models (depends on your CPU/GPU)
@ -209,7 +224,7 @@ docker compose up -d
Check if GPU is available:
```bash
# Show available GPUs
docker exec open_notebook-ollama-1 ollama ps
docker exec open-notebook-local-ollama-1 ollama ps
# Enable GPU in docker-compose.yml:
# - OLLAMA_NUM_GPU=1
@ -221,10 +236,10 @@ Then restart: `docker compose restart ollama`
```bash
# List available models
docker exec open_notebook-ollama-1 ollama list
docker exec open-notebook-local-ollama-1 ollama list
# Pull additional model
docker exec open_notebook-ollama-1 ollama pull neural-chat
docker exec open-notebook-local-ollama-1 ollama pull neural-chat
```
---
@ -248,13 +263,12 @@ docker exec open_notebook-ollama-1 ollama pull neural-chat
1. Download LM Studio: https://lmstudio.ai
2. Open the app, download a model from the library
3. Go to "Local Server" tab, start server (port 1234)
4. Update your docker-compose.yml:
```yaml
environment:
- OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:1234/v1
- OPENAI_COMPATIBLE_API_KEY=not-needed
```
5. Configure in Settings → Models → Select your LM Studio model
4. In Open Notebook, go to **Settings** → **API Keys**
5. Click **Add Credential** → Select **OpenAI-Compatible**
6. Enter base URL: `http://host.docker.internal:1234/v1`
7. Enter API key: `lm-studio` (placeholder)
8. Click **Save**, then **Test Connection**
9. Configure in Settings → Models → Select your LM Studio model
**Note**: LM Studio runs outside Docker, use `host.docker.internal` to connect.
@ -264,10 +278,10 @@ docker exec open_notebook-ollama-1 ollama pull neural-chat
- **Switch models**: Change in Settings → Models anytime
- **Add more models**:
- Ollama: Run `ollama pull <model>`
- Ollama: Run `ollama pull <model>`, then re-discover models from the credential
- LM Studio: Download from the app library
- **Deploy to server**: Same docker-compose.yml works anywhere
- **Use cloud hybrid**: Keep some local models, add OpenAI/Anthropic for complex tasks
- **Use cloud hybrid**: Keep some local models, add cloud provider credentials for complex tasks
---

View file

@ -36,8 +36,8 @@ services:
- "8502:8502" # Web UI
- "5055:5055" # API
environment:
# Your OpenAI key
- OPENAI_API_KEY=sk-...
# Encryption key for credential storage (required)
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database (required)
- SURREAL_URL=ws://surrealdb:8000/rpc
@ -54,7 +54,7 @@ services:
```
**Edit the file:**
- Replace `sk-...` with your actual OpenAI API key
- Replace `change-me-to-a-secret-string` with your own secret (any string works)
---
@ -81,7 +81,22 @@ You should see the Open Notebook interface!
---
## Step 4: Create Your First Notebook (1 min)
## Step 4: Configure Your OpenAI Provider (1 min)
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **OpenAI**
4. Give it a name (e.g., "My OpenAI Key")
5. Paste your OpenAI API key
6. Click **Save**
7. Click **Test Connection** — should show success
8. Click **Discover Models** → **Register Models**
Your OpenAI models are now available!
---
## Step 5: Create Your First Notebook (1 min)
1. Click **New Notebook**
2. Name: "My Research"
@ -89,7 +104,7 @@ You should see the Open Notebook interface!
---
## Step 5: Add a Source (1 min)
## Step 6: Add a Source (1 min)
1. Click **Add Source**
2. Choose **Web Link**
@ -99,7 +114,7 @@ You should see the Open Notebook interface!
---
## Step 6: Chat With Your Content (1 min)
## Step 7: Chat With Your Content (1 min)
1. Go to **Chat**
2. Type: "What is artificial intelligence?"
@ -112,11 +127,12 @@ You should see the Open Notebook interface!
- [ ] Docker is running
- [ ] You can access `http://localhost:8502`
- [ ] OpenAI credential is configured and tested
- [ ] You created a notebook
- [ ] You added a source
- [ ] Chat works
**All checked?** 🎉 You have a fully working AI research assistant!
**All checked?** You have a fully working AI research assistant!
---
@ -142,9 +158,10 @@ Then access at `http://localhost:8503`
### "API key not working"
1. Double-check your API key (no extra spaces)
2. Verify you added credits at https://platform.openai.com
3. Restart: `docker compose restart api`
1. Go to **Settings** → **API Keys**
2. Click **Test Connection** on your OpenAI credential
3. If it fails, verify your key at https://platform.openai.com
4. Delete the credential and create a new one with the correct key
### "Cannot connect to server"

View file

@ -10,55 +10,51 @@ Multi-container setup with separate services. **Best for most users.**
- **5-10 minutes** of your time
- **API key** for at least one AI provider (OpenAI recommended for beginners)
## Step 1: Get an API Key (2 min)
## Step 1: Get docker-compose.yml (1 min)
Choose at least one AI provider. **OpenAI recommended if you're unsure:**
```
OpenAI: https://platform.openai.com/api-keys
Anthropic: https://console.anthropic.com/
Google: https://aistudio.google.com/
Groq: https://console.groq.com/
**Option A: Download from repository**
```bash
curl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/docker-compose.yml
```
Add at least $5 in credits to your account.
**Option B: Use the official file from the repo**
(Skip this if using Ollama for free local models)
The official `docker-compose.yml` is in the root of our repository: [View on GitHub](https://github.com/lfnovo/open-notebook/blob/main/docker-compose.yml)
---
Copy that file to your project folder.
## Step 2: Create Configuration (2 min)
**Option C: Create manually**
Create a folder `open-notebook` and add this file:
Create a file called `docker-compose.yml` with this content:
**docker-compose.yml**:
```yaml
services:
surrealdb:
image: surrealdb/surrealdb:v2
command: start --user root --pass password --bind 0.0.0.0:8000 rocksdb:/mydata/mydatabase.db
user: root # Required for bind mounts on Linux (SurrealDB runs as non-root by default)
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
user: root # Required for bind mounts on Linux
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
environment:
- SURREAL_EXPERIMENTAL_GRAPHQL=true
restart: always
pull_policy: always
open_notebook:
image: lfnovo/open_notebook:v1-latest
pull_policy: always
ports:
- "8502:8502" # Web UI
- "5055:5055" # API
- "5055:5055" # REST API
environment:
# AI Provider (choose ONE)
- OPENAI_API_KEY=sk-... # Your OpenAI key
# - ANTHROPIC_API_KEY=sk-ant-... # Or Anthropic
# - GOOGLE_API_KEY=... # Or Google
# REQUIRED: Change this to your own secret string
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database
# Database connection (default values - no need to change)
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=password
- SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=open_notebook
volumes:
@ -66,17 +62,15 @@ services:
depends_on:
- surrealdb
restart: always
pull_policy: always
```
**Edit the file:**
- Replace `sk-...` with your actual OpenAI API key
- (Or use Anthropic, Google, Groq keys instead)
- If you have multiple keys, uncomment the ones you want
- Replace `change-me-to-a-secret-string` with your own secret (any string works, e.g., `my-super-secret-key-123`)
---
## Step 3: Start Services (2 min)
## Step 2: Start Services (2 min)
Open terminal in the `open-notebook` folder:
@ -97,7 +91,7 @@ docker compose ps
---
## Step 4: Verify Installation (1 min)
## Step 3: Verify Installation (1 min)
**API Health:**
```bash
@ -115,6 +109,26 @@ You should see the Open Notebook interface!
---
## Step 4: Configure AI Provider (2 min)
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select your provider (e.g., OpenAI, Anthropic, Google)
4. Give it a name, paste your API key
5. Click **Save**
6. Click **Test Connection** — should show success
7. Click **Discover Models** → **Register Models**
Your models are now available!
> **Need an API key?** Get one from your chosen provider:
> - **OpenAI**: https://platform.openai.com/api-keys
> - **Anthropic**: https://console.anthropic.com/
> - **Google**: https://aistudio.google.com/
> - **Groq**: https://console.groq.com/
---
## Step 5: First Notebook (2 min)
1. Click **New Notebook**
@ -122,35 +136,27 @@ You should see the Open Notebook interface!
3. Description: "Getting started"
4. Click **Create**
Done! You now have a fully working Open Notebook instance. 🎉
Done! You now have a fully working Open Notebook instance.
---
## Configuration
### Using Different AI Providers
### Adding Ollama (Free Local Models)
Change `environment` section in `docker-compose.yml`:
Instead of manually editing, use our ready-made example:
```yaml
# For Anthropic (Claude)
- ANTHROPIC_API_KEY=sk-ant-...
```bash
# Download the Ollama example
curl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/examples/docker-compose-ollama.yml
# For Google Gemini
- GOOGLE_API_KEY=...
# For Groq (fast, free tier available)
- GROQ_API_KEY=...
# For local Ollama docker container (free, offline) --> Virtual machine
- OLLAMA_API_BASE=http://ollama:11434
# For localhost Ollama (free, offline) --> Real machine
# - OLLAMA_API_BASE=http://host.docker.internal:11434
# Or copy from repo
cp examples/docker-compose-ollama.yml docker-compose.yml
```
### Adding Ollama container (Free Local Models)
See [examples/docker-compose-ollama.yml](../../examples/docker-compose-ollama.yml) for the complete setup.
Add to `docker-compose.yml`:
**Manual setup:** Add this to your existing `docker-compose.yml`:
```yaml
ollama:
@ -162,35 +168,35 @@ Add to `docker-compose.yml`:
restart: always
volumes:
surreal_data:
ollama_models:
```
Then update API service:
```yaml
environment:
- OLLAMA_API_BASE=http://ollama:11434
```
Restart and pull a model:
Then restart and pull a model:
```bash
docker compose restart
docker exec open_notebook-ollama-1 ollama pull mistral
docker exec open-notebook-local-ollama-1 ollama pull mistral
```
Configure Ollama in the Settings UI:
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **Ollama**
3. Enter base URL: `http://ollama:11434`
4. Click **Save**, then **Test Connection**
5. Click **Discover Models** → **Register Models**
---
## Environment Variables Reference
| Variable | Purpose | Example |
|----------|---------|---------|
| `OPENAI_API_KEY` | OpenAI API key | `sk-proj-...` |
| `ANTHROPIC_API_KEY` | Anthropic/Claude key | `sk-ant-...` |
| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | Encryption key for credentials | `my-secret-key` |
| `SURREAL_URL` | Database connection | `ws://surrealdb:8000/rpc` |
| `SURREAL_USER` | Database user | `root` |
| `SURREAL_PASSWORD` | Database password | `password` |
| `SURREAL_PASSWORD` | Database password | `root` |
| `API_URL` | API external URL | `http://localhost:5055` |
| `NEXT_PUBLIC_API_URL` | Frontend API URL | `http://localhost:5055` |
See [Environment Reference](../5-CONFIGURATION/environment-reference.md) for complete list.
---
@ -266,12 +272,13 @@ Then access at `http://localhost:8503`
---
### API Key Not Working
### Credential Issues
1. Double-check your API key in the file (no extra spaces)
2. Verify key is valid at provider's website
3. Check you added credits to your account
4. Restart: `docker compose restart api`
1. Go to **Settings** → **API Keys**
2. Click **Test Connection** on the credential
3. If it fails, verify key at provider's website
4. Check you have credits in your account
5. Delete and re-create the credential if needed
---
@ -313,6 +320,18 @@ docker compose up -d
---
## Alternative Setups
Looking for different configurations? Check out our [examples/](../../examples/) folder:
- **[Ollama Setup](../../examples/docker-compose-ollama.yml)** - Run local AI models (free, private)
- **[Single Container](../../examples/docker-compose-single.yml)** - All-in-one container (deprecated, not recommended)
- **[Development](../../examples/docker-compose-dev.yml)** - For contributors and developers
Each example includes detailed comments and usage instructions.
---
## Next Steps
1. **Add Content**: Sources, notebooks, documents
@ -325,8 +344,8 @@ docker compose up -d
## Production Deployment
For production use, see:
- [Security Hardening](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/security.md)
- [Reverse Proxy](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/reverse-proxy.md)
- [Security Hardening](../5-CONFIGURATION/security.md)
- [Reverse Proxy](../5-CONFIGURATION/reverse-proxy.md)
---

View file

@ -62,11 +62,12 @@ make database
```bash
cp .env.example .env
# Edit .env and add your API key:
# OPENAI_API_KEY=sk-...
# (or ANTHROPIC_API_KEY, GROQ_API_KEY, etc.)
# Edit .env and set:
# OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
```
After starting the app, configure AI providers via the **Settings → API Keys** UI in the browser.
### 5. Start API
```bash
@ -88,6 +89,14 @@ cd frontend && npm install && npm run dev
- **API Docs**: http://localhost:5055/docs
- **Database**: http://localhost:8000
### 8. Configure AI Provider
1. Open http://localhost:3000
2. Go to **Settings** → **API Keys**
3. Click **Add Credential** → Select your provider → Paste API key
4. Click **Save**, then **Test Connection**
5. Click **Discover Models** → **Register Models**
---
## Development Workflow

View file

@ -144,9 +144,9 @@ Once you're up and running:
Installing for production use? See additional resources:
- [Security Hardening](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/security.md)
- [Reverse Proxy Setup](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/reverse-proxy.md)
- [Performance Tuning](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/retry-configuration.md)
- [Security Hardening](../5-CONFIGURATION/security.md)
- [Reverse Proxy Setup](../5-CONFIGURATION/reverse-proxy.md)
- [Performance Tuning](../5-CONFIGURATION/advanced.md)
---

View file

@ -6,7 +6,7 @@ All-in-one container setup. **Simpler than Docker Compose, but less flexible.**
> **Alternative Registry:** Images available on both Docker Hub (`lfnovo/open_notebook:v1-latest-single`) and GitHub Container Registry (`ghcr.io/lfnovo/open-notebook:v1-latest-single`).
> ⚠️ **Note**: While this is a simple way to get started, we recommend [Docker Compose](docker-compose.md) for most users. Docker Compose is more flexible and will make it easier if we add more services to the setup in the future. This single-container option is best for platforms that specifically require it (PikaPods, Railway, etc.).
> **Note**: While this is a simple way to get started, we recommend [Docker Compose](docker-compose.md) for most users. Docker Compose is more flexible and will make it easier if we add more services to the setup in the future. This single-container option is best for platforms that specifically require it (PikaPods, Railway, etc.).
## Prerequisites
@ -28,7 +28,7 @@ services:
- "8502:8502" # Web UI (React frontend)
- "5055:5055" # API
environment:
- OPENAI_API_KEY=sk-...
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
- SURREAL_URL=ws://localhost:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=password
@ -46,31 +46,39 @@ docker compose up -d
Access: `http://localhost:8502`
Then configure your AI provider:
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select your provider → Paste API key
3. Click **Save**, then **Test Connection**
4. Click **Discover Models** → **Register Models**
### For Cloud Platforms
**PikaPods:**
1. Click "New App"
2. Search "Open Notebook"
3. Set environment variables
3. Set environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
4. Click "Deploy"
5. Open the app → Go to **Settings → API Keys** to configure your AI provider
**Railway:**
1. Create new project
2. Add `lfnovo/open_notebook:v1-latest-single`
3. Set environment variables
3. Set environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
4. Deploy
5. Open the app → Go to **Settings → API Keys** to configure your AI provider
**Render:**
1. Create new Web Service
2. Use Docker image: `lfnovo/open_notebook:v1-latest-single`
3. Set environment variables in dashboard
3. Set environment variables in dashboard (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
4. Configure persistent disk for `/app/data` and `/mydata`
**DigitalOcean App Platform:**
1. Create new app from Docker Hub
2. Use image: `lfnovo/open_notebook:v1-latest-single`
3. Set port to 8502
4. Add environment variables
4. Add environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
5. Configure persistent storage
**Heroku:**
@ -78,14 +86,14 @@ Access: `http://localhost:8502`
# Using heroku.yml
heroku container:push web
heroku container:release web
heroku config:set OPENAI_API_KEY=sk-...
heroku config:set OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
```
**Coolify:**
1. Add new service → Docker Image
2. Image: `lfnovo/open_notebook:v1-latest-single`
3. Port: 8502
4. Add environment variables
4. Add environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
5. Enable persistent volumes
6. Coolify handles HTTPS automatically
@ -95,12 +103,14 @@ heroku config:set OPENAI_API_KEY=sk-...
| Variable | Purpose | Example |
|----------|---------|---------|
| `OPENAI_API_KEY` | API key | `sk-...` |
| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | Encryption key for credentials (required) | `my-secret-key` |
| `SURREAL_URL` | Database | `ws://localhost:8000/rpc` |
| `SURREAL_USER` | DB user | `root` |
| `SURREAL_PASSWORD` | DB password | `password` |
| `API_URL` | External URL (for remote access) | `https://myapp.example.com` |
AI provider API keys are configured via the **Settings → API Keys** UI after deployment.
---
## Limitations vs Docker Compose
@ -119,4 +129,7 @@ heroku config:set OPENAI_API_KEY=sk-...
Same as Docker Compose setup - just access via `http://localhost:8502` (local) or your platform's URL (cloud).
1. Go to **Settings → API Keys** to add your AI provider credential
2. **Test Connection** and **Discover Models**
See [Docker Compose](docker-compose.md) for full post-install guide.

View file

@ -0,0 +1,390 @@
# API Configuration
Configure AI provider credentials through the Settings UI. No file editing required.
> **Credential System**: Open Notebook uses encrypted credentials stored in the database. Each credential connects to a provider and allows you to discover, register, and test models.
---
## Overview
Open Notebook manages AI provider access through a **credential-based system**:
1. You create a **credential** for each provider (API key + settings)
2. Credentials are **encrypted** and stored in the database
3. You **test connections** to verify credentials work
4. You **discover and register models** from each credential
5. Models are linked to credentials for direct configuration
---
## Encryption Setup
Before storing credentials, you must configure an encryption key.
### Setting the Encryption Key
Add `OPEN_NOTEBOOK_ENCRYPTION_KEY` to your docker-compose.yml:
```yaml
environment:
- OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase
```
Any string works as a key — it will be securely derived via SHA-256 internally.
> **Warning**: If you change or lose the encryption key, **all stored credentials become unreadable**. Back up your encryption key securely and separately from your database backups.
### Docker Secrets Support
Both password and encryption key support Docker secrets:
```yaml
# docker-compose.yml
services:
open_notebook:
environment:
- OPEN_NOTEBOOK_PASSWORD_FILE=/run/secrets/app_password
- OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key
secrets:
- app_password
- encryption_key
secrets:
app_password:
file: ./secrets/password.txt
encryption_key:
file: ./secrets/encryption_key.txt
```
### Encryption Details
API keys stored in the database are encrypted using Fernet (AES-128-CBC + HMAC-SHA256).
| Configuration | Behavior |
|---------------|----------|
| Encryption key set | Keys encrypted with your key |
| No encryption key set | Storing credentials is disabled |
---
## Accessing Credential Configuration
1. Click **Settings** in the navigation bar
2. Select **API Keys** tab
3. You'll see existing credentials and an **Add Credential** button
```
Navigation: Settings → API Keys
```
---
## Supported Providers
### Cloud Providers
| Provider | Required Fields | Optional Fields |
|----------|-----------------|-----------------|
| OpenAI | API Key | — |
| Anthropic | API Key | — |
| Google Gemini | API Key | — |
| Groq | API Key | — |
| Mistral | API Key | — |
| DeepSeek | API Key | — |
| xAI | API Key | — |
| OpenRouter | API Key | — |
| Voyage AI | API Key | — |
| ElevenLabs | API Key | — |
### Local/Self-Hosted
| Provider | Required Fields | Notes |
|----------|-----------------|-------|
| Ollama | Base URL | Typically `http://localhost:11434` or `http://ollama:11434` |
### Enterprise
| Provider | Required Fields | Optional Fields |
|----------|-----------------|-----------------|
| Azure OpenAI | API Key, Endpoint, API Version | Service-specific endpoints (LLM, Embedding, STT, TTS) |
| OpenAI-Compatible | Base URL | API Key, Service-specific configs |
| Vertex AI | Project ID, Location, Credentials Path | — |
---
## Creating a Credential
### Step 1: Add Credential
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select your provider
4. Give it a descriptive name (e.g., "My OpenAI Key", "Work Anthropic")
5. Fill in the required fields (API key, base URL, etc.)
6. Click **Save**
### Step 2: Test Connection
1. On your new credential card, click **Test Connection**
2. Wait for the result:
| Result | Meaning |
|--------|---------|
| Success | Key is valid, provider accessible |
| Invalid API key | Check key format and value |
| Connection failed | Check URL, network, firewall |
### Step 3: Discover Models
1. Click **Discover Models** on the credential card
2. The system queries the provider for available models
3. Review the discovered models
### Step 4: Register Models
1. Select the models you want to use
2. Click **Register Models**
3. The models are now available throughout Open Notebook
---
## Multi-Credential Support
Each provider can have **multiple credentials**. This is useful when:
- You have different API keys for different projects
- You want to test with different endpoints
- Multiple team members need separate credentials
### Creating Multiple Credentials
1. Click **Add Credential** again
2. Select the same provider
3. Fill in different credentials
4. Each credential can discover and register its own models
### How Models Link to Credentials
When you register models from a credential, those models are linked to that specific credential. This means:
- Each model knows which API key to use
- You can have models from different credentials for the same provider
- Deleting a credential removes its linked models
---
## Testing Connections
Click **Test Connection** to verify your credential:
| Result | Meaning |
|--------|---------|
| Success | Key is valid, provider accessible |
| Invalid API key | Check key format and value |
| Connection failed | Check URL, network, firewall |
| Model not available | Key valid but model access restricted |
Test uses inexpensive models (e.g., `gpt-3.5-turbo`, `claude-3-haiku`) to minimize cost.
---
## Configuring Specific Providers
### Simple Providers (API Key Only)
For OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter:
1. Add credential with your API key
2. Test connection
3. Discover and register models
### Ollama (URL-Based)
1. Add credential with provider **Ollama**
2. Enter the base URL (e.g., `http://ollama:11434`)
3. Test connection
4. Discover and register models
Ollama allows localhost and private IPs since it runs locally.
### Azure OpenAI
Azure requires multiple fields:
| Field | Example | Required |
|-------|---------|----------|
| API Key | `abc123...` | Yes |
| Endpoint | `https://myresource.openai.azure.com` | Yes |
| API Version | `2024-02-15-preview` | Yes |
| LLM Endpoint | `https://myresource-llm.openai.azure.com` | No |
| Embedding Endpoint | `https://myresource-embed.openai.azure.com` | No |
Service-specific endpoints override the main endpoint for that service type.
### OpenAI-Compatible
For custom OpenAI-compatible servers (LM Studio, vLLM, etc.):
1. Add credential with provider **OpenAI-Compatible**
2. Enter the base URL
3. Enter API key (if required)
4. Optionally configure per-service URLs
Supports separate configurations for:
- LLM (language models)
- Embedding
- STT (speech-to-text)
- TTS (text-to-speech)
### Vertex AI
Google Cloud's enterprise AI platform:
| Field | Example |
|-------|---------|
| Project ID | `my-gcp-project` |
| Location | `us-central1` |
| Credentials Path | `/path/to/service-account.json` |
---
## Migrating from Environment Variables
If you have existing API keys in environment variables (from a previous version):
1. Open **Settings → API Keys**
2. A banner appears: "Environment variables detected"
3. Click **Migrate to Database**
4. Keys are copied to the database (encrypted)
5. Original environment variables remain unchanged
### Migration Behavior
| Scenario | Action |
|----------|--------|
| Key in env only | Migrated to database |
| Key in database only | No change |
| Key in both | Database version kept (skipped) |
### After Migration
- Database credentials are used for all operations
- You can remove the API key environment variables from your docker-compose.yml
- Keep `OPEN_NOTEBOOK_ENCRYPTION_KEY` — it's still required
### Migration Banner Visibility
The migration banner only appears when:
- You have environment variables configured
- Those providers are **not** already in the database
- If all env providers are already migrated, the banner won't show
---
## Migrating from ProviderConfig (v1.1 → v1.2)
If you're upgrading from an older version that used the ProviderConfig system:
- The migration happens automatically on first startup
- Your existing configurations are converted to credentials
- Check **Settings → API Keys** to verify the migration succeeded
- If you see issues, check the API logs for migration messages
---
## Key Storage Security
### Encryption
API keys stored in the database are encrypted using Fernet (AES-128-CBC + HMAC-SHA256).
| Configuration | Behavior |
|---------------|----------|
| Encryption key set | Keys encrypted with your key |
| No encryption key set | Storing API keys in database is disabled |
### Default Credentials
| Setting | Default Value | Production Recommendation |
|---------|---------------|---------------------------|
| Password | `open-notebook-change-me` | Set `OPEN_NOTEBOOK_PASSWORD` |
| Encryption Key | None (must be set) | Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` to any secret string |
**For production deployments, always set custom credentials.**
---
## Deleting Credentials
1. Click the **Delete** button on the credential card
2. Confirm deletion
3. Credential and all its linked models are removed from the database
---
## Troubleshooting
### Credential Not Saving
| Symptom | Cause | Solution |
|---------|-------|----------|
| Save button disabled | Empty or invalid input | Enter a valid key |
| Error on save | Encryption key not set | Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in docker-compose.yml |
| Error on save | Database connection issue | Check database status |
### Test Connection Fails
| Error | Cause | Solution |
|-------|-------|----------|
| Invalid API key | Wrong key or format | Verify key from provider dashboard |
| Connection refused | Wrong URL | Check base URL format |
| Timeout | Network issue | Check firewall, proxy settings |
| 403 Forbidden | IP restriction | Whitelist your server IP |
### Migration Issues
| Problem | Solution |
|---------|----------|
| No migration banner | No env vars detected, or already migrated |
| Partial migration | Check error list, fix and retry |
| Keys not working after migration | Clear browser cache, restart services |
### Provider Shows "Not Configured"
1. Check if a credential exists for this provider (Settings → API Keys)
2. Test the credential connection
3. Verify key format matches provider requirements
4. Re-discover and register models if needed
---
## Provider-Specific Notes
### OpenAI
- Keys start with `sk-proj-` (project keys) or `sk-` (legacy)
- Requires billing enabled on account
### Anthropic
- Keys start with `sk-ant-`
- Check account has API access enabled
### Google Gemini
- Keys start with `AIzaSy`
- Free tier has rate limits
### Ollama
- No API key required
- Default URL: `http://localhost:11434` (local) or `http://ollama:11434` (Docker)
- Ensure Ollama server is running
### Azure OpenAI
- Endpoint format: `https://{resource-name}.openai.azure.com`
- API version format: `YYYY-MM-DD` or `YYYY-MM-DD-preview`
- Deployment names configured separately when registering models via the credential's Discover Models dialog
---
## Related
- **[AI Providers](../5-CONFIGURATION/ai-providers.md)** — Provider setup instructions and recommendations
- **[Security](../5-CONFIGURATION/security.md)** — Password and encryption configuration
- **[Environment Reference](../5-CONFIGURATION/environment-reference.md)** — All configuration options

View file

@ -97,6 +97,18 @@ Verify AI claims by tracing them back to source material. Understand the citatio
---
### 8. [API Configuration](api-configuration.md)
Configure AI provider API keys directly through the Settings UI.
**Quick links:**
- Add API keys without editing files
- Test provider connections
- Migrate from environment variables
- Manage Azure and OpenAI-compatible providers
- Understand key storage and encryption
---
## Which Feature for Which Task?
```
@ -120,6 +132,9 @@ Task: "I want to find that quote I remember"
Task: "I'm exploring a concept without knowing exact words"
→ Use: Search / Vector Search (semantic similarity)
Task: "I need to add or change my AI provider API keys"
→ Use: Settings / API Keys (configure providers without editing files)
```
---

View file

@ -142,8 +142,8 @@ Now that you've chosen a provider, follow the detailed setup instructions:
**[AI Providers Configuration Guide](../5-CONFIGURATION/ai-providers.md)**
This guide includes:
- Step-by-step setup instructions for each provider
- Environment variable configuration
- Step-by-step setup instructions for each provider via the Settings UI
- How to add credentials, test connections, and discover models
- Model selection and recommendations
- Provider-specific troubleshooting
- Hardware requirements (for local providers)
@ -184,11 +184,11 @@ Any use: Free (electricity only)
## Next Steps
1. **You've chosen a provider** (from this comparison guide)
2. 📖 **Follow the setup guide**: [AI Providers Configuration](../5-CONFIGURATION/ai-providers.md)
3. ⚙️ **Configure your environment** (detailed in the setup guide)
4. 🧪 **Test your setup** in Settings → Models
5. 🚀 **Start using Open Notebook!**
1. **You've chosen a provider** (from this comparison guide)
2. **Follow the setup guide**: [AI Providers Configuration](../5-CONFIGURATION/ai-providers.md)
3. **Add your credential** in Settings → API Keys
4. **Test your connection** and discover models
5. **Start using Open Notebook!**
---

View file

@ -197,28 +197,21 @@ ESPERANTO_SSL_VERIFY=false
### Use Different Providers for Different Tasks
```env
# Language model (main)
OPENAI_API_KEY=sk-proj-...
Configure multiple AI providers via **Settings → API Keys**. Each provider gets its own credential:
# Embeddings (alternative)
# (Future: Configure different embedding provider)
1. Add a credential for your main language model provider (e.g., OpenAI, Anthropic)
2. Add a credential for embeddings (e.g., Voyage AI, or use the same provider)
3. Add a credential for TTS (e.g., ElevenLabs, or OpenAI-Compatible for local Speaches)
4. Each credential's models are registered and available independently
# TTS (different provider)
ELEVENLABS_API_KEY=...
```
### Multiple Endpoints for OpenAI-Compatible
### OpenAI-Compatible with Fallback
When using OpenAI-Compatible providers, you can configure per-service URLs in a single credential:
```env
# Primary
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
OPENAI_COMPATIBLE_API_KEY=key1
# Can also set specific modality endpoints
OPENAI_COMPATIBLE_BASE_URL_LLM=http://localhost:1234/v1
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8001/v1
```
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **OpenAI-Compatible**
3. Configure separate URLs for LLM, Embedding, TTS, and STT
4. Click **Save**, then **Test Connection**
---
@ -283,25 +276,12 @@ Get key from: https://jina.ai/
## Environment Variable Groups
### API Keys (Choose at least one)
### Credential Storage (Required)
```env
OPENAI_API_KEY
ANTHROPIC_API_KEY
GOOGLE_API_KEY
GROQ_API_KEY
MISTRAL_API_KEY
DEEPSEEK_API_KEY
OPENROUTER_API_KEY
XAI_API_KEY
OPEN_NOTEBOOK_ENCRYPTION_KEY # Required for storing credentials
```
### AI Provider Endpoints
```env
OLLAMA_API_BASE
OPENAI_COMPATIBLE_BASE_URL
AZURE_OPENAI_ENDPOINT
GEMINI_API_BASE_URL
```
AI provider API keys are configured via **Settings → API Keys** (not environment variables).
### Database
```env
@ -332,10 +312,11 @@ ESPERANTO_LLM_TIMEOUT
### Audio/TTS
```env
ELEVENLABS_API_KEY
TTS_BATCH_SIZE
```
> **Note:** `ELEVENLABS_API_KEY` is deprecated. Configure ElevenLabs via **Settings → API Keys**.
### Debugging
```env
LANGCHAIN_TRACING_V2
@ -351,14 +332,10 @@ LANGCHAIN_PROJECT
### Quick Test
```bash
# Add test config
export OPENAI_API_KEY=sk-test-key
export API_URL=http://localhost:5055
# Test connection
# Test API health
curl http://localhost:5055/health
# Test with sample
# Test with sample (requires configured credential and registered models)
curl -X POST http://localhost:5055/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"Hello"}'
@ -368,7 +345,7 @@ curl -X POST http://localhost:5055/api/chat \
```bash
# Check environment variables are set
env | grep OPENAI_API_KEY
env | grep OPEN_NOTEBOOK_ENCRYPTION_KEY
# Verify database connection
python -c "import os; print(os.getenv('SURREAL_URL'))"

View file

@ -1,6 +1,22 @@
# AI Providers - Configuration Reference
# AI Providers - Configuration Guide
Complete setup instructions for each AI provider. Pick the one you're using.
Complete setup instructions for each AI provider via the **Settings UI**.
> **New in v1.2**: All AI provider credentials are now managed through the Settings UI. Environment variables for API keys are deprecated.
---
## How Provider Setup Works
Open Notebook uses a **credential-based system** for managing AI providers:
1. **Get your API key** from the provider's website
2. **Open Settings****API Keys** → **Add Credential**
3. **Test the connection** to verify it works
4. **Discover & Register Models** to make them available
5. **Start using** the provider in your notebooks
> **Prerequisite**: You must set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your docker-compose.yml before storing credentials. See [API Configuration](../3-USER-GUIDE/api-configuration.md#encryption-setup) for details.
---
@ -10,21 +26,21 @@ Complete setup instructions for each AI provider. Pick the one you're using.
**Cost:** ~$0.03-0.15 per 1K tokens (varies by model)
**Setup:**
```bash
**Get Your API Key:**
1. Go to https://platform.openai.com/api-keys
2. Create account (if needed)
3. Create new API key (starts with "sk-proj-")
4. Add $5+ credits to account
5. Add to .env:
OPENAI_API_KEY=sk-proj-...
6. Restart services
```
**Environment Variable:**
```
OPENAI_API_KEY=sk-proj-xxxxx
```
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **OpenAI**
4. Give it a name (e.g., "My OpenAI Key")
5. Paste your API key
6. Click **Save**, then **Test Connection**
7. Click **Discover Models** to find available models
8. Click **Register Models** to make them available
**Available Models (in Open Notebook):**
- `gpt-4o` — Best quality, fast (latest version)
@ -45,9 +61,9 @@ Heavy use: $50-100+/month
```
**Troubleshooting:**
- "Invalid API key" → Check key starts with "sk-proj-"
- "Invalid API key" → Check key starts with "sk-proj-" and test the connection in Settings
- "Rate limit exceeded" → Wait or upgrade account
- "Model not available" → Try gpt-4o-mini instead
- "Model not available" → Try gpt-4o-mini instead, or re-discover models
---
@ -55,21 +71,19 @@ Heavy use: $50-100+/month
**Cost:** ~$0.80-3.00 per 1M tokens (cheaper than OpenAI for long context)
**Setup:**
```bash
**Get Your API Key:**
1. Go to https://console.anthropic.com/
2. Create account or login
3. Go to API keys section
4. Create new API key (starts with "sk-ant-")
5. Add to .env:
ANTHROPIC_API_KEY=sk-ant-...
6. Restart services
```
**Environment Variable:**
```
ANTHROPIC_API_KEY=sk-ant-xxxxx
```
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **Anthropic**
4. Give it a name, paste your API key
5. Click **Save**, then **Test Connection**
6. Click **Discover Models** → **Register Models**
**Available Models:**
- `claude-sonnet-4-5-20250929` — Latest, best quality (recommended)
@ -95,9 +109,9 @@ Opus: $10-50+/month
- Fast processing
**Troubleshooting:**
- "Invalid API key" → Check it starts with "sk-ant-"
- "Invalid API key" → Check it starts with "sk-ant-" and test in Settings
- "Overloaded" → Anthropic is busy, retry later
- "Model unavailable" → Check model name is correct
- "Model unavailable" → Re-discover models from the credential
---
@ -105,28 +119,22 @@ Opus: $10-50+/month
**Cost:** ~$0.075-0.30 per 1K tokens (competitive with OpenAI)
**Setup:**
```bash
**Get Your API Key:**
1. Go to https://aistudio.google.com/app/apikey
2. Create account or login
3. Create new API key
4. Add to .env:
GOOGLE_API_KEY=AIzaSy...
5. Restart services
```
**Environment Variable:**
```
GOOGLE_API_KEY=AIzaSy...
# Optional: override default endpoint
GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
```
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **Google Gemini**
4. Give it a name, paste your API key
5. Click **Save**, then **Test Connection**
6. Click **Discover Models** → **Register Models**
**Available Models:**
- `gemini-2.0-flash-exp` — Latest experimental, fastest (recommended)
- `gemini-2.0-flash` — Stable version, fast, cheap
- `gemini-1.5-pro-latest` — More capable, longer context
- `gemini-1.5-flash` — Previous generation, very cheap
**Recommended:**
- For general use: `gemini-2.0-flash-exp` (best value, latest)
@ -141,7 +149,7 @@ GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
**Troubleshooting:**
- "API key invalid" → Get fresh key from aistudio.google.com
- "Quota exceeded" → Free tier limited, upgrade account
- "Model not found" → Check model name spelling
- "Model not found" → Re-discover models from the credential
---
@ -149,20 +157,18 @@ GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
**Cost:** ~$0.05 per 1M tokens (cheapest, but limited models)
**Setup:**
```bash
**Get Your API Key:**
1. Go to https://console.groq.com/keys
2. Create account or login
3. Create new API key
4. Add to .env:
GROQ_API_KEY=gsk_...
5. Restart services
```
**Environment Variable:**
```
GROQ_API_KEY=gsk_xxxxx
```
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **Groq**
4. Give it a name, paste your API key
5. Click **Save**, then **Test Connection**
6. Click **Discover Models** → **Register Models**
**Available Models:**
- `llama-3.3-70b-versatile` — Best on Groq (recommended)
@ -186,7 +192,7 @@ GROQ_API_KEY=gsk_xxxxx
**Troubleshooting:**
- "Rate limited" → Free tier has limits, upgrade
- "Model not available" → Check supported models list
- "Model not available" → Re-discover models from the credential
---
@ -194,21 +200,19 @@ GROQ_API_KEY=gsk_xxxxx
**Cost:** Varies by model ($0.05-15 per 1M tokens)
**Setup:**
```bash
**Get Your API Key:**
1. Go to https://openrouter.ai/keys
2. Create account or login
3. Add credits to your account
4. Create new API key
5. Add to .env:
OPENROUTER_API_KEY=sk-or-...
6. Restart services
```
**Environment Variable:**
```
OPENROUTER_API_KEY=sk-or-xxxxx
```
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **OpenRouter**
4. Give it a name, paste your API key
5. Click **Save**, then **Test Connection**
6. Click **Discover Models** → **Register Models**
**Available Models (100+ options):**
- OpenAI: `openai/gpt-4o`, `openai/o1`
@ -251,28 +255,24 @@ Heavy use: Depends on models chosen
**Cost:** Free (electricity only)
**Setup:**
```bash
**Setup Ollama:**
1. Install Ollama: https://ollama.ai
2. Run Ollama in background:
ollama serve
2. Run Ollama in background: `ollama serve`
3. Download a model: `ollama pull mistral`
3. Download a model:
ollama pull mistral
# or llama2, neural-chat, phi, etc.
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **Ollama**
4. Give it a name (e.g., "Local Ollama")
5. Enter the base URL:
- Same machine (non-Docker): `http://localhost:11434`
- Docker with Ollama on host: `http://host.docker.internal:11434`
- Docker with Ollama container: `http://ollama:11434`
6. Click **Save**, then **Test Connection**
7. Click **Discover Models** → **Register Models**
4. Add to .env:
OLLAMA_API_BASE=http://localhost:11434
# If on different machine:
# OLLAMA_API_BASE=http://10.0.0.5:11434
5. Restart services
```
**Environment Variable:**
```
OLLAMA_API_BASE=http://localhost:11434
```
See [Ollama Setup Guide](ollama.md) for detailed network configuration.
**Available Models:**
- `llama3.3:70b` — Best quality (requires 40GB+ RAM)
@ -314,7 +314,7 @@ CPU-only:
- Requires local hardware
**Troubleshooting:**
- "Connection refused" → Ollama not running or wrong port
- "Connection refused" → Ollama not running or wrong URL in credential
- "Model not found" → Download it: `ollama pull modelname`
- "Out of memory" → Use smaller model or add more RAM
@ -324,24 +324,21 @@ CPU-only:
**Cost:** Free
**Setup:**
```bash
**Setup LM Studio:**
1. Download LM Studio: https://lmstudio.ai
2. Open app
3. Download a model from library
4. Go to "Local Server" tab
5. Start server (default port: 1234)
6. Add to .env:
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
OPENAI_COMPATIBLE_API_KEY=not-needed
7. Restart services
```
**Environment Variables:**
```
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
OPENAI_COMPATIBLE_API_KEY=lm-studio # Just a placeholder
```
**Configure in Open Notebook:**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **OpenAI-Compatible**
4. Give it a name (e.g., "LM Studio")
5. Enter the base URL: `http://host.docker.internal:1234/v1` (Docker) or `http://localhost:1234/v1` (local)
6. API key: `lm-studio` (placeholder, LM Studio doesn't require one)
7. Click **Save**, then **Test Connection**
**Advantages:**
- GUI interface (easier than Ollama CLI)
@ -360,27 +357,15 @@ OPENAI_COMPATIBLE_API_KEY=lm-studio # Just a placeholder
For Text Generation UI, vLLM, or other OpenAI-compatible endpoints:
```bash
Add to .env:
OPENAI_COMPATIBLE_BASE_URL=http://your-endpoint/v1
OPENAI_COMPATIBLE_API_KEY=your-api-key
```
1. Go to **Settings** → **API Keys**
2. Click **Add Credential**
3. Select provider: **OpenAI-Compatible**
4. Enter the base URL for your endpoint (e.g., `http://localhost:8000/v1`)
5. Enter API key if required
6. Optionally configure per-service URLs (LLM, Embedding, TTS, STT)
7. Click **Save**, then **Test Connection**
If you need different endpoints for different modalities:
```bash
# Language model
OPENAI_COMPATIBLE_BASE_URL_LLM=http://localhost:8000/v1
OPENAI_COMPATIBLE_API_KEY_LLM=sk-...
# Embeddings
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8001/v1
OPENAI_COMPATIBLE_API_KEY_EMBEDDING=sk-...
# TTS (text-to-speech)
OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8002/v1
OPENAI_COMPATIBLE_API_KEY_TTS=sk-...
```
See [OpenAI-Compatible Setup](openai-compatible.md) for detailed instructions.
---
@ -390,29 +375,16 @@ OPENAI_COMPATIBLE_API_KEY_TTS=sk-...
**Cost:** Same as OpenAI (usage-based)
**Setup:**
```bash
**Configure in Open Notebook:**
1. Create Azure OpenAI service in Azure portal
2. Deploy GPT-4/3.5-turbo model
3. Get your endpoint and key
4. Add to .env:
AZURE_OPENAI_API_KEY=your-key
AZURE_OPENAI_ENDPOINT=https://your-name.openai.azure.com/
AZURE_OPENAI_API_VERSION=2024-12-01-preview
5. Restart services
```
**Environment Variables:**
```
AZURE_OPENAI_API_KEY=xxxxx
AZURE_OPENAI_ENDPOINT=https://your-instance.openai.azure.com/
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Optional: Different deployments for different modalities
AZURE_OPENAI_API_KEY_LLM=xxxxx
AZURE_OPENAI_ENDPOINT_LLM=https://your-instance.openai.azure.com/
AZURE_OPENAI_API_VERSION_LLM=2024-12-01-preview
```
4. Go to **Settings** → **API Keys**
5. Click **Add Credential**
6. Select provider: **Azure OpenAI**
7. Fill in: API Key, Endpoint, API Version
8. Optionally configure service-specific endpoints (LLM, Embedding)
9. Click **Save**, then **Test Connection**
**Advantages:**
- Enterprise support
@ -428,26 +400,13 @@ AZURE_OPENAI_API_VERSION_LLM=2024-12-01-preview
## Embeddings (For Search/Semantic Features)
By default, Open Notebook uses the LLM provider's embeddings. To use a different provider:
### OpenAI Embeddings (Default)
```
# Uses OpenAI's embedding model automatically
# Requires OPENAI_API_KEY
# No separate configuration needed
```
### Custom Embeddings
```
# For other embedding providers (future feature)
EMBEDDING_PROVIDER=openai # or custom
```
By default, Open Notebook uses the LLM provider's embeddings. Embedding models are discovered and registered through the same credential system — when you discover models from a credential, embedding models are included alongside language models.
---
## Choosing Your Provider
**1. Don't want to run locally and don't want to mess around with different providers:**
**1. Don't want to run locally and don't want to mess around with different providers:**
Use OpenAI
- Cloud-based
@ -476,17 +435,29 @@ Use OpenAI
1. **Choose your provider** from above
2. **Get API key** (if cloud) or install locally (if Ollama)
3. **Add to .env**
4. **Restart services**
5. **Go to Settings → Models** in Open Notebook
6. **Verify it works** with a test chat
3. **Set `OPEN_NOTEBOOK_ENCRYPTION_KEY`** in your docker-compose.yml (required for storing credentials)
4. **Open Settings****API Keys** → **Add Credential**
5. **Test Connection** to verify it works
6. **Discover & Register Models** to make them available
7. **Verify it works** with a test chat
> **Multiple providers**: You can add credentials for as many providers as you want. Create separate credentials for different projects or team members.
Done!
---
## Legacy: Environment Variables (Deprecated)
> **Deprecated**: Configuring AI provider API keys via environment variables is deprecated. Use the Settings UI instead. Environment variables may still work as a fallback but are no longer the recommended approach.
If you are migrating from an older version that used environment variables, go to **Settings****API Keys** and click the **Migrate to Database** button to import your existing keys into the credential system.
---
## Related
- **[API Configuration](../3-USER-GUIDE/api-configuration.md)** — Detailed credential management guide
- **[Environment Reference](environment-reference.md)** - Complete list of all environment variables
- **[Advanced Configuration](advanced.md)** - Timeouts, SSL, performance tuning
- **[Ollama Setup](ollama.md)** - Detailed Ollama configuration guide

View file

@ -12,160 +12,10 @@ Comprehensive list of all environment variables available in Open Notebook.
| `INTERNAL_API_URL` | No | http://localhost:5055 | Internal API URL for Next.js server-side proxying |
| `API_CLIENT_TIMEOUT` | No | 300 | Client timeout in seconds (how long to wait for API response) |
| `OPEN_NOTEBOOK_PASSWORD` | No | None | Password to protect Open Notebook instance |
| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | **Yes** | None | Secret string to encrypt credentials stored in database (any string works). **Required** for the credential system. Supports Docker secrets via `_FILE` suffix. |
| `HOSTNAME` | No | `0.0.0.0` (in Docker) | Network interface for Next.js to bind to. Default `0.0.0.0` ensures accessibility from reverse proxies |
---
## AI Provider: OpenAI
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `OPENAI_API_KEY` | If using OpenAI | None | OpenAI API key (starts with `sk-`) |
**Setup:** Get from https://platform.openai.com/api-keys
---
## AI Provider: Anthropic (Claude)
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `ANTHROPIC_API_KEY` | If using Anthropic | None | Claude API key (starts with `sk-ant-`) |
**Setup:** Get from https://console.anthropic.com/
---
## AI Provider: Google Gemini
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `GOOGLE_API_KEY` | If using Google | None | Google API key for Gemini |
| `GEMINI_API_BASE_URL` | No | Default endpoint | Override Gemini API endpoint |
| `VERTEX_PROJECT` | If using Vertex AI | None | Google Cloud project ID |
| `VERTEX_LOCATION` | If using Vertex AI | us-east5 | Vertex AI location |
| `GOOGLE_APPLICATION_CREDENTIALS` | If using Vertex AI | None | Path to service account JSON |
**Setup:** Get from https://aistudio.google.com/app/apikey
---
## AI Provider: Groq
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `GROQ_API_KEY` | If using Groq | None | Groq API key (starts with `gsk_`) |
**Setup:** Get from https://console.groq.com/keys
---
## AI Provider: Mistral
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `MISTRAL_API_KEY` | If using Mistral | None | Mistral API key |
**Setup:** Get from https://console.mistral.ai/
---
## AI Provider: DeepSeek
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `DEEPSEEK_API_KEY` | If using DeepSeek | None | DeepSeek API key |
**Setup:** Get from https://platform.deepseek.com/
---
## AI Provider: xAI
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `XAI_API_KEY` | If using xAI | None | xAI API key |
**Setup:** Get from https://console.x.ai/
---
## AI Provider: Ollama (Local)
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `OLLAMA_API_BASE` | If using Ollama | None | Ollama endpoint (e.g., http://localhost:11434) |
**Setup:** Install from https://ollama.ai
---
## AI Provider: OpenRouter
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `OPENROUTER_API_KEY` | If using OpenRouter | None | OpenRouter API key |
| `OPENROUTER_BASE_URL` | No | https://openrouter.ai/api/v1 | OpenRouter endpoint |
**Setup:** Get from https://openrouter.ai/
---
## AI Provider: OpenAI-Compatible
For self-hosted LLMs, LM Studio, or OpenAI-compatible endpoints:
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `OPENAI_COMPATIBLE_BASE_URL` | If using compatible | None | Base URL for OpenAI-compatible endpoint |
| `OPENAI_COMPATIBLE_API_KEY` | If using compatible | None | API key for endpoint |
| `OPENAI_COMPATIBLE_BASE_URL_LLM` | No | Uses generic | Language model endpoint (overrides generic) |
| `OPENAI_COMPATIBLE_API_KEY_LLM` | No | Uses generic | Language model API key (overrides generic) |
| `OPENAI_COMPATIBLE_BASE_URL_EMBEDDING` | No | Uses generic | Embedding endpoint (overrides generic) |
| `OPENAI_COMPATIBLE_API_KEY_EMBEDDING` | No | Uses generic | Embedding API key (overrides generic) |
| `OPENAI_COMPATIBLE_BASE_URL_STT` | No | Uses generic | Speech-to-text endpoint (overrides generic) |
| `OPENAI_COMPATIBLE_API_KEY_STT` | No | Uses generic | STT API key (overrides generic) |
| `OPENAI_COMPATIBLE_BASE_URL_TTS` | No | Uses generic | Text-to-speech endpoint (overrides generic) |
| `OPENAI_COMPATIBLE_API_KEY_TTS` | No | Uses generic | TTS API key (overrides generic) |
**Setup:** For LM Studio, typically: `OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1`
---
## AI Provider: Azure OpenAI
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `AZURE_OPENAI_API_KEY` | If using Azure | None | Azure OpenAI API key |
| `AZURE_OPENAI_ENDPOINT` | If using Azure | None | Azure OpenAI endpoint URL |
| `AZURE_OPENAI_API_VERSION` | No | 2024-12-01-preview | Azure OpenAI API version |
| `AZURE_OPENAI_API_KEY_LLM` | No | Uses generic | LLM-specific API key |
| `AZURE_OPENAI_ENDPOINT_LLM` | No | Uses generic | LLM-specific endpoint |
| `AZURE_OPENAI_API_VERSION_LLM` | No | Uses generic | LLM-specific API version |
| `AZURE_OPENAI_API_KEY_EMBEDDING` | No | Uses generic | Embedding-specific API key |
| `AZURE_OPENAI_ENDPOINT_EMBEDDING` | No | Uses generic | Embedding-specific endpoint |
| `AZURE_OPENAI_API_VERSION_EMBEDDING` | No | Uses generic | Embedding-specific API version |
---
## AI Provider: VoyageAI (Embeddings)
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `VOYAGE_API_KEY` | If using Voyage | None | Voyage AI API key (for embeddings) |
**Setup:** Get from https://www.voyageai.com/
---
## Text-to-Speech (TTS)
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `ELEVENLABS_API_KEY` | If using ElevenLabs TTS | None | ElevenLabs API key for voice generation |
| `TTS_BATCH_SIZE` | No | 5 | Concurrent TTS requests (1-5, depends on provider) |
**Setup:** Get from https://elevenlabs.io/
> **Important**: `OPEN_NOTEBOOK_ENCRYPTION_KEY` is required for storing AI provider credentials via the Settings UI. Without it, you cannot save credentials. If you change or lose this key, all stored credentials become unreadable.
---
@ -211,6 +61,14 @@ For self-hosted LLMs, LM Studio, or OpenAI-compatible endpoints:
---
## Text-to-Speech (TTS)
| Variable | Required? | Default | Description |
|----------|-----------|---------|-------------|
| `TTS_BATCH_SIZE` | No | 5 | Concurrent TTS requests (1-5, depends on provider) |
---
## Content Extraction
| Variable | Required? | Default | Description |
@ -274,19 +132,21 @@ NO_PROXY=localhost,127.0.0.1,.local
## Environment Variables by Use Case
### Minimal Setup (Cloud Provider)
### Minimal Setup (New Installation)
```
OPENAI_API_KEY (or ANTHROPIC_API_KEY, etc.)
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
SURREAL_URL=ws://surrealdb:8000/rpc
SURREAL_USER=root
SURREAL_PASSWORD=password
SURREAL_NAMESPACE=open_notebook
SURREAL_DATABASE=open_notebook
```
Then configure AI providers via **Settings → API Keys** in the browser.
### Local Development (Ollama)
### Production Deployment
```
OLLAMA_API_BASE=http://localhost:11434
```
### Production (OpenAI + Custom Domain)
```
OPENAI_API_KEY=sk-proj-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=your-strong-secret-key
OPEN_NOTEBOOK_PASSWORD=your-secure-password
API_URL=https://mynotebook.example.com
SURREAL_USER=production_user
SURREAL_PASSWORD=secure_password
@ -294,13 +154,13 @@ SURREAL_PASSWORD=secure_password
### Self-Hosted Behind Reverse Proxy
```
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
API_URL=https://mynotebook.example.com
```
### Corporate Environment (Behind Proxy)
```
OPENAI_API_KEY=sk-proj-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
HTTP_PROXY=http://proxy.corp.com:8080
HTTPS_PROXY=http://proxy.corp.com:8080
NO_PROXY=localhost,127.0.0.1
@ -308,7 +168,7 @@ NO_PROXY=localhost,127.0.0.1
### High-Performance Deployment
```
OPENAI_API_KEY=sk-proj-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
SURREAL_COMMANDS_MAX_TASKS=10
TTS_BATCH_SIZE=5
API_CLIENT_TIMEOUT=600
@ -316,7 +176,7 @@ API_CLIENT_TIMEOUT=600
### Debugging
```
OPENAI_API_KEY=sk-proj-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your-key
```
@ -329,10 +189,10 @@ Check if a variable is set:
```bash
# Check single variable
echo $OPENAI_API_KEY
echo $OPEN_NOTEBOOK_ENCRYPTION_KEY
# Check multiple
env | grep -E "OPENAI|API_URL"
env | grep -E "OPEN_NOTEBOOK|API_URL"
# Print all config
env | grep -E "^[A-Z_]+=" | sort
@ -342,23 +202,73 @@ env | grep -E "^[A-Z_]+=" | sort
## Notes
- **Case-sensitive:** `OPENAI_API_KEY` ≠ `openai_api_key`
- **No spaces:** `OPENAI_API_KEY=sk-proj-...` not `OPENAI_API_KEY = sk-proj-...`
- **Case-sensitive:** `OPEN_NOTEBOOK_ENCRYPTION_KEY` ≠ `open_notebook_encryption_key`
- **No spaces:** `OPEN_NOTEBOOK_ENCRYPTION_KEY=my-key` not `OPEN_NOTEBOOK_ENCRYPTION_KEY = my-key`
- **Quote values:** Use quotes for values with spaces: `API_URL="http://my server:5055"`
- **Restart required:** Changes take effect after restarting services
- **Secrets:** Don't commit API keys to git
- **Secrets:** Don't commit encryption keys or passwords to git
- **AI Providers:** Configure via **Settings → API Keys** in the browser (not via env vars)
- **Migration:** Use Settings UI to migrate existing env vars to the credential system. See [API Configuration](../3-USER-GUIDE/api-configuration.md#migrating-from-environment-variables)
---
## Quick Setup Checklist
- [ ] Choose AI provider (OpenAI, Anthropic, Ollama, etc.)
- [ ] Get API key if cloud provider
- [ ] Add to .env or docker.env
- [ ] Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in docker-compose.yml
- [ ] Set database credentials (`SURREAL_*`)
- [ ] Start services
- [ ] Open browser → Go to **Settings → API Keys**
- [ ] **Add Credential** for your AI provider
- [ ] **Test Connection** to verify
- [ ] **Discover & Register Models**
- [ ] Set `API_URL` if behind reverse proxy
- [ ] Change `SURREAL_PASSWORD` in production
- [ ] Verify with: `docker compose logs api | grep -i "error"`
- [ ] Test in browser: Go to Settings → Models
- [ ] Try a test chat
Done!
---
## Legacy: AI Provider Environment Variables (Deprecated)
> **Deprecated**: The following AI provider API key environment variables are deprecated. Configure providers via the Settings UI instead. These variables may still work as a fallback but are no longer recommended.
If you have these variables configured from a previous installation, click the **Migrate to Database** button in **Settings → API Keys** to import them into the credential system, then remove them from your configuration.
| Variable | Provider | Replacement |
|----------|----------|-------------|
| `OPENAI_API_KEY` | OpenAI | Settings → API Keys → Add OpenAI Credential |
| `ANTHROPIC_API_KEY` | Anthropic | Settings → API Keys → Add Anthropic Credential |
| `GOOGLE_API_KEY` | Google Gemini | Settings → API Keys → Add Google Credential |
| `GEMINI_API_BASE_URL` | Google Gemini | Configure in Google Gemini credential |
| `VERTEX_PROJECT` | Vertex AI | Settings → API Keys → Add Vertex AI Credential |
| `VERTEX_LOCATION` | Vertex AI | Configure in Vertex AI credential |
| `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI | Configure in Vertex AI credential |
| `GROQ_API_KEY` | Groq | Settings → API Keys → Add Groq Credential |
| `MISTRAL_API_KEY` | Mistral | Settings → API Keys → Add Mistral Credential |
| `DEEPSEEK_API_KEY` | DeepSeek | Settings → API Keys → Add DeepSeek Credential |
| `XAI_API_KEY` | xAI | Settings → API Keys → Add xAI Credential |
| `OLLAMA_API_BASE` | Ollama | Settings → API Keys → Add Ollama Credential |
| `OPENROUTER_API_KEY` | OpenRouter | Settings → API Keys → Add OpenRouter Credential |
| `OPENROUTER_BASE_URL` | OpenRouter | Configure in OpenRouter credential |
| `VOYAGE_API_KEY` | Voyage AI | Settings → API Keys → Add Voyage AI Credential |
| `ELEVENLABS_API_KEY` | ElevenLabs | Settings → API Keys → Add ElevenLabs Credential |
| `OPENAI_COMPATIBLE_BASE_URL` | OpenAI-Compatible | Settings → API Keys → Add OpenAI-Compatible Credential |
| `OPENAI_COMPATIBLE_API_KEY` | OpenAI-Compatible | Configure in OpenAI-Compatible credential |
| `OPENAI_COMPATIBLE_BASE_URL_LLM` | OpenAI-Compatible | Configure per-service URL in credential |
| `OPENAI_COMPATIBLE_API_KEY_LLM` | OpenAI-Compatible | Configure per-service key in credential |
| `OPENAI_COMPATIBLE_BASE_URL_EMBEDDING` | OpenAI-Compatible | Configure per-service URL in credential |
| `OPENAI_COMPATIBLE_API_KEY_EMBEDDING` | OpenAI-Compatible | Configure per-service key in credential |
| `OPENAI_COMPATIBLE_BASE_URL_STT` | OpenAI-Compatible | Configure per-service URL in credential |
| `OPENAI_COMPATIBLE_API_KEY_STT` | OpenAI-Compatible | Configure per-service key in credential |
| `OPENAI_COMPATIBLE_BASE_URL_TTS` | OpenAI-Compatible | Configure per-service URL in credential |
| `OPENAI_COMPATIBLE_API_KEY_TTS` | OpenAI-Compatible | Configure per-service key in credential |
| `AZURE_OPENAI_API_KEY` | Azure OpenAI | Settings → API Keys → Add Azure OpenAI Credential |
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI | Configure in Azure OpenAI credential |
| `AZURE_OPENAI_API_VERSION` | Azure OpenAI | Configure in Azure OpenAI credential |
| `AZURE_OPENAI_API_KEY_LLM` | Azure OpenAI | Configure per-service in credential |
| `AZURE_OPENAI_ENDPOINT_LLM` | Azure OpenAI | Configure per-service in credential |
| `AZURE_OPENAI_API_VERSION_LLM` | Azure OpenAI | Configure per-service in credential |
| `AZURE_OPENAI_API_KEY_EMBEDDING` | Azure OpenAI | Configure per-service in credential |
| `AZURE_OPENAI_ENDPOINT_EMBEDDING` | Azure OpenAI | Configure per-service in credential |
| `AZURE_OPENAI_API_VERSION_EMBEDDING` | Azure OpenAI | Configure per-service in credential |

View file

@ -23,7 +23,7 @@ Three things:
- **Google Gemini** (multi-modal, long context)
- **Groq** (ultra-fast inference)
Setup: Get API key → Set env var → Done
Setup: Get API key → Add credential in Settings UI → Done
→ Go to **[AI Providers Guide](ai-providers.md)**
@ -86,33 +86,24 @@ SURREAL_DATABASE=open_notebook
> The only thing that is critical to not miss is the hostname in the `SURREAL_URL`. Check what URL to use based on your deployment, [here](database.md).
### AI Provider (API Key or URL)
### AI Provider (Credentials)
We need access to LLMs in order for the app to work. You can use any of the support AI Providers by adding their API Keys.
We need access to LLMs in order for the app to work. AI provider credentials are configured via the **Settings UI**:
1. Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your environment (required for storing credentials)
2. Start services
3. Go to **Settings → API Keys → Add Credential**
4. Select your provider, paste your API key
5. **Test Connection****Discover Models** → **Register Models**
```
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_API_KEY=...
OPENROUTER_API_KEY=...
# Required in your .env or docker-compose.yml:
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
```
Or, if you are planning to use only local providers, you can setup Ollama by configuring it's base URL. This will get you set and ready with text and embeddings in one go:
> **Ollama users**: Add an Ollama credential in Settings → API Keys with the correct base URL. See [Ollama Setup](ollama.md) for network configuration help.
```
OLLAMA_BASE_URL=http://localhost:11434
```
> A lot of people screw up on the Ollama BASE URL by not knowing how to point to their Ollama installation. if you are having trouble connecting to Ollama, see [here](ollama.md).
You can also use LM Studio locally if you prefer by using it as an OpenAI compatible endpoint.
```
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:1234/v1
```
> For more installation on using OpenAI compatible endpoints, see [here](openai-compatible.md).
> **LM Studio / OpenAI-Compatible**: Add an OpenAI-Compatible credential in Settings → API Keys. See [OpenAI-Compatible Guide](openai-compatible.md).
### API URL (If Behind Reverse Proxy)
@ -132,22 +123,22 @@ Auto-detection works for most setups.
### Scenario 1: Docker on Localhost (Default)
```env
# In docker.env:
OPENAI_API_KEY=sk-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
# Everything else uses defaults
# Done!
# Then configure AI provider in Settings → API Keys
```
### Scenario 2: Docker on Remote Server
```env
# In docker.env:
OPENAI_API_KEY=sk-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
API_URL=http://your-server-ip:5055
```
### Scenario 3: Behind Reverse Proxy (Nginx/Cloudflare)
```env
# In docker.env:
OPENAI_API_KEY=sk-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
API_URL=https://your-domain.com
# The reverse proxy handles HTTPS
```
@ -155,16 +146,15 @@ API_URL=https://your-domain.com
### Scenario 4: Using Ollama Locally
```env
# In .env:
OLLAMA_API_BASE=http://localhost:11434
# No API key needed
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
# Then add Ollama credential in Settings → API Keys
```
### Scenario 5: Using Azure OpenAI
```env
# In docker.env:
AZURE_OPENAI_API_KEY=your-key
AZURE_OPENAI_ENDPOINT=https://your-instance.openai.azure.com/
AZURE_OPENAI_API_VERSION=2024-12-01-preview
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
# Then add Azure OpenAI credential in Settings → API Keys
```
---
@ -241,47 +231,45 @@ AZURE_OPENAI_API_VERSION=2024-12-01-preview
## How to Add Configuration
### Method 1: Edit `.env` File (Development)
### Method 1: Settings UI (For AI Provider Credentials)
The recommended way to configure AI providers:
```
1. Open Open Notebook in your browser
2. Go to Settings → API Keys
3. Click "Add Credential"
4. Select provider, enter API key
5. Click Save, then Test Connection
6. Click Discover Models → Register Models
```
No file editing, no restarts. Credentials stored securely (encrypted) in database.
**[Full Guide: API Configuration](../3-USER-GUIDE/api-configuration.md)**
### Method 2: Edit `.env` File (Infrastructure Settings)
For database, network, and encryption key settings:
```bash
1. Open .env in your editor
2. Find the section for your provider
3. Uncomment and fill in your API key
4. Save
5. Restart services
2. Set OPEN_NOTEBOOK_ENCRYPTION_KEY and database vars
3. Save
4. Restart services
```
### Method 2: Set Docker Environment (Deployment)
### Method 3: Set Docker Environment (Deployment)
```bash
# In docker-compose.yml:
services:
api:
environment:
- OPENAI_API_KEY=sk-...
- OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
- API_URL=https://your-domain.com
```
### Method 3: Export Environment Variables
```bash
# In your terminal:
export OPENAI_API_KEY=sk-...
export API_URL=https://your-domain.com
# Then start services
docker compose up
```
### Method 4: Use docker.env File
```bash
1. Create/edit docker.env
2. Add your configuration
3. docker-compose automatically loads it
4. docker compose up
```
---
## Verification
@ -302,11 +290,11 @@ After configuration, verify it works:
| Mistake | Problem | Fix |
|---------|---------|-----|
| Forget API key | Models not available | Add OPENAI_API_KEY (or your provider) |
| No credential configured | Models not available | Add credential in Settings → API Keys |
| Missing encryption key | Can't save credentials | Set OPEN_NOTEBOOK_ENCRYPTION_KEY |
| Wrong database URL | Can't start API | Check SURREAL_URL format |
| Expose port 5055 | "Can't connect to server" | Expose 5055 in docker-compose |
| Typo in env var | Settings ignored | Check spelling (case-sensitive!) |
| Quote mismatch | Value cut off | Use quotes: OPENAI_API_KEY="sk-..." |
| Don't restart | Old config still used | Restart services after env changes |
---
@ -332,9 +320,9 @@ Once configured:
## Summary
**Minimal configuration to run:**
1. Choose an AI provider (or use Ollama locally)
2. Set API key in .env or docker.env
3. Start services
1. Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your environment
2. Start services
3. Add AI provider credential in Settings → API Keys
4. Done!
Everything else is optional optimization.

View file

@ -19,6 +19,12 @@ Run speech-to-text locally for free, private audio/video transcription using Ope
[Speaches](https://github.com/speaches-ai/speaches) is an open-source, OpenAI-compatible server that supports both TTS and STT. It uses [faster-whisper](https://github.com/SYSTRAN/faster-whisper) for transcription.
> **💡 Ready-made Docker Compose files available:**
> - **[docker-compose-speaches.yml](../../examples/docker-compose-speaches.yml)** - Speaches + Open Notebook
> - **[docker-compose-full-local.yml](../../examples/docker-compose-full-local.yml)** - Speaches + Ollama (100% local setup)
>
> These include complete setup instructions and configuration examples. Just copy and run!
### Step 1: Create Docker Compose File
Create a folder and add `docker-compose.yml`:
@ -67,15 +73,21 @@ You should see the transcribed text in the response.
### Step 4: Configure Open Notebook
**Docker deployment:**
**Via Settings UI (Recommended):**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **OpenAI-Compatible**
3. Enter base URL for STT: `http://host.docker.internal:8969/v1` (Docker) or `http://localhost:8969/v1` (local)
4. Click **Save**, then **Test Connection**
**Legacy (Deprecated) — Environment variables:**
```yaml
# In your Open Notebook docker-compose.yml
environment:
- OPENAI_COMPATIBLE_BASE_URL_STT=http://host.docker.internal:8969/v1
```
**Local development:**
```bash
# Local development
export OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:8969/v1
```
@ -162,31 +174,23 @@ This is recommended if you have enough RAM/VRAM, as loading the model can take a
## Docker Networking
When configuring your OpenAI-Compatible credential in **Settings → API Keys**, use the appropriate STT base URL for your setup:
### Open Notebook in Docker (macOS/Windows)
```bash
OPENAI_COMPATIBLE_BASE_URL_STT=http://host.docker.internal:8969/v1
```
**STT Base URL:** `http://host.docker.internal:8969/v1`
### Open Notebook in Docker (Linux)
```bash
# Option 1: Docker bridge IP
OPENAI_COMPATIBLE_BASE_URL_STT=http://172.17.0.1:8969/v1
**STT Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:8969/v1`
# Option 2: Host networking
docker run --network host ...
```
**Option 2:** Use host networking mode (`docker run --network host ...`), then use: `http://localhost:8969/v1`
### Remote Server
Run Speaches on a different machine:
```bash
# On server, bind to all interfaces
# Then in Open Notebook:
OPENAI_COMPATIBLE_BASE_URL_STT=http://server-ip:8969/v1
```
**STT Base URL:** `http://server-ip:8969/v1` (replace with your server's IP)
---
@ -330,13 +334,7 @@ docker stats speaches
## Using Both TTS and STT
Speaches supports both TTS and STT in one server. Configure both:
```bash
# Same server for both
OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1
OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:8969/v1
```
Speaches supports both TTS and STT in one server. In **Settings → API Keys**, add a single **OpenAI-Compatible** credential and configure both the TTS and STT base URLs to point to the same Speaches server (e.g., `http://localhost:8969/v1`).
See **[Local TTS Setup](local-tts.md)** for TTS configuration.
@ -356,7 +354,7 @@ Any OpenAI-compatible STT server works:
The key requirements:
1. Server implements `/v1/audio/transcriptions` endpoint
2. Set `OPENAI_COMPATIBLE_BASE_URL_STT` to server URL
2. Add an OpenAI-Compatible credential in **Settings → API Keys** with the STT base URL
3. Add model with provider `openai_compatible`
---

View file

@ -19,6 +19,12 @@ Run text-to-speech locally for free, private podcast generation using OpenAI-com
[Speaches](https://github.com/speaches-ai/speaches) is an open-source, OpenAI-compatible TTS server.
> **💡 Ready-made Docker Compose files available:**
> - **[docker-compose-speaches.yml](../../examples/docker-compose-speaches.yml)** - Speaches + Open Notebook
> - **[docker-compose-full-local.yml](../../examples/docker-compose-full-local.yml)** - Speaches + Ollama (100% local setup)
>
> These include complete setup instructions and configuration examples. Just copy and run!
### Step 1: Create Docker Compose File
Create a folder and add `docker-compose.yml`:
@ -68,15 +74,21 @@ Play `test.mp3` to verify.
### Step 4: Configure Open Notebook
**Docker deployment:**
**Via Settings UI (Recommended):**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **OpenAI-Compatible**
3. Enter base URL for TTS: `http://host.docker.internal:8969/v1` (Docker) or `http://localhost:8969/v1` (local)
4. Click **Save**, then **Test Connection**
**Legacy (Deprecated) — Environment variables:**
```yaml
# In your Open Notebook docker-compose.yml
environment:
- OPENAI_COMPATIBLE_BASE_URL_TTS=http://host.docker.internal:8969/v1
```
**Local development:**
```bash
# Local development
export OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1
```
@ -163,31 +175,23 @@ volumes:
## Docker Networking
When configuring your OpenAI-Compatible credential in **Settings → API Keys**, use the appropriate TTS base URL for your setup:
### Open Notebook in Docker (macOS/Windows)
```bash
OPENAI_COMPATIBLE_BASE_URL_TTS=http://host.docker.internal:8969/v1
```
**TTS Base URL:** `http://host.docker.internal:8969/v1`
### Open Notebook in Docker (Linux)
```bash
# Option 1: Docker bridge IP
OPENAI_COMPATIBLE_BASE_URL_TTS=http://172.17.0.1:8969/v1
**TTS Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:8969/v1`
# Option 2: Host networking
docker run --network host ...
```
**Option 2:** Use host networking mode (`docker run --network host ...`), then use: `http://localhost:8969/v1`
### Remote Server
Run Speaches on a different machine:
```bash
# On server, bind to all interfaces
# Then in Open Notebook:
OPENAI_COMPATIBLE_BASE_URL_TTS=http://server-ip:8969/v1
```
**TTS Base URL:** `http://server-ip:8969/v1` (replace with your server's IP)
---
@ -327,7 +331,7 @@ docker stats speaches
Any OpenAI-compatible TTS server works. The key is:
1. Server implements `/v1/audio/speech` endpoint
2. Set `OPENAI_COMPATIBLE_BASE_URL_TTS` to server URL
2. Add an OpenAI-Compatible credential in **Settings → API Keys** with the TTS base URL
3. Add model with provider `openai_compatible`
---

View file

@ -38,41 +38,40 @@ ollama pull mxbai-embed-large # Best embedding model for Ollama
### 3. Configure Open Notebook
**For local installation:**
```bash
export OLLAMA_API_BASE=http://localhost:11434
```
**Via Settings UI (Recommended):**
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **Ollama**
3. Enter the base URL (see [Network Configuration](#network-configuration-guide) below for correct URL)
4. Click **Save**, then **Test Connection**
5. Click **Discover Models** → **Register Models**
**For Docker installation:**
**Legacy (Deprecated) — Environment variables:**
```bash
# For local installation:
export OLLAMA_API_BASE=http://localhost:11434
# For Docker installation:
export OLLAMA_API_BASE=http://host.docker.internal:11434
```
> **Note**: The `OLLAMA_API_BASE` environment variable is deprecated. Configure Ollama via Settings → API Keys instead.
## Network Configuration Guide
The `OLLAMA_API_BASE` environment variable tells Open Notebook where to find your Ollama server. The correct value depends on your deployment scenario:
When adding an Ollama credential in **Settings → API Keys**, you need to enter the correct base URL. The correct URL depends on your deployment scenario:
### Scenario 1: Local Installation (Same Machine)
When both Open Notebook and Ollama run directly on your machine:
```bash
export OLLAMA_API_BASE=http://localhost:11434
# or
export OLLAMA_API_BASE=http://127.0.0.1:11434
```
**Base URL to enter in Settings → API Keys:** `http://localhost:11434`
**Use `localhost` vs `127.0.0.1`:**
- **localhost**: Recommended, works with most configurations
- **127.0.0.1**: Use if you have DNS resolution issues with localhost
Alternative: `http://127.0.0.1:11434` (use if you have DNS resolution issues with localhost)
### Scenario 2: Open Notebook in Docker, Ollama on Host
When Open Notebook runs in Docker but Ollama runs on your host machine:
```bash
export OLLAMA_API_BASE=http://host.docker.internal:11434
```
**Base URL to enter in Settings → API Keys:** `http://host.docker.internal:11434`
**⚠️ CRITICAL: Ollama must accept external connections:**
```bash
@ -92,8 +91,6 @@ services:
# ... other settings ...
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- OLLAMA_API_BASE=http://host.docker.internal:11434
```
Without this, you'll get connection errors like:
@ -115,9 +112,7 @@ httpcore.ConnectError: [Errno -2] Name or service not known
When both Open Notebook and Ollama run in the same Docker Compose stack:
```bash
export OLLAMA_API_BASE=http://ollama:11434
```
**Base URL to enter in Settings → API Keys:** `http://ollama:11434`
**Docker Compose Example:**
@ -131,7 +126,7 @@ services:
- "8502:8502"
- "5055:5055"
environment:
- OLLAMA_API_BASE=http://ollama:11434
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
volumes:
- ./notebook_data:/app/data
- ./surreal_data:/mydata
@ -139,7 +134,7 @@ services:
- ollama
ollama:
image: ollama/ollama:v1-latest
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
@ -161,10 +156,7 @@ volumes:
When Ollama runs on a different machine in your network:
```bash
export OLLAMA_API_BASE=http://192.168.1.100:11434
# Replace 192.168.1.100 with your Ollama server's IP address
```
**Base URL to enter in Settings → API Keys:** `http://192.168.1.100:11434` (replace with your Ollama server's IP)
**Security Note:** Only use this in trusted networks. Ollama doesn't have built-in authentication.
@ -175,11 +167,10 @@ If you've configured Ollama to use a different port:
```bash
# Start Ollama on custom port
OLLAMA_HOST=0.0.0.0:8080 ollama serve
# Configure Open Notebook
export OLLAMA_API_BASE=http://localhost:8080
```
**Base URL to enter in Settings → API Keys:** `http://localhost:8080`
## Model Recommendations
### Language Models
@ -301,10 +292,8 @@ qwen3:32b 030ee887880f 20 GB 9 days ago
curl http://localhost:11434/api/tags
```
**Verify environment variable:**
```bash
echo $OLLAMA_API_BASE
```
**Verify credential is configured:**
Check **Settings → API Keys** for an Ollama credential with the correct base URL.
**⚠️ IMPORTANT: Enable external connections (most common fix):**
```bash
@ -382,8 +371,8 @@ netstat -tulpn | grep 11434
**Use custom port:**
```bash
OLLAMA_HOST=0.0.0.0:8080 ollama serve
export OLLAMA_API_BASE=http://localhost:8080
```
Then update the base URL in **Settings → API Keys** to `http://localhost:8080`
**6. "Failed to send message" in Chat**
@ -442,18 +431,19 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- OLLAMA_API_BASE=http://host.docker.internal:11434
# ... rest of your config
```
Then in **Settings → API Keys**, use base URL: `http://host.docker.internal:11434`
This maps `host.docker.internal` to your host machine's IP. macOS/Windows Docker Desktop does this automatically, but Linux requires explicit configuration.
**2. Host networking on Linux (alternative):**
```bash
# Use host networking if host.docker.internal doesn't work
docker run --network host lfnovo/open_notebook:v1-latest-single
export OLLAMA_API_BASE=http://localhost:11434
```
Then in **Settings → API Keys**, use base URL: `http://localhost:11434`
**3. Custom bridge network:**
```yaml
@ -467,14 +457,14 @@ services:
networks:
- ollama_network
environment:
- OLLAMA_API_BASE=http://ollama:11434
ollama:
networks:
- ollama_network
```
**3. Firewall issues:**
Then in **Settings → API Keys**, use base URL: `http://ollama:11434`
**4. Firewall issues:**
```bash
# Allow Ollama port through firewall
sudo ufw allow 11434
@ -550,8 +540,8 @@ export OLLAMA_MAX_QUEUE=512 # Request queue size
export OLLAMA_NUM_PARALLEL=4 # Parallel request handling
export OLLAMA_FLASH_ATTENTION=1 # Enable flash attention (if supported)
# Open Notebook configuration
export OLLAMA_API_BASE=http://localhost:11434
# Open Notebook configuration (configure via Settings → API Keys instead)
# OLLAMA_API_BASE=http://localhost:11434 # Deprecated — use Settings UI
```
### SSL Configuration (Self-Signed Certificates)
@ -583,8 +573,8 @@ services:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
environment:
- OLLAMA_API_BASE=https://ollama.local:11434
# Option 1: Custom CA bundle
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Option 1: Custom CA bundle (if Ollama uses self-signed SSL)
- ESPERANTO_SSL_CA_BUNDLE=/certs/ca-bundle.pem
# Option 2: Disable verification (dev only)
# - ESPERANTO_SSL_VERIFY=false

View file

@ -40,12 +40,18 @@ Open Notebook can connect to any server using this format.
3. Download a model (e.g., Llama 3)
4. Start the local server (default: port 1234)
### Step 2: Configure Environment
### Step 2: Configure in Settings UI (Recommended)
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **OpenAI-Compatible**
3. Enter base URL: `http://host.docker.internal:1234/v1` (Docker) or `http://localhost:1234/v1` (local)
4. API key: `lm-studio` (placeholder, LM Studio doesn't require one)
5. Click **Save**, then **Test Connection**
**Legacy (Deprecated) — Environment variables:**
```bash
# For language models
export OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
export OPENAI_COMPATIBLE_API_KEY=not-needed # LM Studio doesn't require key
export OPENAI_COMPATIBLE_API_KEY=not-needed
```
### Step 3: Add Model in Open Notebook
@ -60,7 +66,19 @@ export OPENAI_COMPATIBLE_API_KEY=not-needed # LM Studio doesn't require key
---
## Environment Variables
## Configuration via Settings UI
The recommended way to configure OpenAI-compatible providers is through the Settings UI:
1. Go to **Settings** → **API Keys**
2. Click **Add Credential** → Select **OpenAI-Compatible**
3. Enter your base URL and API key (if needed)
4. Optionally configure per-service URLs for LLM, Embedding, TTS, and STT
5. Click **Save**, then **Test Connection**
## Legacy: Environment Variables (Deprecated)
> **Deprecated**: These environment variables are deprecated. Use the Settings UI instead.
### Language Models (Chat)
@ -73,7 +91,7 @@ OPENAI_COMPATIBLE_API_KEY=optional-api-key
```bash
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:1234/v1
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=optional-api-key
OPENAI_COMPATIBLE_API_KEY_EMBEDDING=optional-api-key
```
### Text-to-Speech
@ -94,23 +112,18 @@ OPENAI_COMPATIBLE_API_KEY_STT=optional-api-key
## Docker Networking
When Open Notebook runs in Docker and your compatible server runs on the host:
When Open Notebook runs in Docker and your compatible server runs on the host, use the appropriate base URL when adding your credential in **Settings → API Keys**:
### macOS / Windows
```bash
OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:1234/v1
```
**Base URL:** `http://host.docker.internal:1234/v1`
### Linux
```bash
# Option 1: Docker bridge IP
OPENAI_COMPATIBLE_BASE_URL=http://172.17.0.1:1234/v1
**Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:1234/v1`
# Option 2: Host networking mode
docker run --network host ...
```
**Option 2:** Use host networking mode: `docker run --network host ...`
Then use base URL: `http://localhost:1234/v1`
### Same Docker Network
@ -119,8 +132,6 @@ docker run --network host ...
services:
open-notebook:
# ...
environment:
- OPENAI_COMPATIBLE_BASE_URL=http://lm-studio:1234/v1
lm-studio:
# your LM Studio container
@ -128,6 +139,8 @@ services:
- "1234:1234"
```
**Base URL in Settings → API Keys:** `http://lm-studio:1234/v1`
---
## Text Generation WebUI Setup
@ -140,9 +153,7 @@ python server.py --api --listen
### Configure Open Notebook
```bash
OPENAI_COMPATIBLE_BASE_URL=http://localhost:5000/v1
```
In **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://localhost:5000/v1`
### Docker Compose Example
@ -160,12 +171,12 @@ services:
open-notebook:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
environment:
- OPENAI_COMPATIBLE_BASE_URL=http://text-gen:5000/v1
depends_on:
- text-gen
```
Then in **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://text-gen:5000/v1`
---
## vLLM Setup
@ -180,9 +191,7 @@ python -m vllm.entrypoints.openai.api_server \
### Configure Open Notebook
```bash
OPENAI_COMPATIBLE_BASE_URL=http://localhost:8000/v1
```
In **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://localhost:8000/v1`
### Docker Compose with GPU
@ -206,12 +215,12 @@ services:
open-notebook:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
environment:
- OPENAI_COMPATIBLE_BASE_URL=http://vllm:8000/v1
depends_on:
- vllm
```
Then in **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://vllm:8000/v1`
---
## Adding Models in Open Notebook
@ -306,8 +315,8 @@ Problem: 401 or authentication failed
Solutions:
1. Check if server requires API key
2. Set OPENAI_COMPATIBLE_API_KEY
3. Some servers need any non-empty key
2. Set the API key in your credential (Settings → API Keys)
3. Some servers need any non-empty key (use a placeholder like "not-needed")
```
### Timeout Errors
@ -326,20 +335,14 @@ Solutions:
## Multiple Compatible Endpoints
You can use different compatible servers for different purposes:
You can use different compatible servers for different purposes. When adding an **OpenAI-Compatible** credential in **Settings → API Keys**, you can configure per-service URLs:
```bash
# Chat model from LM Studio
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
- **LLM URL**: e.g., `http://localhost:1234/v1` (LM Studio)
- **Embedding URL**: e.g., `http://localhost:8080/v1` (different server)
- **TTS URL**: e.g., `http://localhost:8969/v1` (Speaches)
- **STT URL**: e.g., `http://localhost:9000/v1` (Speaches)
# Embeddings from different server
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8080/v1
# TTS from Speaches
OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1
```
Add each as a separate model in Open Notebook settings.
Alternatively, add each as a separate credential with its own base URL.
---

View file

@ -167,7 +167,7 @@ services:
container_name: open-notebook
environment:
- API_URL=https://notebook.example.com
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}
- OPEN_NOTEBOOK_PASSWORD=${OPEN_NOTEBOOK_PASSWORD}
volumes:
- ./notebook_data:/app/data
@ -340,7 +340,7 @@ services:
pull_policy: always
environment:
- API_URL=https://api.notebook.example.com
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}
# Don't expose ports (nginx handles routing)
```
@ -409,7 +409,7 @@ services:
image: lfnovo/open_notebook_api:v1-latest
pull_policy: always
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}
ports:
- "5055:5055"
depends_on:
@ -616,7 +616,7 @@ You'll see which API URL is being used
curl https://your-domain.com/api/config
# Expected output:
{"openai_api_key_set":true,"anthropic_api_key_set":false,...}
{"status":"ok","credentials_configured":true,...}
```
**Step 3: Check Docker logs**

View file

@ -4,6 +4,65 @@ Protect your Open Notebook deployment with password authentication and productio
---
## API Key Encryption
Open Notebook encrypts API keys stored in the database using Fernet symmetric encryption (AES-128-CBC with HMAC-SHA256).
### Configuration Methods
| Method | Documentation |
|--------|---------------|
| **Settings UI** | [API Configuration Guide](../3-USER-GUIDE/api-configuration.md) |
| **Environment Variables** | This page (below) |
### Setup
Set the encryption key to any secret string:
```bash
# .env or docker.env
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase
```
Any string works — it will be securely derived via SHA-256 internally. Use a strong passphrase for production deployments.
### Default Credentials
| Setting | Default | Security Level |
|---------|---------|----------------|
| Password | `open-notebook-change-me` | Development only |
| Encryption Key | **None** (must be configured) | Required for API key storage |
**The encryption key has no default.** You must set `OPEN_NOTEBOOK_ENCRYPTION_KEY` before using the API key configuration feature. Without it, encrypting/decrypting API keys will fail.
### Docker Secrets Support
Both settings support Docker secrets via `_FILE` suffix:
```yaml
environment:
- OPEN_NOTEBOOK_PASSWORD_FILE=/run/secrets/app_password
- OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key
```
### Security Notes
| Scenario | Behavior |
|----------|----------|
| Key configured | API keys encrypted with your key |
| No key configured | Encryption/decryption will fail (key is required) |
| Key changed | Old encrypted keys become unreadable |
| Legacy data | Unencrypted keys still work (graceful fallback) |
### Key Management
- **Keep secret**: Never commit the encryption key to version control
- **Backup securely**: Store the key separately from database backups
- **No rotation yet**: Changing the key requires re-saving all API keys
- **Per-deployment**: Each instance should have its own encryption key
---
## When to Use Password Protection
### Use it for:
@ -29,7 +88,7 @@ services:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
environment:
- OPENAI_API_KEY=sk-...
- OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-encryption-key
- OPEN_NOTEBOOK_PASSWORD=your_secure_password
# ... rest of config
```
@ -38,10 +97,12 @@ Or using environment file:
```bash
# docker.env
OPENAI_API_KEY=sk-...
OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-encryption-key
OPEN_NOTEBOOK_PASSWORD=your_secure_password
```
> **Important**: The encryption key is **required** for credential storage. Without it, you cannot save AI provider credentials via the Settings UI. If you change or lose the encryption key, all stored credentials become unreadable.
### Development Setup
```bash

View file

@ -63,44 +63,36 @@ ollama list
**Symptom:** Settings → Models shows empty, or "No models configured"
**Cause:** Missing or invalid API key
**Cause:** No credential configured, or credential has invalid API key
**Solutions:**
### Solution 1: Add API Key
```bash
# Check .env has your API key:
cat .env | grep -i "OPENAI\|ANTHROPIC\|GOOGLE"
# Should see something like:
# OPENAI_API_KEY=sk-proj-...
# If missing, add it:
OPENAI_API_KEY=sk-proj-your-key-here
# Save and restart:
docker compose restart api
# Wait 10 seconds, then refresh browser
### Solution 1: Add Credential via Settings UI
```
1. Go to Settings → API Keys
2. Click "Add Credential"
3. Select your provider (e.g., OpenAI, Anthropic, Google)
4. Enter your API key
5. Click Save, then Test Connection
6. Click Discover Models → Register Models
7. Go to Settings → Models to verify
```
### Solution 2: Check Key is Valid
```bash
# Test API key directly:
curl https://api.openai.com/v1/models \
-H "Authorization: Bearer sk-proj-..."
# Should return list of models
# If error: key is invalid
```
1. Go to Settings → API Keys
2. Click "Test Connection" on your credential
3. If it shows "Invalid API key":
- Get a fresh key from the provider's website
- Delete the credential and create a new one
```
### Solution 3: Switch Provider
```bash
# Try a different provider:
# Remove: OPENAI_API_KEY
# Add: ANTHROPIC_API_KEY=sk-ant-...
# Restart and check Settings → Models
```
1. Go to Settings → API Keys
2. Add a credential for a different provider
3. Test Connection → Discover Models → Register Models
4. Go to Settings → Models to select the new provider's models
```
---
@ -109,47 +101,42 @@ curl https://api.openai.com/v1/models \
**Symptom:** Error when trying to chat: "Invalid API key"
**Cause:** API key wrong, expired, or revoked
**Cause:** Credential has wrong, expired, or revoked API key
**Solutions:**
### Step 1: Verify Key Format
```bash
# OpenAI: Should start with sk-proj-
# Anthropic: Should start with sk-ant-
# Google: Should be AIzaSy...
# Check in .env:
cat .env | grep OPENAI_API_KEY
### Step 1: Test Your Credential
```
1. Go to Settings → API Keys
2. Click "Test Connection" on your credential
3. If it fails, proceed to Step 2
```
### Step 2: Get Fresh Key
```bash
# Go to provider's dashboard:
# - OpenAI: https://platform.openai.com/api-keys
# - Anthropic: https://console.anthropic.com/
# - Google: https://aistudio.google.com/app/apikey
```
Go to provider's dashboard:
- OpenAI: https://platform.openai.com/api-keys (starts with sk-proj-)
- Anthropic: https://console.anthropic.com/ (starts with sk-ant-)
- Google: https://aistudio.google.com/app/apikey (starts with AIzaSy)
# Generate new key
# Copy exactly (no extra spaces)
Generate new key and copy exactly (no extra spaces)
```
### Step 3: Update .env
```bash
# Edit .env:
OPENAI_API_KEY=sk-proj-new-key-here
# No quotes needed, no spaces
# Save and restart:
docker compose restart api
### Step 3: Update Credential
```
1. Go to Settings → API Keys
2. Delete the old credential
3. Click "Add Credential" → select provider
4. Paste the new key
5. Click Save, then Test Connection
6. Re-discover and register models if needed
```
### Step 4: Verify in UI
```
1. Open Open Notebook
2. Go to Settings → Models
3. Select your provider
4. Should show available models
1. Go to Settings → Models
2. Verify models are available
3. Try a test chat
```
---

View file

@ -428,9 +428,9 @@ ESPERANTO_SSL_VERIFY=false
### Solution 3: Use HTTP Instead
If services are on a trusted local network, HTTP is acceptable:
```bash
# Change endpoint from https:// to http://
OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
```
Change the base URL in your credential (Settings → API Keys) from https:// to http://
Example: http://localhost:1234/v1
```
> **Security Note:** Disabling SSL verification exposes you to man-in-the-middle attacks. Always prefer custom CA bundle or HTTP on trusted networks.

View file

@ -39,31 +39,23 @@ docker compose restart
**Symptom:** Settings → Models shows "No models available"
**Cause:** API key missing, wrong, or not set
**Cause:** No credential configured, or credential has invalid API key
**Solution (1 minute):**
```bash
# Step 1: Check your .env has API key
cat .env | grep OPENAI_API_KEY
# Step 2: Verify it's correct (from https://platform.openai.com/api-keys)
# Should look like: sk-proj-xxx...
# Step 3: Restart services
docker compose restart api
# Step 4: Wait 10 seconds, then refresh browser
# Go to Settings → Models
# If still no models:
# Check logs for error
docker compose logs api | grep -i "api key\|error"
```
1. Go to Settings → API Keys
2. If no credential exists, click "Add Credential" and add one
3. If a credential exists, click "Test Connection"
4. If test fails, delete and re-create with correct key
5. After test passes, click "Discover Models" → "Register Models"
6. Go to Settings → Models to verify models appear
```
**If still broken:**
- Make sure key has no extra spaces
- Generate a fresh key from provider dashboard
- Check that `OPEN_NOTEBOOK_ENCRYPTION_KEY` is set in docker-compose.yml
- See [AI & Chat Issues](ai-chat-issues.md)
---

View file

@ -75,6 +75,16 @@ Instead of memorizing endpoints, use the interactive API docs:
- `GET /models/defaults` - Current defaults
- `POST /models/config` - Set defaults
**Credentials** - Manage AI provider credentials
- `GET/POST /credentials` - List and create credentials
- `GET/PUT/DELETE /credentials/{id}` - CRUD operations
- `POST /credentials/{id}/test` - Test connection
- `POST /credentials/{id}/discover` - Discover models from provider
- `POST /credentials/{id}/register-models` - Register discovered models
- `GET /credentials/status` - Provider status overview
- `GET /credentials/env-status` - Environment variable status
- `POST /credentials/migrate-from-env` - Migrate env vars to credentials
**Health & Status**
- `GET /health` - Health check
- `GET /commands/{id}` - Track async operations

View file

@ -53,11 +53,8 @@ SURREAL_PASSWORD=password
SURREAL_NAMESPACE=open_notebook
SURREAL_DATABASE=development
# AI Providers (add your API keys)
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_API_KEY=AI...
GROQ_API_KEY=gsk-...
# Credential encryption (required for storing API keys)
OPEN_NOTEBOOK_ENCRYPTION_KEY=my-dev-secret-key
# Application
APP_PASSWORD= # Optional password protection
@ -65,10 +62,17 @@ DEBUG=true
LOG_LEVEL=DEBUG
```
### AI Provider Keys
### AI Provider Configuration
You'll need at least one AI provider. Popular options:
After starting the API and frontend, configure your AI provider via the Settings UI:
1. Open **http://localhost:3000****Settings** → **API Keys**
2. Click **Add Credential** → Select your provider
3. Enter your API key (get from provider dashboard)
4. Click **Save**, then **Test Connection**
5. Click **Discover Models** → **Register Models**
Popular providers:
- **OpenAI** - https://platform.openai.com/api-keys
- **Anthropic (Claude)** - https://console.anthropic.com/
- **Google** - https://ai.google.dev/
@ -77,6 +81,8 @@ You'll need at least one AI provider. Popular options:
For local development, you can also use:
- **Ollama** - Run locally without API keys (see "Local Ollama" below)
> **Note:** API key environment variables (e.g., `OPENAI_API_KEY`) are deprecated. Use the Settings UI to manage credentials instead.
## Step 4: Start SurrealDB
### Option A: Using Docker (Recommended)
@ -363,12 +369,13 @@ For testing with local AI models:
# Pull a model (e.g., Mistral 7B)
ollama pull mistral
# Add to .env
OLLAMA_BASE_URL=http://localhost:11434
```
Then in your code, you can use Ollama through the Esperanto library.
Then configure via the Settings UI:
1. Go to **Settings****API Keys****Add Credential** → **Ollama**
2. Enter base URL: `http://localhost:11434`
3. Click **Save**, then **Test Connection**
4. Click **Discover Models** → **Register Models**
## Optional: Docker Development Environment

96
docs/SECURITY_REVIEW.md Normal file
View file

@ -0,0 +1,96 @@
# Security Review - API Configuration UI
## Date: 2026-01-27 (Updated: 2026-01-28)
## Reviewer: Security Audit
---
## Summary
Security review of the API key management implementation for Open Notebook. The implementation uses a database-first approach with environment variable fallback.
---
## Encryption
| Item | Status | Notes |
|------|--------|-------|
| Fernet encryption implemented | PASS | `open_notebook/utils/encryption.py` uses AES-128-CBC + HMAC-SHA256 |
| Keys encrypted before DB storage | PASS | `encrypt_value()` applied on save |
| Keys decrypted only when needed | PASS | `decrypt_value()` called when reading |
| Encryption key required | PASS | No default key; ValueError if not configured |
| Docker secrets support | PASS | `_FILE` suffix pattern supported |
| Documented in .env.example | PASS | Encryption key documented |
---
## API Security
| Item | Status | Notes |
|------|--------|-------|
| Test endpoint implemented | PASS | `connection_tester.py` validates keys |
| Test doesn't expose keys | PASS | Only returns success/failure |
| Error messages don't leak info | PASS | Generic error messages |
| URL validation for SSRF | PASS | Blocks private IPs (except Ollama) |
| Rate limiting | NOT IMPL | Future enhancement |
---
## Frontend Security
| Item | Status | Notes |
|------|--------|-------|
| No keys in localStorage | PASS | Keys only in React state during entry |
| Keys masked in UI | PASS | Shows `************` placeholder |
| No keys in console.log | PASS | No logging of sensitive data |
| autocomplete attributes | PARTIAL | Some forms missing autocomplete="off" |
---
## Authentication
| Item | Status | Notes |
|------|--------|-------|
| Password protection | PASS | Bearer token authentication |
| Default password | PASS | "open-notebook-change-me" when not set |
| Docker secrets support | PASS | `_FILE` suffix for password |
| Security warnings | PASS | Logged when using defaults |
---
## Files Reviewed
| Component | Path | Status |
|-----------|------|--------|
| Encryption | `open_notebook/utils/encryption.py` | PASS |
| Credential model | `open_notebook/domain/credential.py` | PASS |
| Credentials router | `api/routers/credentials.py` | PASS |
| Key provider | `open_notebook/ai/key_provider.py` | PASS |
| Connection tester | `open_notebook/ai/connection_tester.py` | PASS |
| Auth middleware | `api/auth.py` | PASS |
| Frontend forms | `frontend/src/components/settings/*.tsx` | PASS |
| Environment example | `.env.example` | PASS |
---
## Remaining Recommendations
### Future Improvements
1. **Rate limiting** - Add rate limiting on `/credentials/*` endpoints
2. **Autocomplete attributes** - Add `autocomplete="new-password"` to all password inputs
3. **Show last 4 characters** - Display `********xxxx` format for key identification
4. **Audit logging** - Log API key changes with timestamps
---
## Conclusion
The API Configuration UI implementation meets security requirements:
- API keys encrypted at rest using Fernet (key must be explicitly configured)
- Keys never returned to frontend
- URL validation prevents SSRF attacks
- Docker secrets supported for production deployments
**Review Status: PASS**

163
examples/README.md Normal file
View file

@ -0,0 +1,163 @@
# Docker Compose Examples
This folder contains different `docker-compose.yml` configurations for various use cases.
## 📋 Available Examples
### `docker-compose-full-local.yml` - 100% Local AI (No Cloud APIs) 🌟
**Use this if:** You want complete privacy with zero external API dependencies
**Features:**
- **Ollama**: Local LLM and embeddings (mistral, llama, etc.)
- **Speaches**: Local TTS (text-to-speech) and STT (speech-to-text)
- Everything runs on your machine - nothing sent to cloud
- Perfect for privacy, offline work, or air-gapped environments
**Setup:**
1. Copy to your project folder as `docker-compose.yml`
2. Run: `docker compose up -d`
3. Download models (see file comments for commands)
4. Configure all providers in UI (detailed instructions in file)
**Requirements:**
- Minimum: 8GB RAM, 20GB disk, 4 CPU cores
- Recommended: 16GB+ RAM, NVIDIA GPU (8GB+ VRAM), 50GB disk
**Documentation:**
- [Local TTS Guide](../docs/5-CONFIGURATION/local-tts.md)
- [Local STT Guide](../docs/5-CONFIGURATION/local-stt.md)
---
### `docker-compose-speaches.yml` - Local Speech Processing
**Use this if:** You want free TTS/STT but use cloud LLMs
**Features:**
- **Speaches**: Local text-to-speech and speech-to-text
- Use with cloud LLM providers (OpenAI, Anthropic, etc.)
- Great for podcast generation without TTS API costs
- Private audio processing
**Setup:**
1. Copy to your project folder as `docker-compose.yml`
2. Run: `docker compose up -d`
3. Download speech models (see file for commands)
4. Configure cloud LLM + local Speaches in UI
**Documentation:**
- [Local TTS Guide](../docs/5-CONFIGURATION/local-tts.md)
- [Local STT Guide](../docs/5-CONFIGURATION/local-stt.md)
---
### `docker-compose-ollama.yml` - Free Local AI with Ollama
**Use this if:** You want to run AI models locally without API costs
**Features:**
- Includes Ollama service for local AI models
- No external API keys needed (for LLM and embeddings)
- Full privacy - everything runs on your machine
- Great for testing or privacy-focused deployments
**Setup:**
1. Copy to your project folder as `docker-compose.yml`
2. Run: `docker compose up -d`
3. Pull a model: `docker exec open_notebook-ollama-1 ollama pull mistral`
4. Configure in UI: Settings → API Keys → Add Ollama (URL: `http://ollama:11434`)
**Recommended models:**
- **LLM**: `mistral`, `llama3.1`, `qwen2.5`
- **Embeddings**: `nomic-embed-text`, `mxbai-embed-large`
---
### `docker-compose-single.yml` - Single Container (Deprecated)
**Use this if:** You need all services in one container (not recommended)
**⚠️ Deprecated:** We recommend using the standard multi-container setup (`docker-compose.yml` in root) for better reliability and easier troubleshooting.
**Features:**
- Single container includes SurrealDB, API, and Frontend
- Simpler for very constrained environments
- Less flexible for debugging and scaling
---
### `docker-compose-dev.yml` - Development Setup
**Use this if:** You're contributing to Open Notebook or developing custom features
**Features:**
- Hot-reload for code changes
- Separate backend and frontend services
- Build from source instead of using pre-built images
- Includes development tools and debugging
**Prerequisites:**
- Python 3.11+
- Node.js 18+
- uv (Python package manager)
**Setup:**
See [Development Guide](../docs/7-DEVELOPMENT/index.md)
---
## 🔄 How to Use These Examples
1. **Choose** the example that fits your use case
2. **Copy** the file to your project folder:
```bash
cp examples/docker-compose-ollama.yml docker-compose.yml
```
3. **Edit** the `OPEN_NOTEBOOK_ENCRYPTION_KEY` value
4. **Run** the services:
```bash
docker compose up -d
```
---
## 💡 Need a Custom Setup?
You can combine features from multiple examples. Common customizations:
### Add Ollama to Standard Setup
Add this to the main `docker-compose.yml`:
```yaml
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_models:/root/.ollama
restart: always
volumes:
ollama_models:
```
### Add Reverse Proxy
See [Reverse Proxy Guide](../docs/5-CONFIGURATION/reverse-proxy.md)
### Add Basic Auth
Add to `open_notebook` service environment:
```yaml
- BASIC_AUTH_USERNAME=admin
- BASIC_AUTH_PASSWORD=your-secure-password
```
---
## 📚 Documentation
- [Installation Guide](../docs/1-INSTALLATION/index.md)
- [Configuration Reference](../docs/5-CONFIGURATION/environment-reference.md)
- [Troubleshooting](../docs/6-TROUBLESHOOTING/index.md)
---
## 🆘 Need Help?
- **Discord**: [Join our community](https://discord.gg/37XJPXfz2w)
- **Issues**: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)

View file

@ -0,0 +1,197 @@
# Docker Compose - 100% Local AI Setup
#
# This is the complete privacy-focused setup with NO external APIs needed:
# - Ollama: Local LLM and embeddings (mistral, llama, nomic-embed, etc.)
# - Speaches: Local TTS (text-to-speech) and STT (speech-to-text)
# - Open Notebook: Your research assistant
# - SurrealDB: Local database
#
# Perfect for:
# - Complete privacy (nothing leaves your machine)
# - Offline work
# - No API costs
# - Air-gapped environments
# - Testing and development
#
# Usage:
# 1. Copy this file to your project folder as docker-compose.yml
# 2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below
# 3. Run: docker compose up -d
# 4. Pull models (see instructions below)
# 5. Configure providers in UI
#
# Full documentation:
# - Ollama setup: https://github.com/lfnovo/open-notebook/blob/main/examples/README.md
# - TTS setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-tts.md
# - STT setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-stt.md
services:
surrealdb:
image: surrealdb/surrealdb:v2
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
user: root
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
environment:
- SURREAL_EXPERIMENTAL_GRAPHQL=true
restart: always
pull_policy: always
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_models:/root/.ollama
restart: always
pull_policy: always
# For GPU acceleration (NVIDIA), add:
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: 1
# capabilities: [gpu]
speaches:
image: ghcr.io/speaches-ai/speaches:latest-cpu
container_name: speaches
ports:
- "8969:8000"
volumes:
- hf-hub-cache:/home/ubuntu/.cache/huggingface/hub
restart: unless-stopped
# For GPU acceleration, use: ghcr.io/speaches-ai/speaches:latest-cuda
# and add GPU device mapping (see docs)
open_notebook:
image: lfnovo/open_notebook:v1-latest
ports:
- "8502:8502"
- "5055:5055"
environment:
# REQUIRED: Change this to your own secret string
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database connection
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=open_notebook
# Ollama connection (optional, can also configure via UI)
- OLLAMA_BASE_URL=http://ollama:11434
volumes:
- ./notebook_data:/app/data
depends_on:
- surrealdb
- ollama
- speaches
restart: always
pull_policy: always
volumes:
ollama_models:
hf-hub-cache:
# ==========================================
# AFTER STARTING: Download Models
# ==========================================
#
# Ollama Models (LLM):
# docker exec open_notebook-ollama-1 ollama pull mistral
# docker exec open_notebook-ollama-1 ollama pull llama3.1
# docker exec open_notebook-ollama-1 ollama pull qwen2.5
#
# Ollama Models (Embeddings):
# docker exec open_notebook-ollama-1 ollama pull nomic-embed-text
# docker exec open_notebook-ollama-1 ollama pull mxbai-embed-large
#
# Speaches (TTS):
# docker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX
#
# Speaches (STT):
# docker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small
#
# ==========================================
# CONFIGURATION IN OPEN NOTEBOOK
# ==========================================
#
# 1. Configure Ollama:
# - Go to Settings → API Keys
# - Add Credential → Select "Ollama"
# - Base URL: http://ollama:11434
# - Save → Test Connection → Discover Models → Register Models
#
# 2. Configure Speaches (TTS/STT):
# - Go to Settings → API Keys
# - Add Credential → Select "OpenAI-Compatible"
# - Name: "Local Speaches"
# - Base URL for TTS: http://host.docker.internal:8969/v1 (macOS/Windows)
# or: http://172.17.0.1:8969/v1 (Linux)
# - Base URL for STT: (same as TTS)
# - Save → Test Connection
#
# 3. Discover Speech Models:
# - In the Speaches credential you just created, click Discover Models
# - Select and register the models you need (e.g. TTS and STT)
# - If models aren't discovered automatically, add them manually:
# * TTS: speaches-ai/Kokoro-82M-v1.0-ONNX
# * STT: Systran/faster-whisper-small
#
# ==========================================
# RECOMMENDED MODELS
# ==========================================
#
# For LLM (choose based on your hardware):
# - Fast: mistral (7B), qwen2.5 (7B)
# - Balanced: llama3.1 (8B)
# - Best quality: qwen2.5 (14B+), llama3.1 (70B) - requires powerful GPU
#
# For Embeddings:
# - nomic-embed-text (recommended, 137M params)
# - mxbai-embed-large (334M params, better quality)
#
# For TTS:
# - speaches-ai/Kokoro-82M-v1.0-ONNX (good quality, fast)
#
# For STT (Whisper):
# - faster-whisper-small (balanced, ~500MB)
# - faster-whisper-base (faster, less accurate)
# - faster-whisper-large-v3 (best quality, slower, ~3GB)
#
# ==========================================
# HARDWARE REQUIREMENTS
# ==========================================
#
# Minimum (CPU only):
# - 8 GB RAM
# - 20 GB disk space
# - 4 CPU cores
#
# Recommended (with GPU):
# - 16+ GB RAM
# - 8+ GB VRAM (NVIDIA GPU)
# - 50 GB disk space
# - 8+ CPU cores
#
# ==========================================
# COST COMPARISON
# ==========================================
#
# Local (this setup):
# - Cost: $0 (after hardware)
# - Privacy: 100% private
# - Speed: Depends on hardware
#
# Cloud (OpenAI + ElevenLabs):
# - LLM: ~$0.01-0.10 per 1K tokens
# - Embeddings: ~$0.0001 per 1K tokens
# - TTS: ~$0.015 per minute
# - STT: ~$0.006 per minute
# - Privacy: Data sent to providers
# - Speed: Usually faster

View file

@ -0,0 +1,63 @@
# Docker Compose with Ollama (Free Local AI)
#
# This setup includes Ollama for running local AI models without API costs.
# Great for privacy-focused deployments or testing without cloud dependencies.
#
# Usage:
# 1. Copy this file to your project folder as docker-compose.yml
# 2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below
# 3. Run: docker compose up -d
# 4. Pull a model: docker exec open_notebook-ollama-1 ollama pull mistral
# 5. Configure Ollama in UI: Settings → API Keys → Add Ollama (URL: http://ollama:11434)
services:
surrealdb:
image: surrealdb/surrealdb:v2
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
user: root
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
environment:
- SURREAL_EXPERIMENTAL_GRAPHQL=true
restart: always
pull_policy: always
ollama:
image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
- ollama_models:/root/.ollama
restart: always
pull_policy: always
open_notebook:
image: lfnovo/open_notebook:v1-latest
ports:
- "8502:8502"
- "5055:5055"
environment:
# REQUIRED: Change this to your own secret string
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database connection
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=open_notebook
# Ollama connection
- OLLAMA_BASE_URL=http://ollama:11434
volumes:
- ./notebook_data:/app/data
depends_on:
- surrealdb
- ollama
restart: always
pull_policy: always
volumes:
ollama_models:

View file

@ -0,0 +1,125 @@
# Docker Compose with Speaches (Local TTS/STT)
#
# This setup includes Speaches for free, private speech processing:
# - Text-to-Speech (TTS): Generate podcast audio locally
# - Speech-to-Text (STT): Transcribe audio/video content locally
#
# Why Speaches?
# - Free: No per-minute/per-character costs
# - Private: Audio never leaves your machine
# - Offline: Works without internet
# - OpenAI-compatible: Drop-in replacement for OpenAI TTS/STT
#
# Usage:
# 1. Copy this file to your project folder as docker-compose.yml
# 2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below
# 3. Run: docker compose up -d
# 4. Download models (see instructions below)
# 5. Configure in UI: Settings → API Keys → Add OpenAI-Compatible
#
# Full documentation:
# - TTS setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-tts.md
# - STT setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-stt.md
services:
surrealdb:
image: surrealdb/surrealdb:v2
command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
user: root
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
environment:
- SURREAL_EXPERIMENTAL_GRAPHQL=true
restart: always
pull_policy: always
speaches:
image: ghcr.io/speaches-ai/speaches:latest-cpu
container_name: speaches
ports:
- "8969:8000"
volumes:
- hf-hub-cache:/home/ubuntu/.cache/huggingface/hub
restart: unless-stopped
# For GPU acceleration, use: ghcr.io/speaches-ai/speaches:latest-cuda
# and add GPU device mapping (see docs/5-CONFIGURATION/local-tts.md)
open_notebook:
image: lfnovo/open_notebook:v1-latest
ports:
- "8502:8502"
- "5055:5055"
environment:
# REQUIRED: Change this to your own secret string
- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database connection
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=open_notebook
volumes:
- ./notebook_data:/app/data
depends_on:
- surrealdb
- speaches
restart: always
pull_policy: always
volumes:
hf-hub-cache:
# ==========================================
# AFTER STARTING: Download Speech Models
# ==========================================
#
# For TTS (Text-to-Speech):
# docker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX
#
# For STT (Speech-to-Text):
# docker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small
#
# ==========================================
# CONFIGURATION IN OPEN NOTEBOOK
# ==========================================
#
# 1. Go to Settings → API Keys
# 2. Click "Add Credential" → Select "OpenAI-Compatible"
# 3. Configure:
# - Name: "Local Speaches"
# - Base URL for TTS: http://host.docker.internal:8969/v1 (macOS/Windows)
# or: http://172.17.0.1:8969/v1 (Linux)
# - Base URL for STT: (same as TTS)
# 4. Click Save → Test Connection
#
# 5. Go to Settings → Models
# 6. Add TTS Model:
# - Provider: openai_compatible
# - Model Name: speaches-ai/Kokoro-82M-v1.0-ONNX
# - Display Name: Local TTS
#
# 7. Add STT Model:
# - Provider: openai_compatible
# - Model Name: Systran/faster-whisper-small
# - Display Name: Local Whisper
#
# ==========================================
# TESTING
# ==========================================
#
# Test TTS:
# curl "http://localhost:8969/v1/audio/speech" -s \
# -H "Content-Type: application/json" \
# --output test.mp3 \
# --data '{"input": "Hello, local TTS works!", "model": "speaches-ai/Kokoro-82M-v1.0-ONNX", "voice": "af_bella"}'
#
# Test STT:
# curl "http://localhost:8969/v1/audio/transcriptions" \
# -F "file=@test.mp3" \
# -F "model=Systran/faster-whisper-small"
#
# Available voices: af_bella, af_sarah, am_adam, am_michael, bf_emma, bm_george
# Available models: See docs/5-CONFIGURATION/local-stt.md for model sizes

View file

@ -29,7 +29,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.83.0",
"@uiw/react-md-editor": "^4.0.8",
"axios": "^1.12.0",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -5336,7 +5336,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
@ -5363,13 +5364,13 @@
}
},
"node_modules/axios": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.0.tgz",
"integrity": "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},
@ -5719,6 +5720,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
@ -5983,6 +5985,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
@ -6900,15 +6903,16 @@
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@ -6934,9 +6938,10 @@
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@ -9471,6 +9476,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
@ -9479,6 +9485,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},

View file

@ -33,7 +33,7 @@
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.83.0",
"@uiw/react-md-editor": "^4.0.8",
"axios": "^1.12.0",
"axios": "^1.13.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View file

@ -1,151 +0,0 @@
'use client'
import { useId, useState } from 'react'
import { useForm } from 'react-hook-form'
import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useCreateModel } from '@/lib/hooks/use-models'
import { Plus } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface AddModelFormProps {
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
providers: ProviderAvailability
}
export function AddModelForm({ modelType, providers }: AddModelFormProps) {
const { t } = useTranslation()
const providerSelectId = useId()
const modelNameInputId = useId()
const [open, setOpen] = useState(false)
const createModel = useCreateModel()
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm<CreateModelRequest>({
defaultValues: {
type: modelType
}
})
// Get available providers that support this model type
const availableProviders = providers.available.filter(provider =>
providers.supported_types[provider]?.includes(modelType)
)
const onSubmit = async (data: CreateModelRequest) => {
await createModel.mutateAsync(data)
reset()
setOpen(false)
}
const getModelTypeName = () => {
return (t.models as Record<string, string>)[modelType] || modelType.replace(/_/g, ' ')
}
const getModelPlaceholder = () => {
switch (modelType) {
case 'language':
return 'e.g., gpt-5-mini, claude, gemini'
case 'embedding':
return 'e.g., text-embedding-3-small'
case 'text_to_speech':
return 'e.g., tts-gpt-4o-mini-tts, tts-1-hd'
case 'speech_to_text':
return 'e.g., whisper-1'
default:
return t.models.enterModelName
}
}
if (availableProviders.length === 0) {
return (
<div className="text-sm text-muted-foreground">
{t.models.noProvidersForType.replace('{type}', getModelTypeName())}
</div>
)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
reset()
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
id={`add-model-${modelType}`}
name={`add-model-${modelType}`}
size="sm"
>
<Plus className="h-4 w-4 mr-2" />
{t.models.addModel}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t.models.addSpecificModel.replace('{type}', getModelTypeName())}
</DialogTitle>
<DialogDescription>
{t.models.addSpecificModelDesc.replace('{type}', getModelTypeName())}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor={providerSelectId}>{t.models.provider}</Label>
<Select
name="provider"
onValueChange={(value) => setValue('provider', value)}
required
>
<SelectTrigger id={providerSelectId}>
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.provider && (
<p className="text-sm text-destructive mt-1">{t.models.providerRequired}</p>
)}
</div>
<div>
<Label htmlFor={modelNameInputId}>{t.models.modelName}</Label>
<Input
id={modelNameInputId}
{...register('name', { required: t.models.modelNameRequired })}
placeholder={getModelPlaceholder()}
autoComplete="off"
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{modelType === 'language' && watch('provider') === 'azure' &&
t.models.azureHint}
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
{t.common.cancel}
</Button>
<Button type="submit" disabled={createModel.isPending}>
{createModel.isPending ? t.models.adding : t.models.addModel}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -1,280 +0,0 @@
'use client'
import { useEffect, useState, useId } from 'react'
import { useForm } from 'react-hook-form'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ModelDefaults, Model } from '@/lib/types/models'
import { useUpdateModelDefaults } from '@/lib/hooks/use-models'
import { AlertCircle, X } from 'lucide-react'
import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
import { useTranslation } from '@/lib/hooks/use-translation'
interface DefaultModelsSectionProps {
models: Model[]
defaults: ModelDefaults
}
export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionProps) {
const { t } = useTranslation()
const updateDefaults = useUpdateModelDefaults()
const { setValue, watch } = useForm<ModelDefaults>({
defaultValues: defaults
})
interface DefaultConfig {
key: keyof ModelDefaults
label: string
description: string
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
required?: boolean
id: string
}
const generatedId = useId()
const defaultConfigs: DefaultConfig[] = [
{
key: 'default_chat_model',
label: t.models.chatModelLabel,
description: t.models.chatModelDesc,
modelType: 'language',
required: true,
id: `${generatedId}-chat`,
},
{
key: 'default_transformation_model',
label: t.models.transformationModelLabel,
description: t.models.transformationModelDesc,
modelType: 'language',
required: true,
id: `${generatedId}-transformation`,
},
{
key: 'default_tools_model',
label: t.models.toolsModelLabel,
description: t.models.toolsModelDesc,
modelType: 'language',
id: `${generatedId}-tools`,
},
{
key: 'large_context_model',
label: t.models.largeContextModelLabel,
description: t.models.largeContextModelDesc,
modelType: 'language',
id: `${generatedId}-large-context`,
},
{
key: 'default_embedding_model',
label: t.models.embeddingModelLabel,
description: t.models.embeddingModelDesc,
modelType: 'embedding',
required: true,
id: `${generatedId}-embedding`,
},
{
key: 'default_text_to_speech_model',
label: t.models.ttsModelLabel,
description: t.models.ttsModelDesc,
modelType: 'text_to_speech',
id: `${generatedId}-tts`,
},
{
key: 'default_speech_to_text_model',
label: t.models.sttModelLabel,
description: t.models.sttModelDesc,
modelType: 'speech_to_text',
id: `${generatedId}-stt`,
},
]
// State for embedding model change dialog
const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)
const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{
key: keyof ModelDefaults
value: string
oldModelId?: string
newModelId?: string
} | null>(null)
// Update form when defaults change
useEffect(() => {
if (defaults) {
Object.entries(defaults).forEach(([key, value]) => {
setValue(key as keyof ModelDefaults, value)
})
}
}, [defaults, setValue])
const handleChange = (key: keyof ModelDefaults, value: string) => {
// Special handling for embedding model changes
if (key === 'default_embedding_model') {
const currentEmbeddingModel = defaults[key]
// Only show dialog if there's an existing embedding model and it's changing
if (currentEmbeddingModel && currentEmbeddingModel !== value) {
setPendingEmbeddingChange({
key,
value,
oldModelId: currentEmbeddingModel,
newModelId: value
})
setShowEmbeddingDialog(true)
return
}
}
// For all other changes or new embedding model assignment
const newDefaults = { [key]: value || null }
updateDefaults.mutate(newDefaults)
}
const handleConfirmEmbeddingChange = () => {
if (pendingEmbeddingChange) {
const newDefaults = {
[pendingEmbeddingChange.key]: pendingEmbeddingChange.value || null
}
updateDefaults.mutate(newDefaults)
setPendingEmbeddingChange(null)
}
}
const handleCancelEmbeddingChange = () => {
setPendingEmbeddingChange(null)
setShowEmbeddingDialog(false)
}
const getModelsForType = (type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text') => {
return models.filter(model => model.type === type)
}
const missingRequired = defaultConfigs
.filter(config => {
if (!config.required) return false
const value = defaults[config.key]
if (!value) return true
// Check if the model still exists
const modelsOfType = models.filter(m => m.type === config.modelType)
return !modelsOfType.some(m => m.id === value)
})
.map(config => config.label)
return (
<Card>
<CardHeader>
<CardTitle>{t.models.defaultAssignments}</CardTitle>
<CardDescription>
{t.models.defaultAssignmentsDesc}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{missingRequired.length > 0 && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 md:grid-cols-2">
{defaultConfigs.map((config) => {
const availableModels = getModelsForType(config.modelType)
const currentValue = watch(config.key) || undefined
// Check if the current value exists in available models
const isValidModel = currentValue && availableModels.some(m => m.id === currentValue)
return (
<div key={config.key} className="space-y-2">
<Label htmlFor={config.id}>
{config.label}
{config.required && <span className="text-destructive ml-1">*</span>}
</Label>
<div className="flex gap-2">
<Select
value={currentValue || ""}
onValueChange={(value) => handleChange(config.key, value)}
>
<SelectTrigger
id={config.id}
className={
config.required && !isValidModel && availableModels.length > 0
? 'border-destructive'
: ''
}
>
<SelectValue placeholder={
config.required && !isValidModel && availableModels.length > 0
? t.models.requiredModelPlaceholder
: t.models.selectModelPlaceholder
} />
</SelectTrigger>
<SelectContent>
{availableModels.sort((a, b) => a.name.localeCompare(b.name)).map((model) => (
<SelectItem key={model.id} value={model.id}>
<div className="flex items-center justify-between w-full">
<span>{model.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{model.provider}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{!config.required && currentValue && (
<Button
variant="ghost"
size="icon"
onClick={() => handleChange(config.key, "")}
className="h-10 w-10"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">{config.description}</p>
</div>
)
})}
</div>
<div className="pt-4 border-t">
<a
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t.models.whichModelToChoose}
</a>
</div>
</CardContent>
{/* Embedding Model Change Dialog */}
<EmbeddingModelChangeDialog
open={showEmbeddingDialog}
onOpenChange={(open) => {
if (!open) {
handleCancelEmbeddingChange()
}
}}
onConfirm={handleConfirmEmbeddingChange}
oldModelName={
pendingEmbeddingChange?.oldModelId
? models.find(m => m.id === pendingEmbeddingChange.oldModelId)?.name
: undefined
}
newModelName={
pendingEmbeddingChange?.newModelId
? models.find(m => m.id === pendingEmbeddingChange.newModelId)?.name
: undefined
}
/>
</Card>
)
}

View file

@ -1,213 +0,0 @@
'use client'
import { Model, ProviderAvailability } from '@/lib/types/models'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { AddModelForm } from './AddModelForm'
import { Bot, Mic, Volume2, Search, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { useDeleteModel } from '@/lib/hooks/use-models'
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
import { useState, useMemo } from 'react'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ModelTypeSectionProps {
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
models: Model[]
providers: ProviderAvailability
isLoading: boolean
}
const COLLAPSED_ITEM_COUNT = 5
export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) {
const { t } = useTranslation()
const [deleteModel, setDeleteModel] = useState<Model | null>(null)
const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
const [isExpanded, setIsExpanded] = useState(false)
const deleteModelMutation = useDeleteModel()
const getTypeInfo = () => {
switch (type) {
case 'language':
return {
title: t.models.language,
description: t.models.languageDesc,
icon: Bot,
iconColor: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/20'
}
case 'embedding':
return {
title: t.models.embedding,
description: t.models.embeddingDesc,
icon: Search,
iconColor: 'text-green-500',
bgColor: 'bg-green-50 dark:bg-green-950/20'
}
case 'text_to_speech':
return {
title: t.models.tts,
description: t.models.ttsDesc,
icon: Volume2,
iconColor: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-950/20'
}
case 'speech_to_text':
return {
title: t.models.stt,
description: t.models.sttDesc,
icon: Mic,
iconColor: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-950/20'
}
}
}
const { title, description, icon: Icon, iconColor, bgColor } = getTypeInfo()
// Filter and sort models
const filteredModels = useMemo(() => {
let filtered = models.filter(model => model.type === type)
// Apply provider filter if selected
if (selectedProvider) {
filtered = filtered.filter(model => model.provider === selectedProvider)
}
// Sort by name alphabetically
return filtered.sort((a, b) => a.name.localeCompare(b.name))
}, [models, type, selectedProvider])
// Get unique providers for this model type
const modelProviders = useMemo(() => {
const typeModels = models.filter(model => model.type === type)
const uniqueProviders = [...new Set(typeModels.map(m => m.provider))]
return uniqueProviders.sort()
}, [models, type])
const handleDelete = () => {
if (deleteModel) {
deleteModelMutation.mutate(deleteModel.id)
setDeleteModel(null)
}
}
return (
<>
<Card className="h-full">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${bgColor}`}>
<Icon className={`h-5 w-5 ${iconColor}`} />
</div>
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</div>
</div>
<AddModelForm modelType={type} providers={providers} />
</div>
</CardHeader>
<CardContent className="pt-0">
{/* Provider filter badges */}
{modelProviders.length > 1 && (
<div className="flex flex-wrap gap-1 mb-3">
<Badge
variant={selectedProvider === null ? "default" : "outline"}
className="cursor-pointer text-xs"
onClick={() => setSelectedProvider(null)}
>
{t.models.all}
</Badge>
{modelProviders.map(provider => (
<Badge
key={provider}
variant={selectedProvider === provider ? "default" : "outline"}
className="cursor-pointer text-xs capitalize"
onClick={() => setSelectedProvider(provider === selectedProvider ? null : provider)}
>
{provider}
{selectedProvider === provider && (
<X className="ml-1 h-2.5 w-2.5" />
)}
</Badge>
))}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-6">
<LoadingSpinner />
</div>
) : filteredModels.length === 0 ? (
<div className="text-center py-6 text-sm text-muted-foreground">
{selectedProvider
? t.models.noProviderModelsConfigured.replace('{provider}', selectedProvider)
: t.models.noModelsConfigured
}
</div>
) : (
<div className="space-y-2">
<div className={`space-y-2 ${!isExpanded && filteredModels.length > COLLAPSED_ITEM_COUNT ? 'max-h-[280px] overflow-hidden relative' : ''}`}>
{filteredModels.slice(0, isExpanded ? undefined : COLLAPSED_ITEM_COUNT).map(model => (
<div key={model.id} className="flex items-center gap-2 group">
<div className="flex-1 flex items-center gap-2 px-3 py-2 rounded-md border bg-muted/30">
<span className="font-medium text-sm">{model.name}</span>
<Badge variant="outline" className="text-xs">
{model.provider}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setDeleteModel(model)}
>
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
</Button>
</div>
))}
{!isExpanded && filteredModels.length > COLLAPSED_ITEM_COUNT && (
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-background to-transparent pointer-events-none" />
)}
</div>
{filteredModels.length > COLLAPSED_ITEM_COUNT && (
<Button
variant="ghost"
size="sm"
onClick={() => setIsExpanded(!isExpanded)}
className="w-full mt-2"
>
{isExpanded ? (
<>
<ChevronUp className="h-4 w-4 mr-2" />
{t.models.seeLess}
</>
) : (
<>
<ChevronDown className="h-4 w-4 mr-2" />
{t.models.showMore.replace('{count}', (filteredModels.length - COLLAPSED_ITEM_COUNT).toString())}
</>
)}
</Button>
)}
</div>
)}
</CardContent>
</Card>
<ConfirmDialog
open={!!deleteModel}
onOpenChange={(open) => !open && setDeleteModel(null)}
title={t.models.deleteModel}
description={t.models.deleteModelDesc.replace('{name}', deleteModel?.name || '')}
confirmText={t.common.delete}
confirmVariant="destructive"
onConfirm={handleDelete}
/>
</>
)
}

View file

@ -1,131 +0,0 @@
'use client'
import { useMemo, useState } from 'react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Check, X } from 'lucide-react'
import { ProviderAvailability } from '@/lib/types/models'
import { useTranslation } from '@/lib/hooks/use-translation'
interface ProviderStatusProps {
providers: ProviderAvailability
}
export function ProviderStatus({ providers }: ProviderStatusProps) {
const { t } = useTranslation()
// Combine all providers, with available ones first
const allProviders = useMemo(
() => [
...providers.available.map((p) => ({ name: p, available: true })),
...providers.unavailable.map((p) => ({ name: p, available: false })),
],
[providers.available, providers.unavailable],
)
const [expanded, setExpanded] = useState(false)
const visibleProviders = useMemo(() => {
if (expanded) {
return allProviders
}
return allProviders.slice(0, 6)
}, [allProviders, expanded])
return (
<Card>
<CardHeader>
<CardTitle>{t.models.aiProviders}</CardTitle>
<CardDescription>
{t.models.providerConfigDesc}
<span className="ml-1">
{t.models.configuredCount
.replace('{count}', providers.available.length.toString())
.replace('{total}', allProviders.length.toString())}
</span>
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-2 sm:grid-cols-2">
{visibleProviders.map((provider) => {
const supportedTypes = providers.supported_types[provider.name] ?? []
return (
<div
key={provider.name}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 transition-colors ${
provider.available ? 'bg-card' : 'bg-muted/40'
}`}
>
<div className={`flex items-center justify-center rounded-full p-1.5 ${
provider.available
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-300'
: 'bg-muted-foreground/10 text-muted-foreground'
}`}>
{provider.available ? (
<Check className="h-3.5 w-3.5" />
) : (
<X className="h-3.5 w-3.5" />
)}
</div>
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
<span
className={`truncate text-sm font-medium capitalize ${
!provider.available ? 'text-muted-foreground' : 'text-foreground'
}`}
>
{provider.name}
</span>
{provider.available ? (
<div className="flex flex-wrap items-center justify-end gap-1">
{supportedTypes.length > 0 ? (
supportedTypes.map((type) => (
<Badge key={type} variant="secondary" className="text-xs font-medium">
{(t.models as Record<string, string>)[type] || type.replace('_', ' ')}
</Badge>
))
) : (
<Badge variant="outline" className="text-xs">{t.models.noModels}</Badge>
)}
</div>
) : (
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
{t.models.notConfigured}
</Badge>
)}
</div>
</div>
)
})}
</div>
{allProviders.length > 6 ? (
<div className="mt-4 flex justify-center">
<button
type="button"
onClick={() => setExpanded((prev) => !prev)}
className="text-sm font-medium text-primary hover:underline"
>
{expanded
? t.models.seeLess
: t.models.seeAll.replace('{count}', allProviders.length.toString())}
</button>
</div>
) : null}
<div className="mt-6 pt-4 border-t">
<a
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
{t.models.learnMore}
</a>
</div>
</CardContent>
</Card>
)
}

View file

@ -1,102 +0,0 @@
'use client'
import { AppShell } from '@/components/layout/AppShell'
import { ProviderStatus } from './components/ProviderStatus'
import { ModelTypeSection } from './components/ModelTypeSection'
import { DefaultModelsSection } from './components/DefaultModelsSection'
import { useModels, useModelDefaults, useProviders } from '@/lib/hooks/use-models'
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
import { RefreshCw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useTranslation } from '@/lib/hooks/use-translation'
export default function ModelsPage() {
const { t } = useTranslation()
const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels()
const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults()
const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders()
const handleRefresh = () => {
refetchModels()
refetchDefaults()
refetchProviders()
}
if (modelsLoading || defaultsLoading || providersLoading) {
return (
<AppShell>
<div className="flex items-center justify-center min-h-[60vh]">
<LoadingSpinner size="lg" />
</div>
</AppShell>
)
}
if (!models || !defaults || !providers) {
return (
<AppShell>
<div className="p-6">
<div className="text-center py-12">
<p className="text-muted-foreground">{t.models.failedToLoad}</p>
</div>
</div>
</AppShell>
)
}
return (
<AppShell>
<div className="flex-1 overflow-y-auto">
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">{t.models.title}</h1>
<p className="text-muted-foreground mt-1">
{t.models.desc}
</p>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<div className="grid gap-6">
{/* Provider Status */}
<ProviderStatus providers={providers} />
{/* Default Models */}
<DefaultModelsSection models={models} defaults={defaults} />
{/* Model Types */}
<div className="grid gap-6 lg:grid-cols-2">
<ModelTypeSection
type="language"
models={models}
providers={providers}
isLoading={modelsLoading}
/>
<ModelTypeSection
type="embedding"
models={models}
providers={providers}
isLoading={modelsLoading}
/>
<ModelTypeSection
type="text_to_speech"
models={models}
providers={providers}
isLoading={modelsLoading}
/>
<ModelTypeSection
type="speech_to_text"
models={models}
providers={providers}
isLoading={modelsLoading}
/>
</div>
</div>
</div>
</div>
</AppShell>
)
}

View file

@ -230,7 +230,7 @@ export default function NotebookPage() {
</div>
{/* Chat Column - always expanded, takes remaining space */}
<div className="transition-all duration-150 flex-1 lg:pr-6 lg:-mr-6">
<div className="transition-all duration-150 flex-1 min-w-0 lg:pr-6 lg:-mr-6">
<ChatColumn
notebookId={notebookId}
contextSelections={contextSelections}

View file

@ -126,7 +126,7 @@ export function NoteEditorDialog({ open, onOpenChange, notebookId, note }: NoteE
<DialogTitle className="sr-only">
{isEditing ? t.sources.editNote : t.sources.createNote}
</DialogTitle>
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col">
<form onSubmit={handleSubmit(onSubmit)} className="flex h-full flex-col min-w-0">
{isEditing && noteLoading ? (
<div className="flex-1 flex items-center justify-center py-10">
<span className="text-sm text-muted-foreground">{t.common.loading}</span>

View file

@ -179,11 +179,11 @@ export function NotesColumn({
</div>
{note.title && (
<h4 className="text-sm font-medium mb-2">{note.title}</h4>
<h4 className="text-sm font-medium mb-2 break-all">{note.title}</h4>
)}
{note.content && (
<p className="text-sm text-muted-foreground line-clamp-3">
<p className="text-sm text-muted-foreground line-clamp-3 break-all">
{note.content}
</p>
)}

File diff suppressed because it is too large Load diff

View file

@ -22,6 +22,7 @@ export default function SettingsPage() {
<RefreshCw className="h-4 w-4" />
</Button>
</div>
<SettingsForm />
</div>
</div>

View file

@ -37,7 +37,7 @@ const getNavigationItems = (t: TranslationKeys) => [
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
{ name: t.navigation.askAndSearch, href: '/search', icon: Search, keywords: ['find', 'query'] },
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
{ name: t.navigation.models, href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
{ name: t.navigation.settings, href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },

View file

@ -87,7 +87,7 @@ export function InlineEdit({
<button
type="button"
className={cn(
"cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -mx-2 -my-1 transition-colors text-left w-full",
"cursor-pointer hover:bg-muted/50 rounded px-2 py-1 -mx-2 -my-1 transition-colors text-left w-full break-all",
className
)}
onClick={(e) => {

View file

@ -64,6 +64,12 @@ export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) {
>
<span>{t.common.japanese}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('fr-FR')}
className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''}
>
<span>{t.common.french}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setLanguage('ru-RU')}
className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''}

View file

@ -1,6 +1,7 @@
'use client'
import { AppSidebar } from './AppSidebar'
import { SetupBanner } from './SetupBanner'
interface AppShellProps {
children: React.ReactNode
@ -11,6 +12,7 @@ export function AppShell({ children }: AppShellProps) {
<div className="flex h-screen overflow-hidden">
<AppSidebar />
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
<SetupBanner />
{children}
</main>
</div>

View file

@ -66,7 +66,7 @@ const getNavigation = (t: TranslationKeys) => [
{
title: t.navigation.manage,
items: [
{ name: t.navigation.models, href: '/models', icon: Bot },
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot },
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle },
{ name: t.navigation.settings, href: '/settings', icon: Settings },
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench },

View file

@ -0,0 +1,84 @@
'use client'
import { useMemo } from 'react'
import Link from 'next/link'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { ShieldAlert, AlertTriangle, ArrowRight, ExternalLink } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { useCredentialStatus, useEnvStatus } from '@/lib/hooks/use-credentials'
export function SetupBanner() {
const { t } = useTranslation()
const { data: credentialStatus } = useCredentialStatus()
const { data: envStatus } = useEnvStatus()
const encryptionReady = credentialStatus?.encryption_configured ?? true
const providersToMigrate = useMemo(() => {
if (!envStatus || !credentialStatus) return []
const providers: string[] = []
for (const provider in envStatus) {
if (envStatus[provider] && credentialStatus.source[provider] === 'environment') {
providers.push(provider)
}
}
return providers
}, [envStatus, credentialStatus])
if (encryptionReady && providersToMigrate.length === 0) {
return null
}
if (!encryptionReady) {
return (
<div className="px-4 pt-3">
<Alert className="border-red-500/50 bg-red-50 dark:bg-red-950/20">
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
<AlertTitle className="text-red-800 dark:text-red-200">
{t.setupBanner.encryptionRequired}
</AlertTitle>
<AlertDescription className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-red-700 dark:text-red-300">
<span>{t.setupBanner.encryptionRequiredDescription}</span>
<a
href="https://github.com/lfnovo/open-notebook/blob/main/docs/3-USER-GUIDE/api-configuration.md#encryption-setup"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center shrink-0 text-sm font-medium underline underline-offset-2 hover:text-red-900 dark:hover:text-red-100"
>
{t.setupBanner.viewDocs}
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</AlertDescription>
</Alert>
</div>
)
}
return (
<div className="px-4 pt-3">
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-800 dark:text-amber-200">
{t.setupBanner.migrationAvailable}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-amber-700 dark:text-amber-300">
{t.setupBanner.migrationDescription.replace('{count}', providersToMigrate.length.toString())}
</span>
<Button
variant="outline"
size="sm"
asChild
className="shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30"
>
<Link href="/settings/api-keys">
{t.setupBanner.goToSettings}
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</AlertDescription>
</Alert>
</div>
)
}

View file

@ -75,7 +75,7 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="notebook-name">{t.common.name || 'Name'} *</Label>
<Label htmlFor="notebook-name">{t.common.name} *</Label>
<Input
id="notebook-name"
{...register('name')}

View file

@ -9,7 +9,8 @@ import { useEpisodeProfiles, useGeneratePodcast } from '@/lib/hooks/use-podcasts
import { chatApi } from '@/lib/api/chat'
import { sourcesApi } from '@/lib/api/sources'
import { notesApi } from '@/lib/api/notes'
import { BuildContextRequest, NoteResponse, SourceListResponse } from '@/lib/types/api'
import { BuildContextRequest, NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api'
import type { QueryClient } from '@tanstack/react-query'
import { PodcastGenerationRequest } from '@/lib/types/podcasts'
import { QUERY_KEYS } from '@/lib/api/query-client'
import { useToast } from '@/lib/hooks/use-toast'
@ -69,6 +70,30 @@ interface GeneratePodcastDialogProps {
onOpenChange: (open: boolean) => void
}
interface NotebookSummary {
notebookId: string
sources: number
notes: number
}
interface ContentSelectionPanelProps {
notebooks: NotebookResponse[]
isLoading: boolean
selectedNotebookSummaries: NotebookSummary[]
tokenCount: number
charCount: number
expandedNotebooks: string[]
setExpandedNotebooks: (notebooks: string[]) => void
selections: Record<string, NotebookSelection>
sourcesByNotebook: Record<string, SourceListResponse[]>
notesByNotebook: Record<string, NoteResponse[]>
fetchingNotebookIds: Set<string>
handleNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void
handleSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void
handleNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void
queryClient: QueryClient
}
// Extracted component for content selection panel
function ContentSelectionPanel({
notebooks,
@ -86,7 +111,7 @@ function ContentSelectionPanel({
handleSourceModeChange,
handleNoteToggle,
queryClient,
}: any) {
}: ContentSelectionPanelProps) {
const { t, language } = useTranslation()
// Cache all translation strings at render time to avoid repeated Proxy accesses in loops
@ -138,7 +163,7 @@ function ContentSelectionPanel({
{tr.itemsSelected.replace(
'{count}',
selectedNotebookSummaries.reduce(
(acc: number, summary: any) => acc + summary.sources + summary.notes,
(acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,
0
).toString()
)}
@ -170,7 +195,7 @@ function ContentSelectionPanel({
onValueChange={(value) => setExpandedNotebooks(value as string[])}
className="w-full"
>
{notebooks.map((notebook: any, index: number) => {
{notebooks.map((notebook: NotebookResponse, index: number) => {
const sources = sourcesByNotebook[notebook.id] ?? []
const notes = notesByNotebook[notebook.id] ?? []
const selection = selections[notebook.id]
@ -239,7 +264,7 @@ function ContentSelectionPanel({
</p>
) : (
<div className="space-y-2">
{sources.map((source: any) => {
{sources.map((source: SourceListResponse) => {
const mode = selection?.sources?.[source.id] ?? 'off'
return (
<div
@ -318,7 +343,7 @@ function ContentSelectionPanel({
</p>
) : (
<div className="space-y-2">
{notes.map((note: any) => {
{notes.map((note: NoteResponse) => {
const mode = selection?.notes?.[note.id] ?? 'off'
return (
<div
@ -370,7 +395,7 @@ function ContentSelectionPanel({
}
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
const { t, language } = useTranslation()
const { t } = useTranslation()
const { toast } = useToast()
const queryClient = useQueryClient()
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])

View file

@ -68,7 +68,7 @@ export function EmbeddingModelChangeDialog({
</p>
<div className="bg-muted p-4 rounded-md space-y-2">
<p className="font-semibold text-foreground"> {t.models.rebuildRequired}</p>
<p className="font-semibold text-foreground">{t.models.rebuildRequired}</p>
<p className="text-sm">
{t.models.rebuildReason}
</p>

View file

@ -0,0 +1,53 @@
'use client'
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { AlertTriangle, ArrowRight, Loader2 } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { useMigrateFromEnv } from '@/lib/hooks/use-credentials'
interface MigrationBannerProps {
providersToMigrate: string[]
}
export function MigrationBanner({ providersToMigrate }: MigrationBannerProps) {
const { t } = useTranslation()
const migrate = useMigrateFromEnv()
if (providersToMigrate.length === 0) {
return null
}
return (
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<AlertTitle className="text-amber-800 dark:text-amber-200">
{t.apiKeys.migrationAvailable}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="text-amber-700 dark:text-amber-300">
{t.apiKeys.migrationDescription.replace('{count}', providersToMigrate.length.toString())}
</span>
<Button
variant="outline"
size="sm"
onClick={() => migrate.mutate()}
disabled={migrate.isPending}
className="shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30"
>
{migrate.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t.apiKeys.migrating}
</>
) : (
<>
{t.apiKeys.migrateToDatabase}
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</AlertDescription>
</Alert>
)
}

View file

@ -0,0 +1,63 @@
'use client'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Check, X } from 'lucide-react'
import { useTranslation } from '@/lib/hooks/use-translation'
import { ModelTestResult } from '@/lib/types/models'
export function ModelTestResultDialog({
open,
onOpenChange,
result,
modelName,
}: {
open: boolean
onOpenChange: (open: boolean) => void
result: ModelTestResult | null
modelName: string
}) {
const { t } = useTranslation()
if (!result) return null
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{result.success ? (
<Check className="h-5 w-5 text-emerald-500" />
) : (
<X className="h-5 w-5 text-destructive" />
)}
{result.success ? t.models.testModelSuccess : t.models.testModelFailed}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">{modelName}</p>
<p className="text-sm">{result.message}</p>
{result.details && (
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-60 whitespace-pre-wrap break-words">
{result.details}
</pre>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t.common.done}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,3 @@
export { MigrationBanner } from './MigrationBanner'
export { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
export { ModelTestResultDialog } from './ModelTestResultDialog'

View file

@ -203,7 +203,7 @@ export function ChatPanel({
onReferenceClick={handleReferenceClick}
/>
) : (
<p className="text-sm break-words overflow-wrap-anywhere">{message.content}</p>
<p className="text-sm break-all">{message.content}</p>
)}
</div>
{message.type === 'ai' && (
@ -290,7 +290,7 @@ export function ChatPanel({
</div>
)}
<div className="flex gap-2 items-end">
<div className="flex gap-2 items-end min-w-0">
<Textarea
id={chatInputId}
name="chat-message"
@ -300,7 +300,7 @@ export function ChatPanel({
onKeyDown={handleKeyDown}
placeholder={`${t.chat.sendPlaceholder} (${t.chat.pressToSend.replace('{key}', keyHint)})`}
disabled={isStreaming}
className="flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3"
className="flex-1 min-h-[40px] max-h-[100px] resize-none py-2 px-3 min-w-0"
rows={1}
/>
<Button

View file

@ -542,7 +542,7 @@ export function AddSourceDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)}>
<form onSubmit={handleSubmit(onSubmit)} className="min-w-0">
<WizardContainer
currentStep={currentStep}
steps={WIZARD_STEPS}

View file

@ -240,7 +240,7 @@ export function SourceCard({
{/* Title */}
<div className={cn('mb-1.5', !isCompleted && 'mb-1')}>
<h4
className="text-sm font-medium leading-tight line-clamp-2"
className="text-sm font-medium leading-tight line-clamp-2 break-all"
title={title}
>
{title}

View file

@ -63,7 +63,7 @@ const DialogContent = ({
data-slot="dialog-content"
aria-describedby={undefined}
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:pointer-events-none fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[calc(100%-2rem)]",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:pointer-events-none fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-[calc(100%-2rem)] overflow-hidden",
className
)}
{...props}

View file

@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 min-w-0 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}

View file

@ -85,15 +85,15 @@ export function WizardContainer({
className
}: WizardContainerProps) {
return (
<div className={cn('flex flex-col h-[500px] bg-card rounded-lg border border-border', className)}>
<StepIndicator
<div className={cn('flex flex-col h-[500px] min-w-0 overflow-hidden bg-card rounded-lg border border-border', className)}>
<StepIndicator
currentStep={currentStep}
steps={steps}
onStepClick={onStepClick}
/>
<div className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto px-6 py-4">
<div className="flex-1 min-w-0 overflow-hidden">
<div className="h-full min-w-0 overflow-y-auto px-6 py-4">
{children}
</div>
</div>

View file

@ -64,3 +64,95 @@ const response = await sourcesApi.create({
// With auth token (auto-added by interceptor)
const notes = await notesApi.list()
```
## Credentials Module (`credentials.ts`)
Client functions for managing AI provider credentials (API keys, base URLs, endpoints) stored encrypted in SurrealDB.
### Type Definitions
```typescript
// Full credential object (api_key never exposed)
interface Credential {
id: string
name: string
provider: string
modalities: string[]
has_api_key: boolean
model_count: number
base_url?: string
endpoint?: string
api_version?: string
// ... endpoint_llm, endpoint_embedding, endpoint_stt, endpoint_tts, project, location, credentials_path
}
// Request payload for creating/updating credential
interface CreateCredentialRequest {
name: string
provider: string
modalities: string[]
api_key?: string
base_url?: string
// ... other provider-specific fields
}
// Model discovery and registration
interface DiscoverModelsResponse { provider: string; models: DiscoveredModel[]; credential_id: string }
interface RegisterModelsRequest { models: RegisterModelData[] }
// Status and migration
interface CredentialStatus { configured: Record<string, boolean>; source: Record<string, string>; encryption_configured: boolean }
interface EnvStatus { [provider: string]: boolean }
interface MigrationResult { message: string; migrated: string[]; skipped: string[]; errors: string[] }
interface TestConnectionResult { provider: string; success: boolean; message: string }
```
### API Functions
| Function | Description | Endpoint |
|----------|-------------|----------|
| `getStatus()` | Get configuration status of all providers | `GET /credentials/status` |
| `getEnvStatus()` | Get which providers have env vars set | `GET /credentials/env-status` |
| `list(provider?)` | List all credentials (optional filter) | `GET /credentials` |
| `listByProvider(provider)` | List credentials for a provider | `GET /credentials/by-provider/{provider}` |
| `get(credentialId)` | Get a specific credential | `GET /credentials/{credentialId}` |
| `create(data)` | Create a new credential | `POST /credentials` |
| `update(credentialId, data)` | Update a credential | `PUT /credentials/{credentialId}` |
| `delete(credentialId, options?)` | Delete a credential | `DELETE /credentials/{credentialId}` |
| `test(credentialId)` | Test connection using credential | `POST /credentials/{credentialId}/test` |
| `discover(credentialId)` | Discover available models | `POST /credentials/{credentialId}/discover` |
| `registerModels(credentialId, data)` | Register discovered models | `POST /credentials/{credentialId}/register-models` |
| `migrateFromProviderConfig()` | Migrate from legacy ProviderConfig | `POST /credentials/migrate-from-provider-config` |
| `migrateFromEnv()` | Migrate from env vars | `POST /credentials/migrate-from-env` |
### Usage Example
```typescript
import { credentialsApi } from '@/lib/api/credentials'
// Check which providers are configured
const status = await credentialsApi.getStatus()
if (status.configured['openai']) {
console.log(`OpenAI configured via ${status.source['openai']}`)
}
// Create a new credential
const cred = await credentialsApi.create({
name: 'My OpenAI Key',
provider: 'openai',
modalities: ['language', 'embedding'],
api_key: 'sk-proj-...'
})
// Test the connection
const result = await credentialsApi.test(cred.id)
if (result.success) {
console.log('Connection successful!')
}
// Discover and register models
const discovered = await credentialsApi.discover(cred.id)
await credentialsApi.registerModels(cred.id, {
models: discovered.models.map(m => ({ model_id: m.model_id, name: m.name, type: 'language' }))
})
```

View file

@ -0,0 +1,239 @@
import apiClient from './client'
// Types for credentials API
export interface Credential {
id: string
name: string
provider: string
modalities: string[]
base_url?: string | null
endpoint?: string | null
api_version?: string | null
endpoint_llm?: string | null
endpoint_embedding?: string | null
endpoint_stt?: string | null
endpoint_tts?: string | null
project?: string | null
location?: string | null
credentials_path?: string | null
has_api_key: boolean
created: string
updated: string
model_count: number
}
export interface CreateCredentialRequest {
name: string
provider: string
modalities: string[]
api_key?: string
base_url?: string
endpoint?: string
api_version?: string
endpoint_llm?: string
endpoint_embedding?: string
endpoint_stt?: string
endpoint_tts?: string
project?: string
location?: string
credentials_path?: string
}
export interface UpdateCredentialRequest {
name?: string
modalities?: string[]
api_key?: string
base_url?: string
endpoint?: string
api_version?: string
endpoint_llm?: string
endpoint_embedding?: string
endpoint_stt?: string
endpoint_tts?: string
project?: string
location?: string
credentials_path?: string
}
export interface DiscoveredModel {
name: string
provider: string
model_type?: string
description?: string
}
export interface RegisterModelData {
name: string
provider: string
model_type: string
}
export interface DiscoverModelsResponse {
credential_id: string
provider: string
discovered: DiscoveredModel[]
}
export interface RegisterModelsRequest {
models: RegisterModelData[]
}
export interface RegisterModelsResponse {
created: number
existing: number
}
export interface TestConnectionResult {
provider: string
success: boolean
message: string
}
export interface CredentialDeleteResponse {
message: string
deleted_models: number
}
export interface MigrationResult {
message: string
migrated: string[]
skipped: string[]
not_configured?: string[]
errors: string[]
}
export interface CredentialStatus {
configured: Record<string, boolean>
source: Record<string, string>
encryption_configured: boolean
}
export type EnvStatus = Record<string, boolean>
export const credentialsApi = {
/**
* Get configuration status for all providers
*/
getStatus: async (): Promise<CredentialStatus> => {
const response = await apiClient.get<CredentialStatus>('/credentials/status')
return response.data
},
/**
* Get environment variable status for all providers
*/
getEnvStatus: async (): Promise<EnvStatus> => {
const response = await apiClient.get<EnvStatus>('/credentials/env-status')
return response.data
},
/**
* List all credentials, optionally filtered by provider
*/
list: async (provider?: string): Promise<Credential[]> => {
const params = provider ? { provider } : {}
const response = await apiClient.get<Credential[]>('/credentials', { params })
return response.data
},
/**
* List credentials for a specific provider
*/
listByProvider: async (provider: string): Promise<Credential[]> => {
const response = await apiClient.get<Credential[]>(`/credentials/by-provider/${provider}`)
return response.data
},
/**
* Get a specific credential by ID
*/
get: async (credentialId: string): Promise<Credential> => {
const response = await apiClient.get<Credential>(`/credentials/${credentialId}`)
return response.data
},
/**
* Create a new credential
*/
create: async (data: CreateCredentialRequest): Promise<Credential> => {
const response = await apiClient.post<Credential>('/credentials', data)
return response.data
},
/**
* Update an existing credential
*/
update: async (credentialId: string, data: UpdateCredentialRequest): Promise<Credential> => {
const response = await apiClient.put<Credential>(`/credentials/${credentialId}`, data)
return response.data
},
/**
* Delete a credential
*/
delete: async (
credentialId: string,
options?: { delete_models?: boolean; migrate_to?: string }
): Promise<CredentialDeleteResponse> => {
const params: Record<string, string | boolean> = {}
if (options?.delete_models) params.delete_models = true
if (options?.migrate_to) params.migrate_to = options.migrate_to
const response = await apiClient.delete<CredentialDeleteResponse>(
`/credentials/${credentialId}`,
{ params }
)
return response.data
},
/**
* Test connection for a credential
*/
test: async (credentialId: string): Promise<TestConnectionResult> => {
const response = await apiClient.post<TestConnectionResult>(
`/credentials/${credentialId}/test`
)
return response.data
},
/**
* Discover models using a credential's API key
*/
discover: async (credentialId: string): Promise<DiscoverModelsResponse> => {
const response = await apiClient.post<DiscoverModelsResponse>(
`/credentials/${credentialId}/discover`
)
return response.data
},
/**
* Register discovered models and link them to a credential
*/
registerModels: async (
credentialId: string,
data: RegisterModelsRequest
): Promise<RegisterModelsResponse> => {
const response = await apiClient.post<RegisterModelsResponse>(
`/credentials/${credentialId}/register-models`,
data
)
return response.data
},
/**
* Migrate from ProviderConfig to individual credentials
*/
migrateFromProviderConfig: async (): Promise<MigrationResult> => {
const response = await apiClient.post<MigrationResult>(
'/credentials/migrate-from-provider-config'
)
return response.data
},
/**
* Migrate from environment variables to credentials
*/
migrateFromEnv: async (): Promise<MigrationResult> => {
const response = await apiClient.post<MigrationResult>('/credentials/migrate-from-env')
return response.data
},
}

View file

@ -1,5 +1,16 @@
import apiClient from './client'
import { Model, CreateModelRequest, ModelDefaults, ProviderAvailability } from '@/lib/types/models'
import {
Model,
CreateModelRequest,
ModelDefaults,
ProviderAvailability,
DiscoveredModel,
ProviderSyncResult,
AllProvidersSyncResult,
ProviderModelCount,
AutoAssignResult,
ModelTestResult,
} from '@/lib/types/models'
export const modelsApi = {
list: async () => {
@ -34,5 +45,62 @@ export const modelsApi = {
getProviders: async () => {
const response = await apiClient.get<ProviderAvailability>('/models/providers')
return response.data
}
},
// Model Discovery API
/**
* Discover available models from a provider without registering them
*/
discoverModels: async (provider: string) => {
const response = await apiClient.get<DiscoveredModel[]>(`/models/discover/${provider}`)
return response.data
},
/**
* Sync models for a specific provider (discover and register)
*/
syncProvider: async (provider: string) => {
const response = await apiClient.post<ProviderSyncResult>(`/models/sync/${provider}`)
return response.data
},
/**
* Sync models for all configured providers
*/
syncAll: async () => {
const response = await apiClient.post<AllProvidersSyncResult>('/models/sync')
return response.data
},
/**
* Get count of registered models for a provider
*/
getProviderModelCount: async (provider: string) => {
const response = await apiClient.get<ProviderModelCount>(`/models/count/${provider}`)
return response.data
},
/**
* Get all models for a specific provider
*/
getByProvider: async (provider: string) => {
const response = await apiClient.get<Model[]>(`/models/by-provider/${provider}`)
return response.data
},
/**
* Auto-assign default models based on available models
*/
autoAssign: async () => {
const response = await apiClient.post<AutoAssignResult>('/models/auto-assign')
return response.data
},
/**
* Test an individual model configuration
*/
testModel: async (modelId: string): Promise<ModelTestResult> => {
const response = await apiClient.post<ModelTestResult>(`/models/${modelId}/test`)
return response.data
},
}

View file

@ -66,3 +66,128 @@ render(<Component />, { wrapper: QueryClientProvider })
// Assert mutations trigger cache invalidation
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalled())
```
## Credentials Hooks (`use-credentials.ts`)
Hooks for managing AI provider credentials with TanStack Query integration, toast notifications, and cache invalidation.
### Query Keys
```typescript
export const CREDENTIAL_QUERY_KEYS = {
all: ['credentials'] as const,
status: ['credentials', 'status'] as const,
envStatus: ['credentials', 'env-status'] as const,
byProvider: (provider: string) => ['credentials', 'provider', provider] as const,
detail: (id: string) => ['credentials', id] as const,
}
```
### Query Hooks
| Hook | Description | Returns |
|------|-------------|---------|
| `useCredentialStatus()` | Get configuration status of all providers | `{ configured, source, encryption_configured }` |
| `useEnvStatus()` | Get which providers have env vars set | `{ [provider]: boolean }` |
| `useCredentials(provider?)` | List all credentials (optional filter) | `Credential[]` |
| `useCredentialsByProvider(provider)` | List credentials for a specific provider | `Credential[]` |
| `useCredential(credentialId)` | Get a specific credential | `Credential` |
### Mutation Hooks
| Hook | Description | Cache Invalidation |
|------|-------------|-------------------|
| `useCreateCredential()` | Create new credential | `all`, `providers` |
| `useUpdateCredential()` | Update credential | `all`, `providers` |
| `useDeleteCredential()` | Delete credential | `all`, `models`, `providers` |
| `useTestCredential()` | Test credential connection | None (stores result locally) |
| `useDiscoverModels()` | Discover models for credential | None |
| `useRegisterModels()` | Register discovered models | `models`, `all` |
| `useMigrateFromEnv()` | Migrate from env vars | `status`, `envStatus`, `models`, `providers` |
| `useMigrateFromProviderConfig()` | Migrate from legacy ProviderConfig | `status`, `envStatus`, `models`, `providers` |
### useTestCredential Details
Returns extended interface with local state management for test results:
```typescript
const {
testCredential, // (credentialId: string) => void
testCredentialAsync, // (credentialId: string) => Promise<TestConnectionResult>
isPending, // boolean
testResults, // Record<string, TestConnectionResult>
clearResult, // (credentialId: string) => void
} = useTestCredential()
```
### Cache Invalidation Strategy
All mutation hooks invalidate:
- `CREDENTIAL_QUERY_KEYS.all` — refreshes all credential queries (cascades to filtered queries)
- `MODEL_QUERY_KEYS.providers` — refreshes provider list
Delete hook additionally invalidates:
- `MODEL_QUERY_KEYS.models` — refreshes full model list (linked models may be deleted)
Migration hooks additionally invalidate:
- `CREDENTIAL_QUERY_KEYS.status` — refreshes configured/source info
- `CREDENTIAL_QUERY_KEYS.envStatus` — refreshes env var status
### Usage Example
```typescript
import {
useCredentialStatus,
useCredentials,
useCreateCredential,
useTestCredential,
useMigrateFromEnv
} from '@/lib/hooks/use-credentials'
function CredentialSettings() {
const { data: status, isLoading } = useCredentialStatus()
const { data: credentials } = useCredentials()
const createCredential = useCreateCredential()
const { testCredential, testResults, isPending } = useTestCredential()
const migrateFromEnv = useMigrateFromEnv()
const handleCreate = () => {
createCredential.mutate({
name: 'My OpenAI Key',
provider: 'openai',
modalities: ['language', 'embedding'],
api_key: 'sk-...'
})
}
const handleTest = (credentialId: string) => {
testCredential(credentialId)
}
const handleMigrate = () => {
migrateFromEnv.mutate()
}
return (
<div>
{credentials?.map(cred => (
<div key={cred.id}>
<span>{cred.name} ({cred.provider})</span>
<button onClick={() => handleTest(cred.id)} disabled={isPending}>Test</button>
{testResults[cred.id]?.success && <span>Connected!</span>}
</div>
))}
<button onClick={handleCreate}>Add Credential</button>
<button onClick={handleMigrate}>Migrate from .env</button>
</div>
)
}
```
### Important Notes
- **Toast notifications**: All mutations show success/error toasts automatically
- **i18n integration**: Toast messages use translation keys from `t.apiKeys.*` and `t.common.*`
- **Error handling**: Uses `getApiErrorKey()` utility to extract error messages from API responses
- **Local test results**: `useTestCredential` stores results in local state (not cached in TanStack Query)
- **Migration feedback**: Migration hooks show different toasts based on migrated/skipped/error counts

View file

@ -3,7 +3,7 @@
import { useState, useCallback } from 'react'
import { toast } from 'sonner'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { getApiErrorMessage } from '@/lib/utils/error-handler'
import { searchApi } from '@/lib/api/search'
import { AskStreamEvent } from '@/lib/types/search'
@ -122,8 +122,12 @@ export function useAsk() {
throw new Error(data.message || 'Stream error occurred')
}
} catch (e) {
console.error('Error parsing SSE data:', e, 'Line:', line)
// Don't throw - continue processing other lines
if (e instanceof SyntaxError) {
console.error('Error parsing SSE data:', e, 'Line:', line)
// Don't throw - continue processing other lines
} else {
throw e
}
}
}
}
@ -144,7 +148,7 @@ export function useAsk() {
}))
toast.error(t('apiErrors.askFailed'), {
description: t(getApiErrorKey(errorMessage))
description: getApiErrorMessage(errorMessage, (key) => t(key))
})
}
}, [t])

View file

@ -0,0 +1,388 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
credentialsApi,
CreateCredentialRequest,
UpdateCredentialRequest,
TestConnectionResult,
RegisterModelData,
} from '@/lib/api/credentials'
import { useToast } from '@/lib/hooks/use-toast'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { MODEL_QUERY_KEYS } from '@/lib/hooks/use-models'
export const CREDENTIAL_QUERY_KEYS = {
all: ['credentials'] as const,
status: ['credentials', 'status'] as const,
envStatus: ['credentials', 'env-status'] as const,
byProvider: (provider: string) => ['credentials', 'provider', provider] as const,
detail: (id: string) => ['credentials', id] as const,
}
/**
* Hook to get the configuration status of all providers
*/
export function useCredentialStatus() {
return useQuery({
queryKey: CREDENTIAL_QUERY_KEYS.status,
queryFn: () => credentialsApi.getStatus(),
})
}
/**
* Hook to get the environment variable status
*/
export function useEnvStatus() {
return useQuery({
queryKey: CREDENTIAL_QUERY_KEYS.envStatus,
queryFn: () => credentialsApi.getEnvStatus(),
})
}
/**
* Hook to list all credentials
*/
export function useCredentials(provider?: string) {
return useQuery({
queryKey: provider ? CREDENTIAL_QUERY_KEYS.byProvider(provider) : CREDENTIAL_QUERY_KEYS.all,
queryFn: () => credentialsApi.list(provider),
})
}
/**
* Hook to list credentials for a specific provider.
* Uses the same list endpoint with provider filter for cache consistency.
*/
export function useCredentialsByProvider(provider: string) {
return useQuery({
queryKey: CREDENTIAL_QUERY_KEYS.byProvider(provider),
queryFn: () => credentialsApi.list(provider),
enabled: !!provider,
})
}
/**
* Hook to get a specific credential
*/
export function useCredential(credentialId: string) {
return useQuery({
queryKey: CREDENTIAL_QUERY_KEYS.detail(credentialId),
queryFn: () => credentialsApi.get(credentialId),
enabled: !!credentialId,
})
}
/**
* Hook to create a new credential
*/
export function useCreateCredential() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: (data: CreateCredentialRequest) => credentialsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
toast({
title: t.common.success,
description: t.apiKeys.configSaveSuccess,
})
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}
/**
* Hook to update a credential
*/
export function useUpdateCredential() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: ({
credentialId,
data,
}: {
credentialId: string
data: UpdateCredentialRequest
}) => credentialsApi.update(credentialId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
toast({
title: t.common.success,
description: t.apiKeys.configUpdateSuccess,
})
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}
/**
* Hook to delete a credential
*/
export function useDeleteCredential() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: ({
credentialId,
options,
}: {
credentialId: string
options?: { delete_models?: boolean; migrate_to?: string }
}) => credentialsApi.delete(credentialId, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
toast({
title: t.common.success,
description: t.apiKeys.configDeleteSuccess,
})
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}
/**
* Hook to test a credential's connection
*/
export function useTestCredential() {
const { toast } = useToast()
const { t } = useTranslation()
const [testResults, setTestResults] = useState<Record<string, TestConnectionResult>>({})
const mutation = useMutation({
mutationFn: (credentialId: string) => credentialsApi.test(credentialId),
onSuccess: (result, credentialId) => {
setTestResults(prev => ({ ...prev, [credentialId]: result }))
if (result.success) {
toast({
title: t.common.success,
description: t.apiKeys.testSuccess,
})
} else {
toast({
title: t.common.error,
description: result.message || t.apiKeys.testFailed,
variant: 'destructive',
})
}
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.apiKeys.testFailed),
variant: 'destructive',
})
},
})
return {
testCredential: mutation.mutate,
testCredentialAsync: mutation.mutateAsync,
isPending: mutation.isPending,
testResults,
clearResult: (credentialId: string) => {
setTestResults(prev => {
const { [credentialId]: _removed, ...rest } = prev
return rest
})
},
}
}
/**
* Hook to discover models for a credential
*/
export function useDiscoverModels() {
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: (credentialId: string) => credentialsApi.discover(credentialId),
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.apiKeys.syncFailed),
variant: 'destructive',
})
},
})
}
/**
* Hook to register discovered models
*/
export function useRegisterModels() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: ({
credentialId,
models,
}: {
credentialId: string
models: RegisterModelData[]
}) => credentialsApi.registerModels(credentialId, { models }),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
if (result.created > 0) {
toast({
title: t.common.success,
description: t.apiKeys.syncSuccess
.replace('{discovered}', (result.created + result.existing).toString())
.replace('{new}', result.created.toString()),
})
} else {
toast({
title: t.common.success,
description: t.apiKeys.syncNoNew.replace('{count}', result.existing.toString()),
})
}
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.apiKeys.syncFailed),
variant: 'destructive',
})
},
})
}
/**
* Hook to migrate from environment variables
*/
export function useMigrateFromEnv() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: () => credentialsApi.migrateFromEnv(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.status })
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.envStatus })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
const migratedCount = result.migrated.length
const errorCount = result.errors?.length ?? 0
if (errorCount > 0 && migratedCount === 0) {
toast({
title: t.common.error,
description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()),
variant: 'destructive',
})
} else if (migratedCount > 0 && errorCount > 0) {
toast({
title: t.common.success,
description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`,
})
} else if (migratedCount > 0) {
toast({
title: t.common.success,
description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()),
})
} else {
toast({
title: t.common.success,
description: t.apiKeys.migrationNothingToMigrate,
})
}
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}
/**
* Hook to migrate from ProviderConfig
*/
export function useMigrateFromProviderConfig() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: () => credentialsApi.migrateFromProviderConfig(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.status })
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.envStatus })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
const migratedCount = result.migrated.length
const errorCount = result.errors?.length ?? 0
if (errorCount > 0 && migratedCount === 0) {
toast({
title: t.common.error,
description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()),
variant: 'destructive',
})
} else if (migratedCount > 0 && errorCount > 0) {
toast({
title: t.common.success,
description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`,
})
} else if (migratedCount > 0) {
toast({
title: t.common.success,
description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()),
})
} else {
toast({
title: t.common.success,
description: t.apiKeys.migrationNothingToMigrate,
})
}
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}

View file

@ -1,9 +1,10 @@
import { useState, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { modelsApi } from '@/lib/api/models'
import { useToast } from '@/lib/hooks/use-toast'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { CreateModelRequest, ModelDefaults } from '@/lib/types/models'
import { CreateModelRequest, ModelDefaults, ModelTestResult } from '@/lib/types/models'
export const MODEL_QUERY_KEYS = {
models: ['models'] as const,
@ -61,6 +62,7 @@ export function useDeleteModel() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })
queryClient.invalidateQueries({ queryKey: ['credentials'] })
toast({
title: t.common.success,
description: t.models.deleteSuccess,
@ -113,3 +115,85 @@ export function useProviders() {
queryFn: () => modelsApi.getProviders(),
})
}
export function useAutoAssignDefaults() {
const queryClient = useQueryClient()
const { toast } = useToast()
const { t } = useTranslation()
return useMutation({
mutationFn: () => modelsApi.autoAssign(),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })
const assignedCount = Object.keys(result.assigned).length
const missingCount = result.missing.length
if (assignedCount > 0) {
toast({
title: t.common.success,
description: t.models.autoAssignSuccess.replace('{count}', assignedCount.toString()),
})
} else if (missingCount > 0) {
toast({
title: t.common.warning,
description: t.models.autoAssignNoModels,
variant: 'destructive',
})
} else {
toast({
title: t.common.success,
description: t.models.autoAssignAlreadySet,
})
}
},
onError: (error: unknown) => {
toast({
title: t.common.error,
description: getApiErrorKey(error, t.common.error),
variant: 'destructive',
})
},
})
}
export function useTestModel() {
const [testResult, setTestResult] = useState<ModelTestResult | null>(null)
const [testedModelName, setTestedModelName] = useState('')
const [testingModelId, setTestingModelId] = useState<string | null>(null)
const mutation = useMutation({
mutationFn: (modelId: string) => modelsApi.testModel(modelId),
onSuccess: (result) => {
setTestResult(result)
setTestingModelId(null)
},
onError: (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error)
setTestResult({ success: false, message: msg })
setTestingModelId(null)
},
})
const testModel = useCallback((modelId: string, modelName: string) => {
setTestedModelName(modelName)
setTestingModelId(modelId)
setTestResult(null)
mutation.mutate(modelId)
}, [mutation])
const clearResult = useCallback(() => {
setTestResult(null)
setTestedModelName('')
setTestingModelId(null)
}, [])
return {
testModel,
isPending: mutation.isPending,
testingModelId,
testResult,
testedModelName,
clearResult,
}
}

View file

@ -4,7 +4,7 @@ import { sourcesApi } from '@/lib/api/sources'
import { QUERY_KEYS } from '@/lib/api/query-client'
import { useToast } from '@/lib/hooks/use-toast'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { getApiErrorMessage } from '@/lib/utils/error-handler'
import {
CreateSourceRequest,
UpdateSourceRequest,
@ -131,7 +131,7 @@ export function useCreateSource() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToAddSource)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSource),
variant: 'destructive',
})
},
@ -158,7 +158,7 @@ export function useUpdateSource() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToUpdateSource)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUpdateSource),
variant: 'destructive',
})
},
@ -185,7 +185,7 @@ export function useDeleteSource() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToDeleteSource)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToDeleteSource),
variant: 'destructive',
})
},
@ -212,7 +212,7 @@ export function useFileUpload() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToUploadFile)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToUploadFile),
variant: 'destructive',
})
},
@ -270,7 +270,7 @@ export function useRetrySource() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToRetry)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRetry),
variant: 'destructive',
})
},
@ -332,7 +332,7 @@ export function useAddSourcesToNotebook() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToAddSourcesToNotebook)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToAddSourcesToNotebook),
variant: 'destructive',
})
},
@ -366,7 +366,7 @@ export function useRemoveSourceFromNotebook() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.sources.failedToRemoveSourceFromNotebook)),
description: getApiErrorMessage(error, (key) => t(key), t.sources.failedToRemoveSourceFromNotebook),
variant: 'destructive',
})
},

View file

@ -2,7 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { transformationsApi } from '@/lib/api/transformations'
import { useToast } from '@/lib/hooks/use-toast'
import { useTranslation } from '@/lib/hooks/use-translation'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { getApiErrorMessage } from '@/lib/utils/error-handler'
import {
CreateTransformationRequest,
UpdateTransformationRequest,
@ -49,7 +49,7 @@ export function useCreateTransformation() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.common.error)),
description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive',
})
},
@ -75,7 +75,7 @@ export function useUpdateTransformation() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.common.error)),
description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive',
})
},
@ -99,7 +99,7 @@ export function useDeleteTransformation() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.common.error)),
description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive',
})
},
@ -115,7 +115,7 @@ export function useExecuteTransformation() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.common.error)),
description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive',
})
},
@ -146,7 +146,7 @@ export function useUpdateDefaultPrompt() {
onError: (error: unknown) => {
toast({
title: t.common.error,
description: t(getApiErrorKey(error, t.common.error)),
description: getApiErrorMessage(error, (key) => t(key)),
variant: 'destructive',
})
},

View file

@ -78,12 +78,6 @@ export function useTranslation() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target as any)[prop];
}
// Handle function's own properties
if (prop in target) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (target as any)[prop];
}
if (typeof prop !== 'string') return undefined;
@ -94,7 +88,9 @@ export function useTranslation() {
const currentPath = path ? `${path}.${prop}` : prop;
// Try to get the translation
// Try to get the translation first (before checking target properties,
// since target is a function and has built-in properties like 'name'
// that would shadow translation keys)
const result = i18nTranslateCopy(currentPath, { returnObjects: true });
// If it's a leaf string, return it directly

View file

@ -3,7 +3,7 @@
import { useState, useCallback, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { getApiErrorMessage } from '@/lib/utils/error-handler'
import { useTranslation } from '@/lib/hooks/use-translation'
import { chatApi } from '@/lib/api/chat'
import { QUERY_KEYS } from '@/lib/api/query-client'
@ -84,7 +84,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
}
})
@ -105,7 +105,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToUpdateSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))
}
})
@ -125,7 +125,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToDeleteSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))
}
})
@ -197,7 +197,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
})
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
return
}
}
@ -230,7 +230,7 @@ export function useNotebookChat({ notebookId, sources, notes, contextSelections
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
console.error('Error sending message:', error)
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToSendMessage')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))
// Remove optimistic message on error
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
} finally {

View file

@ -3,7 +3,7 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { getApiErrorKey } from '@/lib/utils/error-handler'
import { getApiErrorMessage } from '@/lib/utils/error-handler'
import { useTranslation } from '@/lib/hooks/use-translation'
import { sourceChatApi } from '@/lib/api/source-chat'
import {
@ -64,7 +64,7 @@ export function useSourceChat(sourceId: string) {
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
}
})
@ -79,7 +79,7 @@ export function useSourceChat(sourceId: string) {
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToUpdateSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToUpdateSession'))
}
})
@ -97,7 +97,7 @@ export function useSourceChat(sourceId: string) {
},
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToDeleteSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToDeleteSession'))
}
})
@ -116,7 +116,7 @@ export function useSourceChat(sourceId: string) {
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
console.error('Failed to create chat session:', error)
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToCreateSession')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToCreateSession'))
return
}
}
@ -182,7 +182,11 @@ export function useSourceChat(sourceId: string) {
throw new Error(data.message || 'Stream error')
}
} catch (e) {
console.error('Error parsing SSE data:', e)
if (e instanceof SyntaxError) {
console.error('Error parsing SSE data:', e)
} else {
throw e
}
}
}
}
@ -190,7 +194,7 @@ export function useSourceChat(sourceId: string) {
} catch (err: unknown) {
const error = err as { response?: { data?: { detail?: string } }, message?: string };
console.error('Error sending message:', error)
toast.error(t(getApiErrorKey(error.response?.data?.detail || error.message, 'apiErrors.failedToSendMessage')))
toast.error(getApiErrorMessage(error.response?.data?.detail || error.message, (key) => t(key), 'apiErrors.failedToSendMessage'))
// Remove optimistic messages on error
setMessages(prev => prev.filter(msg => !msg.id.startsWith('temp-')))
} finally {

View file

@ -23,6 +23,7 @@ export const enUS = {
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
source: "Source",
notebook: "Notebook",
@ -36,22 +37,16 @@ export const enUS = {
warning: "Warning",
error: "Error",
success: "Success",
sessions: "Sessions",
model: "Model",
send: "Send",
back: "Back",
next: "Next",
done: "Done",
processing: "Processing...",
creating: "Creating...",
tokenCount: "Tokens",
charCount: "Chars",
linked: "Linked",
added: "Added on {date}",
adding: "Adding...",
addSelected: "Add Selected",
customModel: "Custom Model",
messages: "Messages",
failed: "failed",
current: "Current",
save: "Save",
@ -72,7 +67,6 @@ export const enUS = {
unknown: "Unknown",
notes: "Notes",
chat: "Chat",
details: "Details",
deleteForever: "Delete Forever",
connectionError: "Connection Error",
unableToConnect: "Unable to connect to the API server",
@ -85,7 +79,6 @@ export const enUS = {
checkConsoleLogs: "Check browser console for detailed logs (look for 🔧 [Config] messages)",
yes: "Yes",
no: "No",
simple: "Simple",
saving: "Saving...",
description: "Description",
saveToNote: "Save to note",
@ -103,7 +96,6 @@ export const enUS = {
nameRequired: "Name is required",
modelConfiguration: "Model Configuration",
resetToDefault: "Reset to Default",
notFound: "Not found",
reasoning: "Reasoning",
searchTerms: "Search Terms",
strategy: "Strategy",
@ -112,14 +104,12 @@ export const enUS = {
notebookLabel: "Notebook: {name}",
itemNotFound: "This {type} could not be found",
accessibility: {
navigation: "Navigation",
transformationViews: "Transformation views",
searchKB: "Ask or search your knowledge base",
enterQuestion: "Enter your question to ask the knowledge base",
enterSearch: "Enter search query",
searchKBBtn: "Search knowledge base",
podcastViews: "Podcast views",
chatSessions: "Chat sessions",
ytVideo: "YouTube video",
askResponse: "Ask Response",
searchNotebooks: "Search notebooks",
@ -127,7 +117,6 @@ export const enUS = {
url: "URL",
errorDetails: "Error Details",
editTransformation: "Edit Transformation",
comingSoon: "Coming soon",
retry: "Try Again",
traditionalChinese: "繁體中文",
portuguese: "Português",
@ -165,7 +154,6 @@ export const enUS = {
failedToSendMessage: "Failed to send message",
unauthorized: "Unauthorized access, please check your password",
invalidPassword: "Invalid password",
missingAuth: "Missing authorization",
embeddingModelRequired: "This feature requires an embedding model. Please configure one in the Models section.",
strategyModelNotFound: "Strategy model not found",
answerModelNotFound: "Answer model not found",
@ -206,7 +194,6 @@ export const enUS = {
passwordPlaceholder: "Password",
signingIn: "Signing in...",
signIn: "Sign In",
unhandledError: "Unhandled error during login",
connectErrorHint: "Unable to connect to server. Please check if the API is running.",
},
navigation: {
@ -226,7 +213,6 @@ export const enUS = {
nav: "Navigation",
language: "Toggle language",
theme: "Theme",
search: "Search",
ask: "Ask",
},
notebooks: {
@ -248,12 +234,8 @@ export const enUS = {
keepExclusiveSourcesLabel: "Unlink and keep them",
activeNotebooks: "Active Notebooks",
archivedNotebooks: "Archived Notebooks",
emptyDescription: "Start by creating your first notebook to organize your research.",
noActiveNotebooks: "No active notebooks",
noArchivedNotebooks: "No archived notebooks",
notFound: "Notebook not found",
notFoundDesc: "The requested notebook does not exist.",
noDescription: "No description...",
updated: "Updated",
namePlaceholder: "Notebook name",
addDescription: "Add description...",
@ -278,10 +260,7 @@ export const enUS = {
add: "Add Source",
addNew: "Add New Source",
addExisting: "Add Existing Source",
empty: "No sources yet",
emptyDesc: "Add your first source to start building your knowledge base.",
delete: "Delete Source",
deleteMsg: "Are you sure you want to delete this source? This action cannot be undone.",
statusPreparing: "Preparing",
statusQueued: "Queued",
statusProcessing: "Processing",
@ -321,7 +300,6 @@ export const enUS = {
sourceRequeued: "Source Retry Queued",
sourceRequeuedDesc: "The source has been requeued for processing.",
failedToRetry: "Retry Failed",
failedToRetryDesc: "Failed to retry source processing. Please try again.",
sourcesAddedToNotebook: "{count} source(s) added to notebook",
failedToAddSourcesToNotebook: "Failed to add sources to notebook",
partialAddSuccess: "{success} source(s) added, {failed} failed",
@ -359,34 +337,31 @@ export const enUS = {
deleteInsight: "Delete Insight",
deleteInsightConfirm: "Are you sure you want to delete this insight? This action cannot be undone.",
insightGenerationStarted: "Insight generation started. It will appear shortly.",
deleteNoteConfirm: 'Are you sure you want to delete this note? This action cannot be undone.',
editNote: 'Edit note',
createNote: 'Create note',
addTitle: 'Add a title...',
untitledNote: 'Untitled Note',
writeNotePlaceholder: 'Write your note content here...',
saveNote: 'Save Note',
createNoteBtn: 'Create Note',
noNotesYet: "No notes yet",
editNote: "Edit note",
createNote: "Create note",
addTitle: "Add a title...",
untitledNote: "Untitled Note",
writeNotePlaceholder: "Write your note content here...",
saveNote: "Save Note",
createNoteBtn: "Create Note",
createFirstNote: "Create your first note to capture insights and observations.",
deleteNote: "Delete Note",
urlLabel: 'URL(s) *',
fileLabel: 'File(s) *',
textContentLabel: 'Text Content *',
enterUrlsPlaceholder: 'Enter URLs, one per line\nhttps://example.com/article1\nhttps://example.com/article2',
batchUrlHint: 'Paste multiple URLs (one per line) to batch import',
invalidUrlsDetected: 'Invalid URLs detected:',
lineLabel: 'Line {line}',
fixInvalidUrls: 'Please fix or remove invalid URLs to continue',
selectMultipleFilesHint: 'Select multiple files to batch import. Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)',
selectedFiles: 'Selected files:',
textPlaceholder: 'Paste or type your content here...',
htmlDetected: 'HTML content detected. It will be converted to Markdown after processing.',
titlePlaceholder: 'Give your source a descriptive title',
batchTitlesAuto: 'Titles will be automatically generated for each source.',
batchCommonSettings: 'The same notebooks and transformations will be applied to all items.',
urlsCount: '{count} URL(s)',
filesCount: '{count} file(s)',
urlLabel: "URL(s) *",
fileLabel: "File(s) *",
textContentLabel: "Text Content *",
enterUrlsPlaceholder: "Enter URLs, one per line\nhttps://example.com/article1\nhttps://example.com/article2",
batchUrlHint: "Paste multiple URLs (one per line) to batch import",
invalidUrlsDetected: "Invalid URLs detected:",
lineLabel: "Line {line}",
fixInvalidUrls: "Please fix or remove invalid URLs to continue",
selectMultipleFilesHint: "Select multiple files to batch import. Supported: Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Media (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)",
selectedFiles: "Selected files:",
textPlaceholder: "Paste or type your content here...",
htmlDetected: "HTML content detected. It will be converted to Markdown after processing.",
titlePlaceholder: "Give your source a descriptive title",
batchTitlesAuto: "Titles will be automatically generated for each source.",
batchCommonSettings: "The same notebooks and transformations will be applied to all items.",
urlsCount: "{count} URL(s)",
filesCount: "{count} file(s)",
addSource: "Add Source",
notEmbeddedAlert: "Content Not Embedded",
notEmbeddedDesc: "This content hasn't been embedded for vector search. Embedding enables advanced search capabilities and better content discovery.",
@ -403,7 +378,6 @@ export const enUS = {
retryProcessing: "Retry Processing",
deleteSource: "Delete Source",
retry: "Retry",
progress: "Progress",
addExistingTitle: "Add Existing Sources",
addExistingDesc: "Select existing sources from across all your notebooks to add to the current one.",
searchPlaceholder: "Search sources by name or URL...",
@ -435,8 +409,6 @@ export const enUS = {
batchFailed: "Failed to create all {count} sources",
batchPartial: "{success} succeeded, {failed} failed",
submittingSource: "Submitting source for processing...",
contentRequired: "Please provide the required content for the selected source type",
titleRequiredForText: "Title is required for text sources",
processingBatchSources: "Processing {count} sources. This may take a few moments.",
processingSource: "Your source is being processed. This may take a few moments.",
maxFilesAllowed: "Maximum {count} files allowed per batch",
@ -445,26 +417,20 @@ export const enUS = {
sessions: "Sessions",
sessionTitlePlaceholder: "Type a title here...",
noSessions: "No chat sessions yet",
startChatting: "Start chatting about your sources.",
deleteSession: "Delete Session",
deleteSessionDesc: "Are you sure you want to delete this chat session? This action cannot be undone.",
sendPlaceholder: "Ask anything about your sources...",
newChat: "New Chat",
sessionsTitle: "Chat Sessions",
clearhistory: "Clear History",
renameSession: "Rename Session",
noSourcesLinked: "No sources linked",
thinking: "AI is thinking...",
chatWith: "Chat with {name}",
startConversation: "Start a conversation about this {type}",
askQuestions: "Ask questions to understand the content better",
pressToSend: "Press {key} to send",
model: "Model",
createToStart: 'Create a session to start.',
chatWithNotebook: 'Chat with Notebook',
unableToLoadChat: 'Unable to load chat',
noDescription: 'No description',
startByCreating: 'Start by creating your first notebook to organize your research.',
createToStart: "Create a session to start.",
chatWithNotebook: "Chat with Notebook",
unableToLoadChat: "Unable to load chat",
noDescription: "No description",
startByCreating: "Start by creating your first notebook to organize your research.",
messagesCount: "{count} messages",
sessionCreated: "Chat session created",
sessionUpdated: "Session updated",
@ -508,8 +474,6 @@ export const enUS = {
saveSuccess: "Successfully saved to notebook",
saveError: "Failed to save to notebook",
selectNotebook: "Select Notebook",
createNewNotebook: "Create New Notebook",
cancel: "Cancel",
searchAndAsk: "Search & Ask",
searchResultsFor: "Search results for “{query}”",
askAbout: "Ask about “{query}”",
@ -734,8 +698,6 @@ export const enUS = {
speakerCountMin: "At least one speaker is required",
speakerCountMax: "You can configure up to 4 speakers",
delete: "Delete",
unknown: "Unknown",
deleteSuccess: "Podcast deleted successfully",
failedToDelete: "Failed to delete podcast",
},
settings: {
@ -772,13 +734,8 @@ export const enUS = {
title: "AdvancedTools",
desc: "Advanced tools and utilities for power users",
systemInfo: "System Info",
systemInfoDesc: "View the status of underlying system components",
rebuildEmbeddings: "Rebuild Embeddings",
rebuildEmbeddingsDesc: "Rebuild vector search index for all sources",
rebuildWarning: "This action can be very time-consuming depending on the number of sources you have. It will clear existing vector indices and re-generate embeddings for everything.",
startRebuild: "Start Rebuild",
rebuilding: "Rebuilding...",
rebuildSuccess: "Embedding rebuild started successfully",
currentVersion: "Current Version",
latestVersion: "Latest Version",
status: "Status",
@ -823,101 +780,53 @@ export const enUS = {
defaultPrompt: "Default Transformation Prompt",
defaultPromptDesc: "This will be added to all your transformation prompts",
defaultPromptPlaceholder: "Enter your default transformation instructions...",
saveDefault: "Save Default",
listTitle: "Custom Transformations",
createNew: "Create New",
testInPlayground: "Test in Playground",
inputLabel: "Input Text",
inputPlaceholder: 'Enter some text to transform...',
outputLabel: 'Output',
runTest: 'Run Transformation',
running: 'Running...',
selectToStart: 'Select a transformation to start',
name: 'Name',
namePlaceholder: 'Unique identifier, e.g. key_topics',
titlePlaceholder: 'Displayed title, defaults to name',
promptPlaceholder: 'Write the prompt that will power this transformation...',
descriptionPlaceholder: 'Describe what this transformation does.',
suggestDefault: 'Suggest by default on new sources',
promptHint: 'Prompts should be written with the source content in mind. You can ask the model to summarise, extract insights, or produce structured outputs such as tables.',
createSuccess: 'Transformation created successfully',
updateSuccess: 'Transformation updated successfully',
deleteSuccess: 'Transformation deleted successfully',
inputPlaceholder: "Enter some text to transform...",
outputLabel: "Output",
runTest: "Run Transformation",
running: "Running...",
selectToStart: "Select a transformation to start",
name: "Name",
namePlaceholder: "Unique identifier, e.g. key_topics",
titlePlaceholder: "Displayed title, defaults to name",
promptPlaceholder: "Write the prompt that will power this transformation...",
descriptionPlaceholder: "Describe what this transformation does.",
suggestDefault: "Suggest by default on new sources",
promptHint: "Prompts should be written with the source content in mind. You can ask the model to summarise, extract insights, or produce structured outputs such as tables.",
createSuccess: "Transformation created successfully",
updateSuccess: "Transformation updated successfully",
deleteSuccess: "Transformation deleted successfully",
noTransformations: "No transformations yet",
createOne: "Create a transformation to get started",
deleteDesc: "Deleting this transformation cannot be undone.",
selectModel: "Select a model",
deleteConfirm: "Are you sure you want to delete this transformation?",
model: "Model",
systemPrompt: "System Prompt",
type: "Type",
extraction: "Extraction",
summary: "Summary",
custom: "Custom",
saveChanges: "Save Changes",
overrideModelDesc: "Override the default model for this chat session. Leave empty to use the system default.",
sessionUseReplacement: "This session will use {name} instead of the default model.",
systemDefault: "System Default",
},
models: {
title: "Model Management",
desc: "Configure AI models for different purposes across Open Notebook",
failedToLoad: "Failed to load models data",
language: "Language Models",
embedding: "Embedding Models",
tts: "Text to Speech (TTS)",
stt: "Speech to Text (STT)",
providers: "Providers",
defaultModels: "Default Models",
status: "Status",
notConfigured: "Not configured",
active: "Active",
inactive: "Inactive",
configure: "Configure",
saveChanges: "Save Changes",
addModel: "Add Model",
modelName: "Model Name",
provider: "Provider",
apiKey: "API Key",
baseUrl: "Base URL",
capabilities: "Capabilities",
enabled: "Enabled",
disabled: "Disabled",
deleteConfirm: "Are you sure you want to delete this model?",
deleteSuccess: "Model deleted successfully",
saveSuccess: "Model saved successfully",
providerStatus: "Provider Status",
connectionOk: "Connection OK",
connectionFailed: "Connection failed",
changeEmbeddingWarning: "Changing the default embedding model will affect new sources. Existing sources might need to be re-indexed.",
changeEmbeddingTitle: "Change Default Embedding Model?",
aiProviders: "AI Providers",
providerConfigDesc: "Configure providers through environment variables to enable their models.",
configuredCount: "{count} of {total} configured",
noModels: "No models",
learnMore: "Learn how to configure providers →",
seeLess: "See less",
seeAll: "See all {count} providers",
language_models: "Language Models",
embedding_models: "Embedding Models",
text_to_speech: "Text to Speech (TTS)",
speech_to_text: "Speech to Text (STT)",
languageDesc: "Chat, transformations, and text generation",
embeddingDesc: "Semantic search and vector embeddings",
ttsDesc: "Generate audio from text",
sttDesc: "Transcribe audio to text",
all: "All",
noModelsConfigured: "No models configured",
noProviderModelsConfigured: "No {provider} models configured",
showMore: "Show {count} more",
discoverModels: "Discover Models",
noModelsFound: "No models found from this provider",
modelType: "Model Type",
modelTypeHint: "Select the type for the models you want to add. If you need different types, add them in separate batches.",
deleteModel: "Delete Model",
deleteModelDesc: "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
defaultAssignments: "Default Model Assignments",
defaultAssignmentsDesc: "Configure which models to use for different purposes across Open Notebook",
missingRequiredModels: "Missing required models: {models}. Open Notebook may not function properly without these.",
selectModelPlaceholder: "Select a model",
requiredModelPlaceholder: "⚠️ Required - Select a model",
whichModelToChoose: "Which model should I choose? →",
chatModelLabel: "Chat Model",
chatModelDesc: "Used for chat conversations",
transformationModelLabel: "Transformation Model",
@ -932,16 +841,9 @@ export const enUS = {
ttsModelDesc: "Used for podcast generation",
sttModelLabel: "Speech-to-Text Model",
sttModelDesc: "Used for audio transcription",
addSpecificModel: "Add {type} Model",
addSpecificModelDesc: "Configure a new {type} model from available providers.",
noProvidersForType: "No providers available for {type} models",
selectProviderPlaceholder: "Select a provider",
providerRequired: "Provider is required",
modelNameRequired: "Model name is required",
modelRequired: "Model is required",
adding: "Adding...",
azureHint: "For Azure, use the deployment name as the model name",
enterModelName: "Enter model name",
embeddingChangeTitle: "Embedding Model Change",
embeddingChangeConfirm: "You are about to change your embedding model from {from} to {to}.",
rebuildRequired: "Important: Rebuild Required",
@ -954,5 +856,62 @@ export const enUS = {
proceedToRebuildPrompt: "Would you like to proceed to the Advanced page to start the rebuild now?",
changeModelOnly: "Change Model Only",
changeAndRebuild: "Change & Go to Rebuild",
}
autoAssign: "Auto-assign Defaults",
autoAssigning: "Assigning...",
autoAssignSuccess: "{count} default models automatically assigned",
autoAssignNoModels: "No models available to assign. Please sync models first.",
autoAssignAlreadySet: "All default models are already configured",
testModel: "Test Model",
testModelSuccess: "Model Test Passed",
testModelFailed: "Model Test Failed",
searchOrAddModel: "Search or type a model name...",
addCustomModel: "Add \"{name}\"",
},
apiKeys: {
title: "Configure your AI with your own API keys",
description: "Store API keys securely in the database to enable AI providers in Open Notebook.",
encryptionRequired: "Encryption key not configured",
encryptionRequiredDescription: "Set the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable to any secret string to enable storing API keys in the database.",
configured: "Configured",
notConfigured: "Not configured",
migrationAvailable: "Environment Variables Detected",
migrationDescription: "{count} API key(s) are configured via environment variables and can be migrated to the database for easier management.",
migrateToDatabase: "Migrate to Database",
migrating: "Migrating...",
migrationSuccess: "{count} API key(s) migrated successfully",
migrationErrors: "{count} key(s) failed to migrate",
migrationNothingToMigrate: "All keys are already in the database",
learnMore: "Learn how to configure API keys →",
testConnection: "Test Connection",
testSuccess: "Connection successful",
testFailed: "Connection test failed",
syncModels: "Sync Models",
syncSuccess: "Discovered {discovered} models, added {new} new",
syncNoNew: "Discovered {count} models, all already registered",
syncFailed: "Failed to sync models",
getApiKey: "Get API Key",
vertexProject: "GCP Project ID",
vertexLocation: "Region",
vertexCredentials: "Service Account JSON Path",
addConfig: "Add Configuration",
editConfig: "Edit Configuration",
deleteConfig: "Delete Configuration",
configName: "Configuration Name",
configNameHint: "A descriptive name for this configuration (e.g., 'Production', 'Development')",
baseUrl: "Base URL",
baseUrlOverrideHint: "Only change this if you need to override the provider's default API endpoint.",
deleteConfigConfirm: "Are you sure you want to delete '{name}'? This cannot be undone.",
configSaveSuccess: "Configuration saved successfully",
configUpdateSuccess: "Configuration updated successfully",
configDeleteSuccess: "Configuration deleted successfully",
apiKeyEditHint: "Leave blank to keep the existing API key",
},
setupBanner: {
encryptionRequired: "Encryption key not configured",
encryptionRequiredDescription: "Set the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable to enable secure credential storage.",
migrationAvailable: "API key migration available",
migrationDescription: "{count} provider(s) have API keys set via environment variables. Migrate them to the database for easier management.",
goToSettings: "Go to Settings",
viewDocs: "View docs",
},
}

View file

@ -0,0 +1,917 @@
export const frFR = {
common: {
search: "Recherche...",
create: "Créer",
new: "Nouveau",
cancel: "Annuler",
delete: "Supprimer",
edit: "Modifier",
theme: "Thème",
signOut: "Se déconnecter",
noMatches: "Aucun résultat trouvé",
tryDifferentSearch: "Essayez d'utiliser un terme de recherche différent.",
light: "Clair",
dark: "Sombre",
system: "Système",
loading: "Chargement...",
note: "Note",
insight: "Aperçu",
newSource: "Nouvelle Source",
newNotebook: "Nouveau Carnet",
newPodcast: "Nouveau Podcast",
language: "Langue",
english: "English",
chinese: "简体中文",
japanese: "日本語",
french: "Français",
russian: "Русский",
source: "Source",
notebook: "Carnet",
podcast: "Podcast",
quickActions: "Actions rapides",
quickActionsDesc: "Navigation, recherche, poser une question, thème",
appName: "Open Notebook",
add: "Ajouter",
remove: "Retirer",
confirm: "Confirmer",
warning: "Avertissement",
error: "Erreur",
success: "Succès",
model: "Modèle",
back: "Retour",
next: "Suivant",
done: "Terminé",
processing: "Traitement...",
creating: "Création...",
linked: "Lié",
adding: "Ajout en cours...",
addSelected: "Ajouter la sélection",
customModel: "Modèle personnalisé",
failed: "échec",
current: "Actuel",
save: "Enregistrer",
writeNote: "Écrire une note",
batchMode: "Mode par lot",
optional: "Optionnel",
type: "Type",
title: "Titre",
created: "Créé à {time}",
updated: "Mis à jour à {time}",
actions: "Actions",
noResults: "Aucun résultat",
references: "Références",
refreshPage: "Veuillez essayer de rafraîchir la page",
refresh: "Rafraîchir",
aiGenerated: "Généré par IA",
human: "Humain",
unknown: "Inconnu",
notes: "Notes",
chat: "Chat",
deleteForever: "Supprimer définitivement",
connectionError: "Erreur de connexion",
unableToConnect: "Impossible de se connecter au serveur API",
retryConnection: "Réessayer la connexion",
diagnosticInfo: "Informations de diagnostic",
version: "Version",
built: "Compilé le",
apiUrl: "URL de l'API",
frontendUrl: "URL du Frontend",
checkConsoleLogs: "Vérifiez la console du navigateur pour les logs détaillés (cherchez les messages 🔧 [Config])",
yes: "Oui",
no: "Non",
saving: "Enregistrement...",
description: "Description",
saveToNote: "Enregistrer dans la note",
copyToClipboard: "Copier dans le presse-papiers",
close: "Fermer",
insights: "Analyses",
progress: "Progression",
deleting: "Suppression...",
created_label: "Créé",
updated_label: "Mis à jour",
download: "Télécharger",
saveChanges: "Enregistrer les modifications",
name: "Nom",
default: "Par défaut",
nameRequired: "Le nom est requis",
modelConfiguration: "Configuration du modèle",
resetToDefault: "Réinitialiser",
reasoning: "Raisonnement",
searchTerms: "Termes de recherche",
strategy: "Stratégie",
individualAnswers: "Réponses individuelles ({count})",
finalAnswer: "Réponse finale",
notebookLabel: "Carnet : {name}",
itemNotFound: "Ce {type} est introuvable",
accessibility: {
transformationViews: "Vues de transformation",
searchKB: "Interroger ou fouiller votre base de connaissances",
enterQuestion: "Entrez votre question pour interroger la base de connaissances",
enterSearch: "Entrez votre recherche",
searchKBBtn: "Rechercher dans la base de connaissances",
podcastViews: "Vues podcast",
ytVideo: "Vidéo YouTube",
askResponse: "Réponse à la question",
searchNotebooks: "Rechercher dans les carnets",
},
url: "URL",
errorDetails: "Détails de l'erreur",
editTransformation: "Modifier la transformation",
retry: "Réessayer",
traditionalChinese: "繁體中文",
portuguese: "Português",
completed: "terminé",
saveSuccess: "Enregistré avec succès",
contextModes: {
off: "Non inclus dans le chat",
insights: "Analyses uniquement",
full: "Contenu complet",
clickToCycle: "Cliquez pour faire défiler",
},
clickToEdit: "Cliquez pour modifier",
},
apiErrors: {
notebookNotFound: "Carnet introuvable",
sourceNotFound: "Source introuvable",
transformationNotFound: "Transformation introuvable",
fileUploadFailed: "Échec du téléchargement du fichier",
urlRequired: "L'URL est requise pour le type lien",
contentRequired: "Le contenu est requis pour le type texte",
invalidSourceType: "Type de source invalide",
processingFailed: "Échec du traitement",
failedToQueue: "Échec de la mise en file d'attente du traitement",
invalidSortBy: "Le champ de tri doit être 'created' ou 'updated'",
invalidSortOrder: "L'ordre de tri doit être 'asc' ou 'desc'",
accessDenied: "Accès au fichier refusé",
fileNotFoundOnServer: "Fichier introuvable sur le serveur",
searchFailed: "La recherche a échoué",
askFailed: "La demande a échoué",
pleaseEnterQuestion: "Veuillez entrer une question",
pleaseConfigureModels: "Veuillez configurer tous les modèles requis",
failedToCreateSession: "Échec de la création de la session",
failedToUpdateSession: "Échec de la mise à jour de la session",
failedToDeleteSession: "Échec de la suppression de la session",
failedToSendMessage: "Échec de l'envoi du message",
unauthorized: "Accès non autorisé, veuillez vérifier votre mot de passe",
invalidPassword: "Mot de passe invalide",
embeddingModelRequired: "Cette fonctionnalité nécessite un modèle d'embedding. Veuillez en configurer un dans la section Modèles.",
strategyModelNotFound: "Modèle de stratégie introuvable",
answerModelNotFound: "Modèle de réponse introuvable",
finalAnswerModelNotFound: "Modèle de réponse finale introuvable",
noAnswerGenerated: "Aucune réponse n'a pu être générée",
genericError: "Une erreur inattendue est survenue",
},
connectionErrors: {
apiTitle: "Impossible de se connecter au serveur API",
apiDesc: "Le serveur API de Open Notebook est injoignable",
dbTitle: "Échec de la connexion à la base de données",
dbDesc: "Le serveur API fonctionne, mais la base de données n'est pas accessible",
troubleshooting: "Cela signifie généralement :",
apiUnreachable1: "Le serveur API n'est pas lancé",
apiUnreachable2: "Le serveur API fonctionne sur une adresse différente",
apiUnreachable3: "Problèmes de connectivité réseau",
dbFailed1: "SurrealDB n'est pas lancé",
dbFailed2: "Les paramètres de connexion à la base de données sont incorrects",
dbFailed3: "Problèmes réseau entre l'API et la base de données",
quickFixes: "Solutions rapides :",
setApiUrl: "Définissez la variable d'environnement API_URL :",
checkSurreal: "Vérifiez si SurrealDB est lancé :",
seeDocumentation: "Pour des instructions de configuration détaillées, consultez :",
docLink: "Documentation de Open Notebook",
showTechnical: "Afficher les détails techniques",
attemptedUrl: "URL tentée",
message: "Message",
technicalDetails: "Détails techniques",
stackTrace: "Trace de la pile (Stack Trace)",
retryLabel: "Réessayer la connexion",
retryHint: "Appuyez sur R ou cliquez sur le bouton pour réessayer",
dockerLabel: "Pour Docker",
localDevLabel: "Pour le développement local",
},
auth: {
loginTitle: "Open Notebook",
loginDesc: "Entrez votre mot de passe pour accéder à l'application",
passwordPlaceholder: "Mot de passe",
signingIn: "Connexion...",
signIn: "Se connecter",
connectErrorHint: "Impossible de se connecter au serveur. Veuillez vérifier si l'API est lancée.",
},
navigation: {
collect: "Collecter",
process: "Traiter",
create: "Créer",
manage: "Gérer",
sources: "Sources",
notebooks: "Carnets",
askAndSearch: "Demander et rechercher",
podcasts: "Podcasts",
models: "Modèles",
transformations: "Transformations",
transformation: "Transformation",
settings: "Paramètres",
advanced: "Avancé",
nav: "Navigation",
language: "Changer de langue",
theme: "Thème",
ask: "Demander",
},
notebooks: {
title: "Carnets",
newNotebook: "Nouveau Carnet",
searchPlaceholder: "Rechercher des carnets...",
archived: "Archivé",
archive: "Archiver",
unarchive: "Désarchiver",
deleteNotebook: "Supprimer le carnet",
deleteNotebookDesc: "Êtes-vous sûr de vouloir supprimer \"{name}\" ? Cette action est irréversible.",
deleteNotebookLoading: "Chargement de l'aperçu de suppression...",
deleteNotebookNotes: "{count} note(s) seront supprimées définitivement.",
deleteNotebookNoNotes: "Aucune note à supprimer.",
deleteNotebookExclusiveSources: "{count} source(s) existent uniquement dans ce carnet.",
deleteNotebookSharedSources: "{count} source(s) sont partagées avec d'autres carnets et seront déliées.",
deleteNotebookNoSources: "Aucune source dans ce carnet.",
deleteExclusiveSourcesLabel: "Supprimer les sources exclusives",
keepExclusiveSourcesLabel: "Délier et les conserver",
activeNotebooks: "Carnets actifs",
archivedNotebooks: "Carnets archivés",
notFound: "Carnet introuvable",
notFoundDesc: "Le carnet demandé n'existe pas.",
updated: "Mis à jour",
namePlaceholder: "Nom du carnet",
addDescription: "Ajouter une description...",
noNotesYet: "Aucune note pour le moment",
deleteNote: "Supprimer la note",
deleteNoteConfirm: "Êtes-vous sûr de vouloir supprimer cette note ? Cette action est irréversible.",
noteCreatedSuccess: "Note créée avec succès",
failedToCreateNote: "Échec de la création de la note",
noteUpdatedSuccess: "Note mise à jour avec succès",
failedToUpdateNote: "Échec de la mise à jour de la note",
noteDeletedSuccess: "Note supprimée avec succès",
failedToDeleteNote: "Échec de la suppression de la note",
createNew: "Créer un nouveau carnet",
createNewDesc: "Entrez un nom et une description facultative pour commencer.",
descPlaceholder: "Ajoutez plus d'informations sur ce carnet ici...",
createSuccess: "Carnet créé avec succès",
updateSuccess: "Carnet mis à jour avec succès",
deleteSuccess: "Carnet supprimé avec succès",
},
sources: {
title: "Sources",
add: "Ajouter une source",
addNew: "Ajouter une nouvelle source",
addExisting: "Ajouter une source existante",
delete: "Supprimer la source",
statusPreparing: "Préparation",
statusQueued: "En attente",
statusProcessing: "Traitement",
statusCompleted: "Terminé",
statusFailed: "Échec",
statusPreparingDesc: "Préparation au traitement",
statusQueuedDesc: "En attente de traitement",
statusProcessingDesc: "En cours de traitement",
statusCompletedDesc: "Traitée avec succès",
statusFailedDesc: "Échec du traitement",
failedToLoad: "Échec du chargement des sources",
allSourcesDesc: "Affichez toutes vos sources ici. Vous pouvez en ajouter de nouvelles ou gérer les existantes.",
allSources: "Toutes les sources",
insights: "Aperçus",
yes: "Oui",
no: "Non",
loadingMore: "Chargement...",
noSourcesYet: "Aucune source pour le moment",
allSourcesDescShort: "Affichez toutes vos sources ici.",
cannotSaveNoteNoNotebook: "Impossible d'enregistrer la note : ID du carnet non disponible",
createFirstSource: "Ajoutez votre première source pour commencer à bâtir votre base de connaissances.",
deleteSourceConfirm: "Êtes-vous sûr de vouloir supprimer cette source ?",
deleteConfirm: "Êtes-vous sûr de vouloir supprimer cet élément ?",
deleteConfirmWithTitle: "Êtes-vous sûr de vouloir supprimer \"{title}\" ?",
deleteSuccess: "Source supprimée avec succès. Note : Pour supprimer le fichier du stockage, vous devez activer l'option \"supprimer le fichier\" dans la page des paramètres.",
failedToDelete: "Échec de la suppression de la source",
sourceQueued: "Source mise en attente",
sourceQueuedDesc: "Source soumise pour traitement en arrière-plan. Vous pouvez suivre la progression dans la liste des sources.",
sourceAddedSuccess: "Source ajoutée avec succès",
failedToAddSource: "Échec de l'ajout de la source",
sourceUpdatedSuccess: "Source mise à jour avec succès",
failedToUpdateSource: "Échec de la mise à jour de la source",
sourceDeletedSuccess: "Source supprimée avec succès",
failedToDeleteSource: "Échec de la suppression de la source",
fileUploadedSuccess: "Fichier téléchargé avec succès",
failedToUploadFile: "Échec du téléchargement du fichier",
sourceRequeued: "Nouvelle tentative de traitement mise en attente",
sourceRequeuedDesc: "La source a été remise en file d'attente pour traitement.",
failedToRetry: "Échec de la tentative",
sourcesAddedToNotebook: "{count} source(s) ajoutée(s) au carnet",
failedToAddSourcesToNotebook: "Échec de l'ajout des sources au carnet",
partialAddSuccess: "{success} source(s) ajoutée(s), {failed} échouée(s)",
sourceRemovedFromNotebook: "Source retirée du carnet avec succès",
failedToRemoveSourceFromNotebook: "Échec du retrait de la source du carnet",
removeConfirm: "Êtes-vous sûr de vouloir retirer cet élément du carnet ?",
checking: "Vérification...",
untitledSource: "Source sans titre",
maxItems: "max {count}",
insightsCount: "{count} aperçus",
details: "Détails",
detailsTitle: "Détails de la source",
content: "Contenu",
metadata: "Métadonnées",
type: {
link: "Lien",
file: "Fichier",
text: "Texte",
},
id: "ID de la source",
topics: "Sujets",
embedded: "Indexé (Embedded)",
notEmbedded: "Non indexé",
embedContent: "Indexer le contenu",
embedding: "Indexation en cours...",
alreadyEmbedded: "Déjà indexé",
downloadFile: "Télécharger le fichier",
fileUnavailable: "Fichier indisponible",
preparing: "Préparation...",
generateNewInsight: "Générer un nouvel aperçu",
selectTransformation: "Sélectionner une transformation...",
noInsightsYet: "Aucun aperçu pour le moment",
createFirstInsight: "Créez votre premier aperçu en utilisant une transformation ci-dessus",
viewInsight: "Voir l'aperçu",
deleteInsight: "Supprimer l'aperçu",
deleteInsightConfirm: "Êtes-vous sûr de vouloir supprimer cet aperçu ? Cette action est irréversible.",
insightGenerationStarted: "Génération de l'aperçu lancée. Il apparaîtra sous peu.",
editNote: "Modifier la note",
createNote: "Créer une note",
addTitle: "Ajouter un titre...",
untitledNote: "Note sans titre",
writeNotePlaceholder: "Écrivez le contenu de votre note ici...",
saveNote: "Enregistrer la note",
createNoteBtn: "Créer la note",
createFirstNote: "Créez votre première note pour capturer des idées et des observations.",
urlLabel: "URL(s) *",
fileLabel: "Fichier(s) *",
textContentLabel: "Contenu textuel *",
enterUrlsPlaceholder: "Entrez les URL, une par ligne\nhttps://exemple.com/article1\nhttps://exemple.com/article2",
batchUrlHint: "Collez plusieurs URL (une par ligne) pour une importation groupée",
invalidUrlsDetected: "URL invalides détectées :",
lineLabel: "Ligne {line}",
fixInvalidUrls: "Veuillez corriger ou supprimer les URL invalides pour continuer",
selectMultipleFilesHint: "Sélectionnez plusieurs fichiers pour une importation groupée. Supportés : Documents (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD), Média (MP4, MP3, WAV, M4A), Images (JPG, PNG), Archives (ZIP)",
selectedFiles: "Fichiers sélectionnés :",
textPlaceholder: "Collez ou tapez votre contenu ici...",
htmlDetected: "Contenu HTML détecté. Il sera converti en Markdown après traitement.",
titlePlaceholder: "Donnez un titre descriptif à votre source",
batchTitlesAuto: "Les titres seront générés automatiquement pour chaque source.",
batchCommonSettings: "Les mêmes carnets et transformations seront appliqués à tous les éléments.",
urlsCount: "{count} URL(s)",
filesCount: "{count} fichier(s)",
addSource: "Ajouter la source",
notEmbeddedAlert: "Contenu non indexé",
notEmbeddedDesc: "Ce contenu n'a pas été indexé pour la recherche vectorielle. L'indexation permet des capacités de recherche avancées et une meilleure découverte de contenu.",
openOnYoutube: "Ouvrir sur YouTube",
urlCopied: "URL copiée dans le presse-papiers",
viewSource: "Voir la source",
noInsightSelected: "Aucun aperçu sélectionné",
sourceInsight: "Aperçu de la source",
manageNotebooks: "Gérer les carnets",
manageNotebooksDesc: "Gérer quels carnets contiennent cette source",
noNotebooksAvailable: "Aucun carnet disponible",
loadFailed: "Échec du chargement des détails de la source",
removeFromNotebook: "Retirer du carnet",
retryProcessing: "Réessayer le traitement",
deleteSource: "Supprimer la source",
retry: "Réessayer",
addExistingTitle: "Ajouter des sources existantes",
addExistingDesc: "Sélectionnez des sources existantes parmi tous vos carnets pour les ajouter au carnet actuel.",
searchPlaceholder: "Rechercher des sources par nom ou URL...",
noNotebooksFound: "Aucun carnet trouvé.",
showingFirst100: "Affichage des 100 premières sources. Utilisez la recherche pour en trouver des spécifiques.",
selectedCount: "{count} sources sélectionnées",
added: "Ajouté le {date}",
addUrl: "Ajouter une URL",
uploadFile: "Télécharger un fichier",
enterText: "Saisir du texte",
processDescription: "Le contenu sera traité et analysé par l'IA.",
processingFiles: "Traitement de vos fichiers...",
titleRequired: "Un titre est requis pour le contenu textuel",
titleGenerated: "Si laissé vide, un titre sera généré à partir du contenu",
batchCount: "{count} {type} seront traités",
enableEmbedding: "Activer l'indexation pour la recherche",
embeddingDesc: "Permet à cette source d'être trouvée dans les recherches vectorielles et les requêtes IA",
embeddingAlways: "Indexation activée automatiquement",
embeddingAlwaysDesc: "Vos paramètres sont configurés pour toujours indexer le contenu pour la recherche vectorielle.",
embeddingNever: "Indexation désactivée",
embeddingNeverDesc: "Vos paramètres sont configurés pour ignorer l'indexation. La recherche vectorielle ne sera pas disponible pour cette source.",
changeInSettings: "Vous pouvez modifier cela dans les Paramètres",
notFound: "Source introuvable",
noContent: "Aucun contenu disponible",
insightsDesc: "Aperçus générés par l'analyse du modèle",
uploadedFile: "Fichier téléchargé",
fileUnavailableDesc: "Ce fichier est actuellement indisponible pour des raisons liées au système de stockage.",
batchSuccess: "{count} source(s) créée(s) avec succès",
batchFailed: "Échec de la création des {count} sources",
batchPartial: "{success} réussies, {failed} échouées",
submittingSource: "Soumission de la source pour traitement...",
processingBatchSources: "Traitement de {count} sources. Cela peut prendre quelques instants.",
processingSource: "Votre source est en cours de traitement. Cela peut prendre quelques instants.",
maxFilesAllowed: "Maximum {count} fichiers autorisés par lot",
},
chat: {
sessions: "Sessions",
sessionTitlePlaceholder: "Saisissez un titre ici...",
noSessions: "Aucune session de chat pour le moment",
deleteSession: "Supprimer la session",
deleteSessionDesc: "Êtes-vous sûr de vouloir supprimer cette session de chat ? Cette action est irréversible.",
sendPlaceholder: "Posez n'importe quelle question sur vos sources...",
sessionsTitle: "Sessions de Chat",
chatWith: "Discuter avec {name}",
startConversation: "Commencer une conversation sur ce {type}",
askQuestions: "Posez des questions pour mieux comprendre le contenu",
pressToSend: "Appuyez sur {key} pour envoyer",
model: "Modèle",
createToStart: "Créez une session pour commencer.",
chatWithNotebook: "Discuter avec le Carnet",
unableToLoadChat: "Impossible de charger le chat",
noDescription: "Aucune description",
startByCreating: "Commencez par créer votre premier carnet pour organiser vos recherches.",
messagesCount: "{count} messages",
sessionCreated: "Session de chat créée",
sessionUpdated: "Session mise à jour",
sessionDeleted: "Session supprimée",
},
searchPage: {
askAndSearch: "Poser une question et Rechercher",
chooseAMode: "Choisir un mode",
askBeta: "Demander (bêta)",
search: "Recherche",
askYourKb: "Interroger votre base de connaissances (bêta)",
askYourKbDesc: "Le LLM répondra à votre requête en se basant sur les documents de votre base de connaissances.",
question: "Question",
enterQuestionPlaceholder: "Entrez votre question...",
pressToSubmit: "Appuyez sur Cmd/Ctrl+Entrée pour envoyer",
noEmbeddingModel: "Vous ne pouvez pas utiliser cette fonctionnalité car aucun modèle d'embedding n'est sélectionné. Veuillez en configurer un dans la page Modèles.",
usingCustomModels: "Utilisation de modèles personnalisés",
usingDefaultModels: "Utilisation des modèles par défaut",
advanced: "Avancé",
strategy: "Stratégie",
answer: "Réponse",
final: "Final",
ask: "Demander",
processing: "Traitement...",
saveToNotebooks: "Enregistrer dans les Carnets",
searchDesc: "Recherchez des mots-clés ou des concepts spécifiques dans votre base de connaissances",
enterSearchPlaceholder: "Entrez votre recherche...",
pressToSearch: "Appuyez sur Entrée pour rechercher",
searchType: "Type de recherche",
vectorSearchWarning: "La recherche vectorielle nécessite un modèle d'embedding. Seule la recherche textuelle est disponible.",
textSearch: "Recherche textuelle",
vectorSearch: "Recherche vectorielle",
searchIn: "Rechercher dans",
searchSources: "Rechercher dans les Sources",
searchNotes: "Rechercher dans les Notes",
resultsFound: "{count} résultats trouvés",
matches: "Correspondances ({count})",
noResultsFor: "Aucun résultat trouvé pour “{query}”",
notSet: "Non défini",
saveToNotebook: "Enregistrer dans le Carnet",
saveSuccess: "Enregistré avec succès dans le carnet",
saveError: "Échec de l'enregistrement dans le carnet",
selectNotebook: "Sélectionner un carnet",
searchAndAsk: "Rechercher & Demander",
searchResultsFor: "Résultats de recherche pour “{query}”",
askAbout: "Poser une question sur “{query}”",
orSearchKb: "Ou rechercher dans votre base de connaissances",
saving: "Enregistrement...",
advancedModelTitle: "Sélection de modèle avancée",
advancedModelDesc: "Choisissez des modèles spécifiques pour chaque étape du processus de demande",
strategyModel: "Modèle de stratégie",
answerModel: "Modèle de réponse",
finalAnswerModel: "Modèle de réponse finale",
selectStrategyPlaceholder: "Sélectionner le modèle de stratégie",
selectAnswerPlaceholder: "Sélectionner le modèle de réponse",
selectFinalPlaceholder: "Sélectionner le modèle final",
saveChanges: "Enregistrer les modifications",
processingQuestion: "Traitement de votre question...",
},
podcasts: {
generateEpisode: "Générer un épisode de podcast",
generateEpisodeDesc: "Sélectionnez le contenu à inclure et configurez les détails de l'épisode avant de générer un nouvel épisode de podcast.",
content: "Contenu",
contentDesc: "Choisissez les carnets, sources et notes à inclure dans cet épisode.",
itemsSelected: "{count} éléments sélectionnés",
tokens: "{count} tokens",
chars: "{count} caractères",
loadingNotebooks: "Chargement des carnets...",
noNotebooksFoundInPodcasts: "Aucun carnet trouvé. Créez un carnet et ajoutez du contenu avant de générer un podcast.",
noContentSelected: "Aucun contenu sélectionné",
summary: "Résumé",
fullContent: "Contenu complet",
untitledSource: "Source sans titre",
untitledNote: "Note sans titre",
episodeSettings: "Paramètres de l'épisode",
episodeProfile: "Profil de l'épisode",
episodeProfilePlaceholder: "Sélectionnez un profil d'épisode",
episodeName: "Nom de l'épisode",
episodeNamePlaceholder: "ex: L'IA et le futur du travail",
additionalInstructions: "Instructions supplémentaires",
instructionsPlaceholder: "Tout conseil supplémentaire à ajouter au briefing de l'épisode...",
generating: "Génération...",
generate: "Générer",
hostPlaceholder: "Hôte {number}",
profileRequired: "Profil d'épisode requis",
profileRequiredDesc: "Sélectionnez un profil d'épisode avant de générer un podcast.",
nameRequired: "Nom de l'épisode requis",
nameRequiredDesc: "Fournissez un nom pour l'épisode.",
addContext: "Ajouter du contexte",
addContextDesc: "Sélectionnez au moins une source ou une note à inclure dans l'épisode.",
generationFailed: "Échec de la génération du podcast",
speakerProfile: "Profil de l'intervenant",
usesSpeakerProfile: "Utilise le profil de l'intervenant",
sources: "Sources",
notes: "Notes",
noSources: "Aucune source disponible dans ce carnet.",
noNotes: "Aucune note disponible dans ce carnet.",
selectMode: "Sélectionner le mode",
buildContextFailed: "Échec de la construction du contexte. Veuillez vérifier vos sélections.",
podcastTaskStarted: "Tâche de podcast démarrée",
loadingProfiles: "Chargement des profils d'épisode...",
noProfilesFound: "Aucun profil d'épisode trouvé. Créez un profil d'épisode avant de générer un podcast.",
listTitle: "Podcasts",
listDesc: "Suivez les épisodes générés et gérez les modèles réutilisables.",
chooseAView: "Choisir une vue",
episodesTab: "Épisodes",
templatesTab: "Modèles",
overviewTitle: "Aperçu des épisodes",
overviewDesc: "Surveillez les tâches de génération de podcast et consultez les artefacts finaux.",
generateBtn: "Générer un podcast",
total: "Total",
processingLabel: "En cours",
completedLabel: "Terminé",
failedLabel: "Échoué",
pendingLabel: "En attente",
loadErrorTitle: "Échec du chargement des épisodes",
loadErrorDesc: "Nous n'avons pas pu récupérer les derniers épisodes. Réessayez dans un instant.",
loadingEpisodes: "Chargement des épisodes…",
noEpisodesYet: "Aucun épisode de podcast pour le moment. Générez votre premier depuis le carnet ou les interfaces de chat.",
statusRunningTitle: "En cours de traitement",
statusRunningDesc: "Épisodes dont les ressources sont activement en cours de génération.",
statusPendingTitle: "En file d'attente / En attente",
statusPendingDesc: "Épisodes soumis en attente de traitement.",
statusCompletedTitle: "Épisodes terminés",
statusCompletedDesc: "Prêts à être consultés, téléchargés ou publiés.",
statusFailedTitle: "Épisodes échoués",
statusFailedDesc: "Épisodes ayant rencontré des problèmes lors de la génération.",
templatesWorkspaceTitle: "Espace de travail des modèles",
templatesWorkspaceDesc: "Créez des configurations d'épisodes et d'intervenants réutilisables pour une production rapide.",
howTemplatesPowerTitle: "Comment les modèles propulsent la génération",
howTemplatesPowerDesc: "Les modèles divisent le flux de travail en deux blocs réutilisables. Mélangez-les à chaque génération d'épisode.",
episodeProfilesSetFormat: "Les profils d'épisode définissent le format",
episodeProfilesList1: "Définissez le nombre de segments et le déroulement de l'histoire",
episodeProfilesList2: "Choisissez les modèles de langue pour le briefing, le plan et l'écriture du script",
episodeProfilesList3: "Enregistrez des briefings par défaut pour un ton cohérent",
speakerProfilesBringVoices: "Les profils d'intervenants donnent vie aux voix",
speakerProfilesList1: "Choisissez le fournisseur de synthèse vocale (TTS) et le modèle",
speakerProfilesList2: "Capturez la personnalité, l'histoire et les notes de prononciation par intervenant",
speakerProfilesList3: "Réutilisez les mêmes voix d'hôtes ou d'invités sur différents formats",
recommendedWorkflow: "Flux de travail recommandé",
workflowStep1: "Créez des profils d'intervenants pour chaque voix nécessaire",
workflowStep2: "Créez des profils d'épisodes qui référencent ces intervenants par leur nom",
workflowStep3: "Générez des podcasts en sélectionnant le profil d'épisode adapté",
workflowHint: "Les profils d'épisode référencent les intervenants par nom ; commencer par les voix évite les oublis d'attribution plus tard.",
failedToLoadTemplates: "Échec du chargement des modèles",
failedToLoadTemplatesDesc: "Vérifiez que l'API fonctionne et réessayez. Certaines sections peuvent être incomplètes.",
loadingTemplates: "Chargement des modèles…",
speakerProfilesTitle: "Profils d'intervenants",
speakerProfilesDesc: "Configurez les voix et personnalités pour les épisodes générés.",
createSpeaker: "Créer un intervenant",
noSpeakerProfiles: "Aucun profil d'intervenant. Créez-en un pour activer les modèles d'épisodes.",
noDescription: "Aucune description fournie.",
usedByCount_one: "Utilisé par 1 épisode",
usedByCount_other: "Utilisé par {count} épisodes",
usedByCount: "Utilisé par {count} épisodes",
unused: "Inutilisé",
voiceId: "ID de la voix",
backstory: "Histoire (Backstory)",
personality: "Personnalité",
edit: "Modifier",
duplicate: "Dupliquer",
deleteSpeakerProfileTitle: "Supprimer le profil de l'intervenant ?",
deleteSpeakerProfileDesc: "La suppression de “{name}” est irréversible.",
deleteSpeakerDisabledHint: "Retirez cet intervenant des profils d'épisode avant de le supprimer.",
deleting: "Suppression…",
episodeProfilesTitle: "Profils d'épisode",
episodeProfilesDesc: "Définissez des paramètres de génération réutilisables pour vos émissions.",
createProfile: "Créer un profil",
createSpeakerFirst: "Créez un profil d'intervenant avant d'ajouter un profil d'épisode.",
noEpisodeProfiles: "Aucun profil d'épisode. Créez-en un pour lancer la génération de podcasts.",
speakerCreated: "Intervenant créé",
speakerCreatedDesc: "L'intervenant \"{name}\" a été ajouté avec succès.",
failedToCreateSpeaker: "Échec de la création du profil d'intervenant",
speakerUpdated: "Intervenant mis à jour",
speakerUpdatedDesc: "L'intervenant \"{name}\" a été mis à jour avec succès.",
failedToUpdateSpeaker: "Échec de la mise à jour du profil d'intervenant",
speakerDeleted: "Intervenant supprimé",
speakerDeletedDesc: "L'intervenant \"{name}\" a été retiré avec succès.",
failedToDeleteSpeaker: "Échec de la suppression du profil d'intervenant",
speakerDuplicated: "Intervenant dupliqué",
speakerDuplicatedDesc: "L'intervenant \"{name}\" a été dupliqué avec succès.",
failedToDuplicateSpeaker: "Échec de la duplication du profil d'intervenant",
generationStarted: "Génération démarrée",
generationStartedDesc: "La génération du podcast a été mise en file d'attente.",
failedToStartGeneration: "Échec du démarrage de la génération",
tryAgainMoment: "Veuillez réessayer dans un instant.",
deleteProfileTitle: "Supprimer le profil ?",
deleteProfileDesc: "Ceci supprimera “{name}”. Les épisodes existants conservent leurs données, mais les nouveaux ne pourront plus utiliser cette configuration.",
profileCreated: "Profil créé",
profileCreatedDesc: "Le profil d'épisode \"{name}\" a été créé avec succès.",
failedToCreateProfile: "Échec de la création du profil",
profileUpdated: "Profil mis à jour",
profileUpdatedDesc: "Le profil d'épisode \"{name}\" a été mis à jour avec succès.",
failedToUpdateProfile: "Échec de la mise à jour du profil",
profileDeleted: "Profil supprimé",
profileDeletedDesc: "Le profil d'épisode \"{name}\" a été retiré avec succès.",
failedToDeleteProfile: "Échec de la suppression du profil",
failedToDeleteProfileDesc: "Impossible de retirer le profil d'épisode.",
profileDuplicated: "Profil dupliqué",
profileDuplicatedDesc: "Le profil d'épisode \"{name}\" a été dupliqué avec succès.",
failedToDuplicateProfile: "Échec de la duplication du profil",
episodeDeleted: "Épisode supprimé",
episodeDeletedDesc: "L'épisode a été supprimé avec succès.",
failedToDeleteEpisode: "Échec de la suppression de l'épisode",
failedToDeleteSpeakerDesc: "Impossible de retirer le profil de l'intervenant.",
outlineModel: "Modèle de plan",
transcriptModel: "Modèle de transcription",
segments: "Segments",
defaultBriefingTitle: "Briefing par défaut",
created: "Créé à {time}",
details: "Détails",
summaryTab: "Résumé",
outlineTab: "Plan",
transcriptTab: "Transcription",
briefing: "Briefing",
noOutline: "Aucun plan disponible.",
noTranscript: "Aucune transcription disponible.",
deleteEpisodeTitle: "Supprimer l'épisode ?",
deleteEpisodeDesc: "Ceci supprimera définitivement “{name}” et son fichier audio.",
audioUnavailable: "Audio indisponible",
segment: "Segment",
speaker: "Intervenant",
profile: "Profil",
link: "Lien",
file: "Fichier",
embedded: "Indexé",
notEmbedded: "Non indexé",
noSpeakerProfilesAvailable: "Aucun profil d'intervenant disponible",
noLanguageModelsAvailable: "Aucun modèle de langue disponible",
editEpisodeProfile: "Modifier le profil d'épisode",
createEpisodeProfile: "Créer un profil d'épisode",
episodeProfileFormDesc: "Définissez comment les épisodes doivent être générés et quelle configuration d'intervenants ils utilisent par défaut.",
noSpeakerProfilesDesc: "Créez un profil d'intervenant avant de configurer un profil d'épisode.",
noLanguageModelsDesc: "Ajoutez des modèles de langue dans la section Modèles pour configurer la génération du plan et de la transcription.",
profileName: "Nom du profil",
profileNamePlaceholder: "ex: Discussion tech",
descriptionPlaceholder: "Bref résumé de l'usage de ce profil",
speakerConfig: "Configuration des intervenants",
selectSpeakerProfile: "Sélectionnez un profil d'intervenant",
outlineGeneration: "Génération du plan",
transcriptGeneration: "Génération de la transcription",
defaultBriefingPlaceholder: "Décrivez la structure, le ton et les objectifs pour ce format d'épisode",
editSpeakerProfile: "Modifier le profil de l'intervenant",
createSpeakerProfile: "Créer un profil d'intervenant",
speakerProfileFormDesc: "Configurez les paramètres de synthèse vocale et définissez jusqu'à quatre intervenants.",
noTtsModelsAvailable: "Aucun modèle TTS disponible",
noTtsModelsDesc: "Ajoutez des modèles TTS dans la section Modèles avant de créer un profil d'intervenant.",
speakers: "Intervenants",
speakersDesc: "Configurez entre un et quatre intervenants pour ce profil.",
addSpeaker: "Ajouter un intervenant",
speakerNumber: "Intervenant {number}",
backstoryPlaceholder: "Courte biographie ou contexte de l'intervenant",
personalityPlaceholder: "Décrivez le style et le ton",
outlineProviderRequired: "Le fournisseur du plan est requis",
outlineModelRequired: "Le modèle du plan est requis",
transcriptProviderRequired: "Le fournisseur de transcription est requis",
transcriptModelRequired: "Le modèle de transcription est requis",
defaultBriefingRequired: "Le briefing par défaut est requis",
segmentsInteger: "Doit être un nombre entier",
segmentsMin: "Au moins 3 segments",
segmentsMax: "20 segments maximum",
voiceIdRequired: "L'ID de la voix est requis",
backstoryRequired: "L'histoire (backstory) est requise",
personalityRequired: "La personnalité est requise",
speakerCountMin: "Au moins un intervenant est requis",
speakerCountMax: "Vous pouvez configurer jusqu'à 4 intervenants",
delete: "Supprimer",
failedToDelete: "Échec de la suppression du podcast",
},
settings: {
contentProcessing: "Traitement du contenu",
contentProcessingDesc: "Configurez la manière dont les documents et les URL sont traités",
docEngine: "Moteur de traitement de documents",
docEnginePlaceholder: "Sélectionnez un moteur de traitement de documents",
urlEngine: "Moteur de traitement d'URL",
urlEnginePlaceholder: "Sélectionnez un moteur de traitement d'URL",
autoRecommended: "Auto (Recommandé)",
simple: "Simple",
docling: "Docling",
helpMeChoose: "Aidez-moi à choisir",
docHelp: "· Docling est un peu plus lent mais plus précis, surtout si les documents contiennent des tableaux et des images. · Simple extraira tout le contenu du document sans le formater. · Auto (recommandé) essaiera de traiter via Docling et se rabattra sur Simple par défaut.",
firecrawl: "Firecrawl",
jina: "Jina",
urlHelp: "· Firecrawl est un service payant (avec un niveau gratuit), et très puissant. · Jina est également une bonne option et dispose aussi d'un niveau gratuit. · Simple utilisera une extraction HTTP basique et manquera du contenu sur les sites basés sur Javascript. · Auto (recommandé) essaiera d'utiliser Firecrawl puis Jina, et enfin se rabattra sur Simple.",
embeddingAndSearch: "Indexation (Embedding) et Recherche",
embeddingAndSearchDesc: "Configurez les options de recherche et d'indexation",
defaultEmbeddingOption: "Option d'indexation par défaut",
embeddingOptionPlaceholder: "Sélectionnez une option d'indexation",
ask: "Demander",
always: "Toujours",
never: "Jamais",
embeddingHelp: "L'indexation du contenu facilite sa recherche par vous et vos agents IA. Si vous utilisez un modèle d'embedding local (Ollama, par exemple), vous n'avez pas à vous soucier du coût et pouvez tout indexer.",
fileManagement: "Gestion des fichiers",
fileManagementDesc: "Configurez les options de manipulation et de stockage des fichiers",
autoDeleteFiles: "Suppression automatique des fichiers",
autoDeletePlaceholder: "Sélectionnez une option de suppression automatique",
filesHelp: "Une fois vos fichiers téléchargés et traités, ils ne sont plus nécessaires. La plupart des utilisateurs devraient autoriser Open Notebook à supprimer automatiquement les fichiers du dossier de téléchargement.",
loadFailed: "Échec du chargement des paramètres",
},
advanced: {
title: "Outils Avancés",
desc: "Outils et utilitaires avancés pour les utilisateurs expérimentés",
systemInfo: "Infos Système",
rebuildEmbeddings: "Reconstruire les index (Embeddings)",
rebuildEmbeddingsDesc: "Reconstruire l'index de recherche vectorielle pour toutes les sources",
currentVersion: "Version actuelle",
latestVersion: "Dernière version",
status: "État",
updateAvailable: "Version {version} disponible",
updateAvailableDesc: "Une nouvelle version de Open Notebook est disponible.",
upToDate: "À jour",
unknown: "Inconnu",
viewOnGithub: "Voir sur GitHub",
updateCheckFailed: "Impossible de vérifier les mises à jour. GitHub est peut-être injoignable.",
rebuild: {
mode: "Mode de reconstruction",
existing: "Existant",
all: "Tout",
existingDesc: "Ré-indexer uniquement les éléments qui ont déjà des embeddings (plus rapide, utile lors d'un changement de modèle)",
allDesc: "Ré-indexer les éléments existants + créer des embeddings pour les éléments qui n'en ont pas (plus lent, complet)",
include: "Inclure dans la reconstruction",
selectOneError: "Veuillez sélectionner au moins un type d'élément à reconstruire",
starting: "Démarrage de la reconstruction...",
startBtn: "🚀 Lancer la reconstruction",
queued: "En attente",
running: "En cours...",
completed: "Terminé !",
failed: "Échoué",
leavePageHint: "Vous pouvez quitter cette page, car l'opération s'exécute en arrière-plan",
startNew: "Lancer une nouvelle reconstruction",
itemsProcessed: "{processed}/{total} éléments ({percent}%)",
failedItems: "{count} éléments n'ont pas pu être traités",
time: "Temps",
whenToRebuild: "Quand dois-je reconstruire les embeddings ?",
whenToRebuildAns: "Vous devriez reconstruire lors d'un changement de modèle, d'une mise à jour de version, pour corriger une corruption de données ou après des imports massifs.",
howLong: "Combien de temps dure la reconstruction ?",
howLongAns: "Le temps de traitement dépend du nombre d'éléments, de la vitesse du modèle et des limites de débit de l'API. Les modèles locaux sont généralement très rapides.",
isSafe: "Est-il sûr de reconstruire pendant l'utilisation de l'application ?",
isSafeAns: "Oui, la reconstruction est sûre ! Elle ne supprime pas le contenu, remplace seulement les embeddings et gère les erreurs proprement.",
},
},
transformations: {
title: "Transformations",
desc: "Les transformations sont des prompts utilisés par le LLM pour traiter une source et extraire des aperçus, des résumés, etc.",
workspace: "Choisissez un espace de travail",
playground: "Bac à sable (Playground)",
defaultPrompt: "Prompt de transformation par défaut",
defaultPromptDesc: "Ceci sera ajouté à tous vos prompts de transformation",
defaultPromptPlaceholder: "Entrez vos instructions de transformation par défaut...",
listTitle: "Transformations personnalisées",
createNew: "Créer une nouvelle",
inputLabel: "Texte d'entrée",
inputPlaceholder: "Entrez du texte à transformer...",
outputLabel: "Sortie",
runTest: "Exécuter la transformation",
running: "Exécution...",
selectToStart: "Sélectionnez une transformation pour commencer",
name: "Nom",
namePlaceholder: "Identifiant unique, ex: points_cles",
titlePlaceholder: "Titre affiché, par défaut le nom",
promptPlaceholder: "Écrivez le prompt qui alimentera cette transformation...",
descriptionPlaceholder: "Décrivez ce que fait cette transformation.",
suggestDefault: "Suggérer par défaut sur les nouvelles sources",
promptHint: "Les prompts doivent être rédigés en pensant au contenu de la source. Vous pouvez demander au modèle de résumer, d'extraire des analyses ou de produire des sorties structurées comme des tableaux.",
createSuccess: "Transformation créée avec succès",
updateSuccess: "Transformation mise à jour avec succès",
deleteSuccess: "Transformation supprimée avec succès",
noTransformations: "Aucune transformation pour le moment",
createOne: "Créez une transformation pour commencer",
selectModel: "Sélectionnez un modèle",
deleteConfirm: "Êtes-vous sûr de vouloir supprimer cette transformation ?",
model: "Modèle",
systemPrompt: "Prompt Système",
overrideModelDesc: "Remplacer le modèle par défaut pour cette session de chat. Laissez vide pour utiliser le modèle par défaut du système.",
sessionUseReplacement: "Cette session utilisera {name} au lieu du modèle par défaut.",
systemDefault: "Défaut Système",
},
models: {
embedding: "Modèles d'Embedding",
tts: "Synthèse vocale (TTS)",
stt: "Transcription vocale (STT)",
provider: "Fournisseur",
apiKey: "Clé API",
deleteSuccess: "Modèle supprimé avec succès",
saveSuccess: "Modèle enregistré avec succès",
noModels: "Aucun modèle",
discoverModels: "Découvrir les modèles",
noModelsFound: "Aucun modèle trouvé pour ce fournisseur",
modelType: "Type de modèle",
modelTypeHint: "Sélectionnez le type de modèles que vous souhaitez ajouter. Si vous avez besoin de types différents, ajoutez-les par lots séparés.",
deleteModel: "Supprimer le modèle",
defaultAssignments: "Attributions des modèles par défaut",
defaultAssignmentsDesc: "Configurez quels modèles utiliser pour les différents usages d'Open Notebook",
missingRequiredModels: "Modèles requis manquants : {models}. Open Notebook pourrait ne pas fonctionner correctement sans eux.",
selectModelPlaceholder: "Sélectionnez un modèle",
requiredModelPlaceholder: "⚠️ Requis - Sélectionnez un modèle",
chatModelLabel: "Modèle de Chat",
chatModelDesc: "Utilisé pour les conversations",
transformationModelLabel: "Modèle de Transformation",
transformationModelDesc: "Utilisé pour les résumés, les aperçus et les transformations",
toolsModelLabel: "Modèle d'Outils",
toolsModelDesc: "Utilisé pour l'appel de fonctions (OpenAI ou Anthropic recommandé)",
largeContextModelLabel: "Modèle à large contexte",
largeContextModelDesc: "Utilisé pour le traitement de documents volumineux (Gemini recommandé)",
embeddingModelLabel: "Modèle d'Embedding",
embeddingModelDesc: "Utilisé pour la recherche sémantique et les index vectoriels",
ttsModelLabel: "Modèle de Synthèse Vocale (TTS)",
ttsModelDesc: "Utilisé pour la génération de podcasts",
sttModelLabel: "Modèle de Transcription Vocale (STT)",
sttModelDesc: "Utilisé pour la transcription audio",
selectProviderPlaceholder: "Sélectionnez un fournisseur",
providerRequired: "Le fournisseur est requis",
modelRequired: "Le modèle est requis",
embeddingChangeTitle: "Changement de modèle d'embedding",
embeddingChangeConfirm: "Vous êtes sur le point de changer votre modèle d'embedding de {from} à {to}.",
rebuildRequired: "Important : Reconstruction requise",
rebuildReason: "Changer votre modèle d'embedding nécessite de reconstruire tous les index existants pour maintenir la cohérence. Sans cela, vos recherches pourraient retourner des résultats incorrects ou incomplets.",
whatHappensNext: "Que se passe-t-il ensuite :",
step1: "Votre modèle d'embedding par défaut sera mis à jour",
step2: "Les embeddings existants resteront inchangés jusqu'à la reconstruction",
step3: "Le nouveau contenu utilisera le nouveau modèle d'embedding",
step4: "Vous devriez reconstruire les index dès que possible",
proceedToRebuildPrompt: "Souhaitez-vous aller sur la page Avancé pour lancer la reconstruction maintenant ?",
changeModelOnly: "Changer le modèle uniquement",
changeAndRebuild: "Changer & Aller à la reconstruction",
autoAssign: "Attribution automatique des défauts",
autoAssigning: "Attribution en cours...",
autoAssignSuccess: "{count} modèles par défaut attribués automatiquement",
autoAssignNoModels: "Aucun modèle disponible à attribuer. Veuillez d'abord synchroniser les modèles.",
autoAssignAlreadySet: "Tous les modèles par défaut sont déjà configurés",
testModel: "Tester le modèle",
testModelSuccess: "Test du modèle réussi",
testModelFailed: "Test du modèle échoué",
searchOrAddModel: "Rechercher ou saisir un nom de modèle...",
addCustomModel: "Ajouter \"{name}\"",
},
apiKeys: {
title: "Configurez votre IA avec vos propres clés API",
description: "Stockez les clés API de manière sécurisée dans la base de données pour activer les fournisseurs d'IA dans Open Notebook.",
encryptionRequired: "Clé de chiffrement non configurée",
encryptionRequiredDescription: "Définissez la variable d'environnement OPEN_NOTEBOOK_ENCRYPTION_KEY avec une chaîne secrète pour activer le stockage des clés API dans la base de données.",
configured: "Configuré",
notConfigured: "Non configuré",
migrationAvailable: "Variables d'environnement détectées",
migrationDescription: "{count} clé(s) API sont configurées via des variables d'environnement et peuvent être migrées vers la base de données pour une gestion plus facile.",
migrateToDatabase: "Migrer vers la base de données",
migrating: "Migration en cours...",
migrationSuccess: "{count} clé(s) API migrée(s) avec succès",
migrationErrors: "{count} clé(s) n'ont pas pu être migrée(s)",
migrationNothingToMigrate: "Toutes les clés sont déjà dans la base de données",
learnMore: "Apprenez à configurer les clés API →",
testConnection: "Tester la connexion",
testSuccess: "Connexion réussie",
testFailed: "Échec du test de connexion",
syncModels: "Synchroniser les modèles",
syncSuccess: "{discovered} modèles découverts, {new} nouveaux ajoutés",
syncNoNew: "{count} modèles découverts, tous déjà enregistrés",
syncFailed: "Échec de la synchronisation des modèles",
getApiKey: "Obtenir une clé API",
vertexProject: "ID du projet GCP",
vertexLocation: "Région",
vertexCredentials: "Chemin du JSON du compte de service",
addConfig: "Ajouter une configuration",
editConfig: "Modifier la configuration",
deleteConfig: "Supprimer la configuration",
configName: "Nom de la configuration",
configNameHint: "Un nom descriptif pour cette configuration (ex : « Production », « Développement »)",
baseUrl: "URL de base",
baseUrlOverrideHint: "Ne modifiez ceci que si vous devez remplacer le point d'accès API par défaut du fournisseur.",
deleteConfigConfirm: "Êtes-vous sûr de vouloir supprimer « {name} » ? Cette action est irréversible.",
configSaveSuccess: "Configuration enregistrée avec succès",
configUpdateSuccess: "Configuration mise à jour avec succès",
configDeleteSuccess: "Configuration supprimée avec succès",
apiKeyEditHint: "Laissez vide pour conserver la clé API existante",
},
setupBanner: {
encryptionRequired: "Clé de chiffrement non configurée",
encryptionRequiredDescription: "Définissez la variable d'environnement OPEN_NOTEBOOK_ENCRYPTION_KEY pour activer le stockage sécurisé des identifiants.",
migrationAvailable: "Migration des clés API disponible",
migrationDescription: "{count} fournisseur(s) ont des clés API définies via des variables d'environnement. Migrez-les vers la base de données pour une gestion plus facile.",
goToSettings: "Aller aux paramètres",
viewDocs: "Voir la documentation",
},
}

View file

@ -1,56 +1,68 @@
import { describe, it, expect } from 'vitest'
import fs from 'fs'
import path from 'path'
import { resources } from './index'
import { enUS } from './en-US'
import { zhCN } from './zh-CN'
import { zhTW } from './zh-TW'
import { jaJP } from './ja-JP'
import { ruRU } from './ru-RU'
describe('Internationalization Locales Integrity', () => {
const getKeys = (obj: Record<string, unknown>, prefix = ''): string[] => {
return Object.keys(obj).reduce((res: string[], el) => {
const val = obj[el]
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
return [...res, ...getKeys(val as Record<string, unknown>, prefix + el + '.')]
}
return [...res, prefix + el]
}, [])
}
const getKeys = (obj: Record<string, unknown>, prefix = ''): string[] => {
return Object.keys(obj).reduce((res: string[], el) => {
const val = obj[el]
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
return [...res, ...getKeys(val as Record<string, unknown>, prefix + el + '.')]
}
return [...res, prefix + el]
}, [])
}
describe('Locale Parity', () => {
const enKeys = getKeys(enUS)
const zhCNKeys = getKeys(zhCN)
const zhTWKeys = getKeys(zhTW)
const jaJPKeys = getKeys(jaJP)
const ruRUKeys = getKeys(ruRU)
it('zh-CN should have the same keys as en-US', () => {
const missingInZhCN = enKeys.filter(key => !zhCNKeys.includes(key))
const extraInZhCN = zhCNKeys.filter(key => !enKeys.includes(key))
const locales = Object.entries(resources).filter(([code]) => code !== 'en-US')
expect(missingInZhCN, `Missing keys in zh-CN: ${missingInZhCN.join(', ')}`).toEqual([])
expect(extraInZhCN, `Extra keys in zh-CN: ${extraInZhCN.join(', ')}`).toEqual([])
})
it.each(locales.map(([code, resource]) => [code, resource] as const))(
'%s should have the same keys as en-US',
(code, resource) => {
const localeKeys = getKeys(resource.translation as Record<string, unknown>)
it('zh-TW should have the same keys as en-US', () => {
const missingInZhTW = enKeys.filter(key => !zhTWKeys.includes(key))
const extraInZhTW = zhTWKeys.filter(key => !enKeys.includes(key))
const missing = enKeys.filter(key => !localeKeys.includes(key))
const extra = localeKeys.filter(key => !enKeys.includes(key))
expect(missingInZhTW, `Missing keys in zh-TW: ${missingInZhTW.join(', ')}`).toEqual([])
expect(extraInZhTW, `Extra keys in zh-TW: ${extraInZhTW.join(', ')}`).toEqual([])
})
it('ja-JP should have the same keys as en-US', () => {
const missingInJaJP = enKeys.filter(key => !jaJPKeys.includes(key))
const extraInJaJP = jaJPKeys.filter(key => !enKeys.includes(key))
expect(missingInJaJP, `Missing keys in ja-JP: ${missingInJaJP.join(', ')}`).toEqual([])
expect(extraInJaJP, `Extra keys in ja-JP: ${extraInJaJP.join(', ')}`).toEqual([])
})
it('ru-RU should have the same keys as en-US', () => {
const missingInRuRU = enKeys.filter(key => !ruRUKeys.includes(key))
const extraInRuRU = ruRUKeys.filter(key => !enKeys.includes(key))
expect(missingInRuRU, `Missing keys in ru-RU: ${missingInRuRU.join(', ')}`).toEqual([])
expect(extraInRuRU, `Extra keys in ru-RU: ${extraInRuRU.join(', ')}`).toEqual([])
})
expect(missing, `Missing keys in ${code}: ${missing.join(', ')}`).toEqual([])
expect(extra, `Extra keys in ${code}: ${extra.join(', ')}`).toEqual([])
},
)
})
describe('Unused Key Detection', () => {
it(
'all en-US leaf keys should be referenced in source files',
() => {
const srcDir = path.resolve(__dirname, '../../..')
const localesDir = path.resolve(__dirname)
const files = fs.readdirSync(srcDir, { recursive: true }) as string[]
const sourceFiles = files.filter(f => {
const full = path.join(srcDir, f)
if (full.startsWith(localesDir)) return false
if (f.endsWith('.test.ts') || f.endsWith('.test.tsx')) return false
return f.endsWith('.ts') || f.endsWith('.tsx')
})
// Normalize optional chaining (t?.common?.key → t.common.key)
// so that keys like "common.errorDetails" match "common?.errorDetails"
const corpus = sourceFiles
.map(f => fs.readFileSync(path.join(srcDir, f), 'utf-8'))
.join('\n')
.replace(/\?\./g, '.')
const leafKeys = getKeys(enUS)
const unused = leafKeys.filter(key => !corpus.includes(key))
expect(
unused,
`Found ${unused.length} unused i18n key(s):\n${unused.join('\n')}`,
).toEqual([])
},
30_000,
)
})

View file

@ -4,6 +4,7 @@ import { zhTW } from './zh-TW';
import { ptBR } from './pt-BR';
import { jaJP } from './ja-JP';
import { itIT } from './it-IT';
import { frFR } from './fr-FR';
import { ruRU } from './ru-RU';
export const resources = {
@ -13,12 +14,13 @@ export const resources = {
'pt-BR': { translation: ptBR },
'ja-JP': { translation: jaJP },
'it-IT': { translation: itIT },
'fr-FR': { translation: frFR },
'ru-RU': { translation: ruRU },
} as const;
export type TranslationKeys = typeof enUS;
export type LanguageCode = 'zh-CN' | 'en-US' | 'zh-TW' | 'pt-BR' | 'ja-JP' | 'it-IT' | 'ru-RU';
export type LanguageCode = 'zh-CN' | 'en-US' | 'zh-TW' | 'pt-BR' | 'ja-JP' | 'it-IT' | 'fr-FR' | 'ru-RU';
export type Language = {
code: LanguageCode;
@ -32,7 +34,8 @@ export const languages: Language[] = [
{ code: 'pt-BR', label: 'Português' },
{ code: 'ja-JP', label: '日本語' },
{ code: 'it-IT', label: 'Italiano' },
{ code: 'fr-FR', label: 'Français' },
{ code: 'ru-RU', label: 'Русский' },
];
export { zhCN, enUS, zhTW, ptBR, jaJP, itIT, ruRU };
export { zhCN, enUS, zhTW, ptBR, jaJP, itIT, frFR, ruRU };

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