From 3f352cfccec746e0d083d1ad23d5a2e1111d9150 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Tue, 10 Feb 2026 08:30:22 -0300 Subject: [PATCH 01/27] feat: credential-based API key management (#477) (#540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: replace provider config with credential-based system (#477) Introduce a new credential management system replacing the old ProviderConfig singleton and standalone Models page. Each credential stores encrypted API keys and provider-specific configuration with full CRUD support via a unified settings UI. Backend: - Add Credential domain model with encrypted API key storage - Add credentials API router (CRUD, discovery, registration, testing) - Add encryption utilities for secure key storage - Add key_provider for DB-first env-var fallback provisioning - Add connection tester and model discovery services - Integrate ModelManager with credential-based config - Add provider name normalization for Esperanto compatibility - Add database migrations 11-12 for credential schema Frontend: - Rewrite settings/api-keys page with credential management UI - Add model discovery dialog with search and custom model support - Add compact default model assignments (primary/advanced layout) - Add inline model testing and credential connection testing - Add env-var migration banner - Update navigation to unified settings page - Remove standalone models page and old settings components i18n: - Update all 7 locale files with credential and model management keys Closes #477 Co-Authored-By: JFMD Co-Authored-By: OraCatQAQ <570768706@qq.com> * fix: address PR #540 review comments - Fix docs referencing removed Models page - Fix error-handler returning raw messages instead of i18n keys - Fix auth.py misleading docstring and missing no-password guard - Fix connection_tester using wrong env var for openai_compatible - Add provision_provider_keys before model discovery/sync - Update CLAUDE.md to reflect credential-based system - Fix missing closing brace in api-keys page useEffect * fix: add logging to credential migration and surface errors in UI - Add comprehensive logging to migrate-from-env and migrate-from-provider-config endpoints (start, per-provider progress, success/failure with stack traces, final summary) - Fix frontend migration hooks ignoring errors array from response - Show error toast when migration fails instead of "nothing to migrate" - Invalidate status/envStatus queries after migration so banner updates * docs: update CLAUDE.md files for credential system Replace stale ProviderConfig and /api-keys/ references across 8 CLAUDE.md files to reflect the new Credential-based system from PR #540. * docs: update user documentation for credential-based system Replace env var API key instructions with Settings UI credential workflow across all user-facing documentation. The new flow is: set OPEN_NOTEBOOK_ENCRYPTION_KEY → start services → add credential in Settings UI → test → discover models → register. - Rewrite ai-providers.md, api-configuration.md, environment-reference.md - Update all quick-start guides and installation docs - Update ollama.md, openai-compatible.md, local-tts/stt networking sections - Update reverse-proxy.md, development-setup.md, security.md - Fix broken links to non-existent docs/deployment/ paths - Add credentials endpoints to api-reference.md - Move all API key env vars to deprecated/legacy sections * chore: bump version to 1.7.0-rc1 Release candidate for credential-based provider management system. * fix: initialize provider before try block in test_credential Prevents UnboundLocalError when Credential.get() throws (e.g., invalid credential_id) before provider is assigned. * fix: reorder down migration to drop index before table Removes duplicate REMOVE FIELD statement and reorders so the index is dropped before the table, preventing rollback failures. * refactor: simplify encryption key to always derive via SHA-256 Remove the dual code path in _ensure_fernet_key() that detected native Fernet keys. Since the credential system is new, always deriving via SHA-256 removes unnecessary complexity. Also removes the generate_key() function and Fernet.generate_key() references from docs. * fix: correct mock patch targets in embedding tests and URL validation Fix embedding tests patching wrong module path for model_manager (was targeting open_notebook.utils.embedding.model_manager but it's imported locally from open_notebook.ai.models). Also fix URL validation to allow unresolvable hostnames since they may be valid in the deployment environment (e.g., Azure endpoints, internal DNS). * feat: add global setup banner for encryption and migration status Show a persistent banner in AppShell when encryption key is missing (red) or env var API keys can be migrated (amber), so users see these prompts on every page instead of only on Settings > API Keys. Includes a docs link for the encryption banner and i18n support across all 7 locales. * docs: several improvements to docker-compose e env examples * Update README.md Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * docs: fix env var format in README and update model setup instructions Align the encryption key snippet in README Step 2 with the list format used in the compose file. Replace deprecated "Settings → Models" instructions with credential-based Discover Models flow. * fix: address credential system review issues - Fix SSRF bypass via IPv4-mapped IPv6 addresses (::ffff:169.254.x.x) - Fix TTS connection test missing config parameter - Add Azure-specific model discovery using api-key auth header - Add Vertex static model list for credential-based discovery - Fix PROVIDER_DISCOVERY_FUNCTIONS incorrect azure/vertex mapping - Extract business logic to api/credentials_service.py (service layer) - Move credential Pydantic schemas to api/models.py - Update tests to use new service imports and ValueError assertions * fix: sanitize error responses and migrate key_provider to Credential - Replace raw exception messages in all credential router 500 responses with generic error strings (internal details logged server-side only) - Refactor key_provider.py to use Credential.get_by_provider() instead of deprecated ProviderConfig.get_instance() - Remove unused functions (get_provider_configs, get_default_api_key, get_provider_config) that were dead code --------- Co-authored-by: JFMD Co-authored-by: OraCatQAQ <570768706@qq.com> --- .env.example | 305 +--- .gitignore | 1 + CHANGELOG.md | 36 + CLAUDE.md | 5 +- README.md | 101 +- api/CLAUDE.md | 112 ++ api/auth.py | 16 +- api/credentials_service.py | 883 +++++++++++ api/main.py | 17 +- api/models.py | 230 ++- api/routers/auth.py | 9 +- api/routers/credentials.py | 386 +++++ api/routers/models.py | 543 ++++++- docker-compose.full.yml | 28 - docker-compose.yml | 36 + docs/0-START-HERE/quick-start-cloud.md | 69 +- docs/0-START-HERE/quick-start-local.md | 64 +- docs/0-START-HERE/quick-start-openai.md | 37 +- docs/1-INSTALLATION/docker-compose.md | 153 +- docs/1-INSTALLATION/from-source.md | 15 +- docs/1-INSTALLATION/index.md | 6 +- docs/1-INSTALLATION/single-container.md | 31 +- docs/3-USER-GUIDE/api-configuration.md | 390 +++++ docs/3-USER-GUIDE/index.md | 15 + docs/4-AI-PROVIDERS/index.md | 14 +- docs/5-CONFIGURATION/advanced.md | 61 +- docs/5-CONFIGURATION/ai-providers.md | 277 ++-- docs/5-CONFIGURATION/environment-reference.md | 260 +-- docs/5-CONFIGURATION/index.md | 114 +- docs/5-CONFIGURATION/local-stt.md | 46 +- docs/5-CONFIGURATION/local-tts.md | 38 +- docs/5-CONFIGURATION/ollama.md | 80 +- docs/5-CONFIGURATION/openai-compatible.md | 85 +- docs/5-CONFIGURATION/reverse-proxy.md | 8 +- docs/5-CONFIGURATION/security.md | 65 +- docs/6-TROUBLESHOOTING/ai-chat-issues.md | 101 +- docs/6-TROUBLESHOOTING/connection-issues.md | 6 +- docs/6-TROUBLESHOOTING/quick-fixes.md | 26 +- docs/7-DEVELOPMENT/api-reference.md | 10 + docs/7-DEVELOPMENT/development-setup.md | 29 +- docs/SECURITY_REVIEW.md | 96 ++ examples/README.md | 163 ++ .../docker-compose-dev.yml | 0 examples/docker-compose-full-local.yml | 197 +++ examples/docker-compose-ollama.yml | 63 + .../docker-compose-single.yml | 0 examples/docker-compose-speaches.yml | 125 ++ .../models/components/AddModelForm.tsx | 151 -- .../components/DefaultModelsSection.tsx | 280 ---- .../models/components/ModelTypeSection.tsx | 213 --- .../models/components/ProviderStatus.tsx | 131 -- frontend/src/app/(dashboard)/models/page.tsx | 102 -- .../(dashboard)/settings/api-keys/page.tsx | 1395 +++++++++++++++++ .../src/app/(dashboard)/settings/page.tsx | 1 + .../src/components/common/CommandPalette.tsx | 2 +- frontend/src/components/layout/AppShell.tsx | 2 + frontend/src/components/layout/AppSidebar.tsx | 2 +- .../src/components/layout/SetupBanner.tsx | 84 + .../podcasts/GeneratePodcastDialog.tsx | 39 +- .../settings}/EmbeddingModelChangeDialog.tsx | 2 +- .../components/settings/MigrationBanner.tsx | 53 + .../settings/ModelTestResultDialog.tsx | 63 + frontend/src/components/settings/index.ts | 3 + frontend/src/lib/api/CLAUDE.md | 92 ++ frontend/src/lib/api/credentials.ts | 239 +++ frontend/src/lib/api/models.ts | 72 +- frontend/src/lib/hooks/CLAUDE.md | 125 ++ frontend/src/lib/hooks/use-credentials.ts | 388 +++++ frontend/src/lib/hooks/use-models.ts | 86 +- frontend/src/lib/locales/en-US/index.ts | 111 +- frontend/src/lib/locales/it-IT/index.ts | 105 +- frontend/src/lib/locales/ja-JP/index.ts | 111 +- frontend/src/lib/locales/pt-BR/index.ts | 111 +- frontend/src/lib/locales/ru-RU/index.ts | 105 +- frontend/src/lib/locales/zh-CN/index.ts | 109 ++ frontend/src/lib/locales/zh-TW/index.ts | 109 ++ frontend/src/lib/types/models.ts | 41 + open_notebook/CLAUDE.md | 393 +++-- open_notebook/ai/CLAUDE.md | 226 ++- open_notebook/ai/connection_tester.py | 438 ++++++ open_notebook/ai/key_provider.py | 297 ++++ open_notebook/ai/model_discovery.py | 756 +++++++++ open_notebook/ai/models.py | 77 +- open_notebook/database/CLAUDE.md | 4 +- open_notebook/database/async_migrate.py | 12 + .../database/migrations/11.surrealql | 10 + .../database/migrations/11_down.surrealql | 4 + .../database/migrations/12.surrealql | 29 + .../database/migrations/12_down.surrealql | 5 + open_notebook/domain/CLAUDE.md | 17 + open_notebook/domain/__init__.py | 7 + open_notebook/domain/credential.py | 199 +++ open_notebook/domain/provider_config.py | 444 ++++++ open_notebook/utils/CLAUDE.md | 34 + open_notebook/utils/__init__.py | 8 + open_notebook/utils/embedding.py | 12 +- open_notebook/utils/encryption.py | 198 +++ pyproject.toml | 3 +- tests/conftest.py | 12 + tests/test_chunking.py | 1 - tests/test_embedding.py | 11 +- tests/test_url_validation.py | 130 ++ uv.lock | 344 +--- 103 files changed, 10681 insertions(+), 2669 deletions(-) create mode 100644 api/credentials_service.py create mode 100644 api/routers/credentials.py delete mode 100644 docker-compose.full.yml create mode 100644 docker-compose.yml create mode 100644 docs/3-USER-GUIDE/api-configuration.md create mode 100644 docs/SECURITY_REVIEW.md create mode 100644 examples/README.md rename docker-compose.dev.yml => examples/docker-compose-dev.yml (100%) create mode 100644 examples/docker-compose-full-local.yml create mode 100644 examples/docker-compose-ollama.yml rename docker-compose.single.yml => examples/docker-compose-single.yml (100%) create mode 100644 examples/docker-compose-speaches.yml delete mode 100644 frontend/src/app/(dashboard)/models/components/AddModelForm.tsx delete mode 100644 frontend/src/app/(dashboard)/models/components/DefaultModelsSection.tsx delete mode 100644 frontend/src/app/(dashboard)/models/components/ModelTypeSection.tsx delete mode 100644 frontend/src/app/(dashboard)/models/components/ProviderStatus.tsx delete mode 100644 frontend/src/app/(dashboard)/models/page.tsx create mode 100644 frontend/src/app/(dashboard)/settings/api-keys/page.tsx create mode 100644 frontend/src/components/layout/SetupBanner.tsx rename frontend/src/{app/(dashboard)/models/components => components/settings}/EmbeddingModelChangeDialog.tsx (97%) create mode 100644 frontend/src/components/settings/MigrationBanner.tsx create mode 100644 frontend/src/components/settings/ModelTestResultDialog.tsx create mode 100644 frontend/src/components/settings/index.ts create mode 100644 frontend/src/lib/api/credentials.ts create mode 100644 frontend/src/lib/hooks/use-credentials.ts create mode 100644 open_notebook/ai/connection_tester.py create mode 100644 open_notebook/ai/key_provider.py create mode 100644 open_notebook/ai/model_discovery.py create mode 100644 open_notebook/database/migrations/11.surrealql create mode 100644 open_notebook/database/migrations/11_down.surrealql create mode 100644 open_notebook/database/migrations/12.surrealql create mode 100644 open_notebook/database/migrations/12_down.surrealql create mode 100644 open_notebook/domain/credential.py create mode 100644 open_notebook/domain/provider_config.py create mode 100644 open_notebook/utils/encryption.py create mode 100644 tests/test_url_validation.py diff --git a/.env.example b/.env.example index 857ca91..0b12ada 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file +# For more configuration options, see: +# https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/environment-reference.md diff --git a/.gitignore b/.gitignore index 1100153..c280d0a 100644 --- a/.gitignore +++ b/.gitignore @@ -131,6 +131,7 @@ doc_exports/ specs/ .claude +.sisyphus .playwright-mcp/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b892bc..3512e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.7.0-rc1] - 2026-02-07 + ### 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 +### 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 + +### 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 + ## [1.6.2] - 2026-01-24 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index d05329a..39c3bc3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index cb24db4..a078dc2 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/api/CLAUDE.md b/api/CLAUDE.md index 6620970..9c14ca9 100644 --- a/api/CLAUDE.md +++ b/api/CLAUDE.md @@ -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": []} +``` diff --git a/api/auth.py b/api/auth.py index 0ad2378..b89058b 100644 --- a/api/auth.py +++ b/api/auth.py @@ -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 diff --git a/api/credentials_service.py b/api/credentials_service.py new file mode 100644 index 0000000..2545705 --- /dev/null +++ b/api/credentials_service.py @@ -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, + } diff --git a/api/main.py b/api/main.py index bdc8eb9..df93eb8 100644 --- a/api/main.py +++ b/api/main.py @@ -17,6 +17,7 @@ from api.routers import ( chat, config, context, + credentials, embedding, embedding_rebuild, episode_profiles, @@ -34,6 +35,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 +50,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() @@ -162,6 +176,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("/") diff --git a/api/models.py b/api/models.py index 80261b6..b7c5ac8 100644 --- a/api/models.py +++ b/api/models.py @@ -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 @@ -434,7 +438,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") diff --git a/api/routers/auth.py b/api/routers/auth.py index 1bcd842..363323a 100644 --- a/api/routers/auth.py +++ b/api/routers/auth.py @@ -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", - } + } \ No newline at end of file diff --git a/api/routers/credentials.py b/api/routers/credentials.py new file mode 100644 index 0000000..59d0940 --- /dev/null +++ b/api/routers/credentials.py @@ -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= 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") diff --git a/api/routers/models.py b/api/routers/models.py index 3744bf9..7a2ef9b 100644 --- a/api/routers/models.py +++ b/api/routers/models.py @@ -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)}" + ) diff --git a/docker-compose.full.yml b/docker-compose.full.yml deleted file mode 100644 index 96d4e09..0000000 --- a/docker-compose.full.yml +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..47c59ff --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/0-START-HERE/quick-start-cloud.md b/docs/0-START-HERE/quick-start-cloud.md index de47392..5c68198 100644 --- a/docs/0-START-HERE/quick-start-cloud.md +++ b/docs/0-START-HERE/quick-start-cloud.md @@ -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" diff --git a/docs/0-START-HERE/quick-start-local.md b/docs/0-START-HERE/quick-start-local.md index 59f9e25..646c60c 100644 --- a/docs/0-START-HERE/quick-start-local.md +++ b/docs/0-START-HERE/quick-start-local.md @@ -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) --- @@ -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) @@ -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 ` + - Ollama: Run `ollama pull `, 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 --- diff --git a/docs/0-START-HERE/quick-start-openai.md b/docs/0-START-HERE/quick-start-openai.md index 92f7470..56968c6 100644 --- a/docs/0-START-HERE/quick-start-openai.md +++ b/docs/0-START-HERE/quick-start-openai.md @@ -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" diff --git a/docs/1-INSTALLATION/docker-compose.md b/docs/1-INSTALLATION/docker-compose.md index 695aee2..93e4764 100644 --- a/docs/1-INSTALLATION/docker-compose.md +++ b/docs/1-INSTALLATION/docker-compose.md @@ -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 ``` +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) --- diff --git a/docs/1-INSTALLATION/from-source.md b/docs/1-INSTALLATION/from-source.md index ab929f6..078bdbc 100644 --- a/docs/1-INSTALLATION/from-source.md +++ b/docs/1-INSTALLATION/from-source.md @@ -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 diff --git a/docs/1-INSTALLATION/index.md b/docs/1-INSTALLATION/index.md index 95ad0a0..1267f21 100644 --- a/docs/1-INSTALLATION/index.md +++ b/docs/1-INSTALLATION/index.md @@ -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) --- diff --git a/docs/1-INSTALLATION/single-container.md b/docs/1-INSTALLATION/single-container.md index a6911e3..0bc0db7 100644 --- a/docs/1-INSTALLATION/single-container.md +++ b/docs/1-INSTALLATION/single-container.md @@ -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. diff --git a/docs/3-USER-GUIDE/api-configuration.md b/docs/3-USER-GUIDE/api-configuration.md new file mode 100644 index 0000000..96c3bc5 --- /dev/null +++ b/docs/3-USER-GUIDE/api-configuration.md @@ -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 diff --git a/docs/3-USER-GUIDE/index.md b/docs/3-USER-GUIDE/index.md index d5ea020..2390d2d 100644 --- a/docs/3-USER-GUIDE/index.md +++ b/docs/3-USER-GUIDE/index.md @@ -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) ``` --- diff --git a/docs/4-AI-PROVIDERS/index.md b/docs/4-AI-PROVIDERS/index.md index c291832..be478da 100644 --- a/docs/4-AI-PROVIDERS/index.md +++ b/docs/4-AI-PROVIDERS/index.md @@ -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!** --- diff --git a/docs/5-CONFIGURATION/advanced.md b/docs/5-CONFIGURATION/advanced.md index cc98fb9..7340b90 100644 --- a/docs/5-CONFIGURATION/advanced.md +++ b/docs/5-CONFIGURATION/advanced.md @@ -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'))" diff --git a/docs/5-CONFIGURATION/ai-providers.md b/docs/5-CONFIGURATION/ai-providers.md index e94443b..b6a4134 100644 --- a/docs/5-CONFIGURATION/ai-providers.md +++ b/docs/5-CONFIGURATION/ai-providers.md @@ -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 diff --git a/docs/5-CONFIGURATION/environment-reference.md b/docs/5-CONFIGURATION/environment-reference.md index b7e8a26..decb10d 100644 --- a/docs/5-CONFIGURATION/environment-reference.md +++ b/docs/5-CONFIGURATION/environment-reference.md @@ -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 | diff --git a/docs/5-CONFIGURATION/index.md b/docs/5-CONFIGURATION/index.md index 561e133..f119836 100644 --- a/docs/5-CONFIGURATION/index.md +++ b/docs/5-CONFIGURATION/index.md @@ -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. diff --git a/docs/5-CONFIGURATION/local-stt.md b/docs/5-CONFIGURATION/local-stt.md index 3e6d313..4b5bac3 100644 --- a/docs/5-CONFIGURATION/local-stt.md +++ b/docs/5-CONFIGURATION/local-stt.md @@ -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` --- diff --git a/docs/5-CONFIGURATION/local-tts.md b/docs/5-CONFIGURATION/local-tts.md index 20547b7..27784ec 100644 --- a/docs/5-CONFIGURATION/local-tts.md +++ b/docs/5-CONFIGURATION/local-tts.md @@ -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` --- diff --git a/docs/5-CONFIGURATION/ollama.md b/docs/5-CONFIGURATION/ollama.md index 7818604..15904b1 100644 --- a/docs/5-CONFIGURATION/ollama.md +++ b/docs/5-CONFIGURATION/ollama.md @@ -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 diff --git a/docs/5-CONFIGURATION/openai-compatible.md b/docs/5-CONFIGURATION/openai-compatible.md index e16f803..220a216 100644 --- a/docs/5-CONFIGURATION/openai-compatible.md +++ b/docs/5-CONFIGURATION/openai-compatible.md @@ -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. --- diff --git a/docs/5-CONFIGURATION/reverse-proxy.md b/docs/5-CONFIGURATION/reverse-proxy.md index e87f84b..da5c4ea 100644 --- a/docs/5-CONFIGURATION/reverse-proxy.md +++ b/docs/5-CONFIGURATION/reverse-proxy.md @@ -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** diff --git a/docs/5-CONFIGURATION/security.md b/docs/5-CONFIGURATION/security.md index e3563d9..b7ffc22 100644 --- a/docs/5-CONFIGURATION/security.md +++ b/docs/5-CONFIGURATION/security.md @@ -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 diff --git a/docs/6-TROUBLESHOOTING/ai-chat-issues.md b/docs/6-TROUBLESHOOTING/ai-chat-issues.md index cff8737..1f8376b 100644 --- a/docs/6-TROUBLESHOOTING/ai-chat-issues.md +++ b/docs/6-TROUBLESHOOTING/ai-chat-issues.md @@ -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 ``` --- diff --git a/docs/6-TROUBLESHOOTING/connection-issues.md b/docs/6-TROUBLESHOOTING/connection-issues.md index b8c47f1..1d10bb8 100644 --- a/docs/6-TROUBLESHOOTING/connection-issues.md +++ b/docs/6-TROUBLESHOOTING/connection-issues.md @@ -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. diff --git a/docs/6-TROUBLESHOOTING/quick-fixes.md b/docs/6-TROUBLESHOOTING/quick-fixes.md index 16ae951..fe5bc55 100644 --- a/docs/6-TROUBLESHOOTING/quick-fixes.md +++ b/docs/6-TROUBLESHOOTING/quick-fixes.md @@ -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) --- diff --git a/docs/7-DEVELOPMENT/api-reference.md b/docs/7-DEVELOPMENT/api-reference.md index 5cfa171..451f674 100644 --- a/docs/7-DEVELOPMENT/api-reference.md +++ b/docs/7-DEVELOPMENT/api-reference.md @@ -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 diff --git a/docs/7-DEVELOPMENT/development-setup.md b/docs/7-DEVELOPMENT/development-setup.md index e0bbc2f..6a537be 100644 --- a/docs/7-DEVELOPMENT/development-setup.md +++ b/docs/7-DEVELOPMENT/development-setup.md @@ -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 diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md new file mode 100644 index 0000000..449b167 --- /dev/null +++ b/docs/SECURITY_REVIEW.md @@ -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** diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..dc2a7fd --- /dev/null +++ b/examples/README.md @@ -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) diff --git a/docker-compose.dev.yml b/examples/docker-compose-dev.yml similarity index 100% rename from docker-compose.dev.yml rename to examples/docker-compose-dev.yml diff --git a/examples/docker-compose-full-local.yml b/examples/docker-compose-full-local.yml new file mode 100644 index 0000000..86d85fe --- /dev/null +++ b/examples/docker-compose-full-local.yml @@ -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 diff --git a/examples/docker-compose-ollama.yml b/examples/docker-compose-ollama.yml new file mode 100644 index 0000000..3d22a77 --- /dev/null +++ b/examples/docker-compose-ollama.yml @@ -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: diff --git a/docker-compose.single.yml b/examples/docker-compose-single.yml similarity index 100% rename from docker-compose.single.yml rename to examples/docker-compose-single.yml diff --git a/examples/docker-compose-speaches.yml b/examples/docker-compose-speaches.yml new file mode 100644 index 0000000..66834c1 --- /dev/null +++ b/examples/docker-compose-speaches.yml @@ -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 diff --git a/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx b/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx deleted file mode 100644 index 45066db..0000000 --- a/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx +++ /dev/null @@ -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({ - 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)[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 ( -
- {t.models.noProvidersForType.replace('{type}', getModelTypeName())} -
- ) - } - - const handleOpenChange = (isOpen: boolean) => { - setOpen(isOpen) - if (!isOpen) { - reset() - } - } - - return ( - - - - - - - - {t.models.addSpecificModel.replace('{type}', getModelTypeName())} - - - {t.models.addSpecificModelDesc.replace('{type}', getModelTypeName())} - - -
-
- - - {errors.provider && ( -

{t.models.providerRequired}

- )} -
- -
- - - {errors.name && ( -

{errors.name.message}

- )} -

- {modelType === 'language' && watch('provider') === 'azure' && - t.models.azureHint} -

-
- -
- - -
-
-
-
- ) -} \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/models/components/DefaultModelsSection.tsx b/frontend/src/app/(dashboard)/models/components/DefaultModelsSection.tsx deleted file mode 100644 index 492f0f0..0000000 --- a/frontend/src/app/(dashboard)/models/components/DefaultModelsSection.tsx +++ /dev/null @@ -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({ - 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 ( - - - {t.models.defaultAssignments} - - {t.models.defaultAssignmentsDesc} - - - - {missingRequired.length > 0 && ( - - - - {t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))} - - - )} - -
- {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 ( -
- -
- - {!config.required && currentValue && ( - - )} -
-

{config.description}

-
- ) - })} -
- - -
- - {/* Embedding Model Change Dialog */} - { - 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 - } - /> -
- ) -} \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/models/components/ModelTypeSection.tsx b/frontend/src/app/(dashboard)/models/components/ModelTypeSection.tsx deleted file mode 100644 index 9810ebd..0000000 --- a/frontend/src/app/(dashboard)/models/components/ModelTypeSection.tsx +++ /dev/null @@ -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(null) - const [selectedProvider, setSelectedProvider] = useState(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 ( - <> - - -
-
-
- -
-
- {title} - {description} -
-
- -
-
- - {/* Provider filter badges */} - {modelProviders.length > 1 && ( -
- setSelectedProvider(null)} - > - {t.models.all} - - {modelProviders.map(provider => ( - setSelectedProvider(provider === selectedProvider ? null : provider)} - > - {provider} - {selectedProvider === provider && ( - - )} - - ))} -
- )} - - {isLoading ? ( -
- -
- ) : filteredModels.length === 0 ? ( -
- {selectedProvider - ? t.models.noProviderModelsConfigured.replace('{provider}', selectedProvider) - : t.models.noModelsConfigured - } -
- ) : ( -
-
COLLAPSED_ITEM_COUNT ? 'max-h-[280px] overflow-hidden relative' : ''}`}> - {filteredModels.slice(0, isExpanded ? undefined : COLLAPSED_ITEM_COUNT).map(model => ( -
-
- {model.name} - - {model.provider} - -
- -
- ))} - {!isExpanded && filteredModels.length > COLLAPSED_ITEM_COUNT && ( -
- )} -
- {filteredModels.length > COLLAPSED_ITEM_COUNT && ( - - )} -
- )} - - - - !open && setDeleteModel(null)} - title={t.models.deleteModel} - description={t.models.deleteModelDesc.replace('{name}', deleteModel?.name || '')} - confirmText={t.common.delete} - confirmVariant="destructive" - onConfirm={handleDelete} - /> - - ) -} \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/models/components/ProviderStatus.tsx b/frontend/src/app/(dashboard)/models/components/ProviderStatus.tsx deleted file mode 100644 index 3d29ac2..0000000 --- a/frontend/src/app/(dashboard)/models/components/ProviderStatus.tsx +++ /dev/null @@ -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 ( - - - {t.models.aiProviders} - - {t.models.providerConfigDesc} - - {t.models.configuredCount - .replace('{count}', providers.available.length.toString()) - .replace('{total}', allProviders.length.toString())} - - - - -
- {visibleProviders.map((provider) => { - const supportedTypes = providers.supported_types[provider.name] ?? [] - - return ( -
-
- {provider.available ? ( - - ) : ( - - )} -
- -
- - {provider.name} - - - {provider.available ? ( -
- {supportedTypes.length > 0 ? ( - supportedTypes.map((type) => ( - - {(t.models as Record)[type] || type.replace('_', ' ')} - - )) - ) : ( - {t.models.noModels} - )} -
- ) : ( - - {t.models.notConfigured} - - )} -
-
- ) - })} -
- - {allProviders.length > 6 ? ( -
- -
- ) : null} - - -
-
- ) -} diff --git a/frontend/src/app/(dashboard)/models/page.tsx b/frontend/src/app/(dashboard)/models/page.tsx deleted file mode 100644 index ff8e65d..0000000 --- a/frontend/src/app/(dashboard)/models/page.tsx +++ /dev/null @@ -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 ( - -
- -
-
- ) - } - - if (!models || !defaults || !providers) { - return ( - -
-
-

{t.models.failedToLoad}

-
-
-
- ) - } - - return ( - -
-
-
-
-

{t.models.title}

-

- {t.models.desc} -

-
- -
- -
- {/* Provider Status */} - - - {/* Default Models */} - - - {/* Model Types */} -
- - - - -
-
-
-
-
- ) -} diff --git a/frontend/src/app/(dashboard)/settings/api-keys/page.tsx b/frontend/src/app/(dashboard)/settings/api-keys/page.tsx new file mode 100644 index 0000000..95b16a0 --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/api-keys/page.tsx @@ -0,0 +1,1395 @@ +'use client' + +import { useMemo, useState, useEffect, useId } from 'react' +import { useForm } from 'react-hook-form' +import { AppShell } from '@/components/layout/AppShell' +import { LoadingSpinner } from '@/components/common/LoadingSpinner' +import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { + RefreshCw, + Key, + ShieldAlert, + Plus, + Edit, + Trash2, + Plug, + Loader2, + Check, + X, + AlertCircle, + Wand2, + MessageSquare, + Code, + Mic, + Volume2, + Bot, +} from 'lucide-react' +import { useTranslation } from '@/lib/hooks/use-translation' +import { useModels, useDeleteModel, useModelDefaults, useUpdateModelDefaults, useAutoAssignDefaults, useTestModel } from '@/lib/hooks/use-models' +import { + useCredentials, + useCredential, + useCredentialStatus, + useEnvStatus, + useCreateCredential, + useUpdateCredential, + useDeleteCredential, + useTestCredential, + useDiscoverModels, + useRegisterModels, + useMigrateFromEnv, +} from '@/lib/hooks/use-credentials' +import { Credential, CreateCredentialRequest, UpdateCredentialRequest, DiscoveredModel } from '@/lib/api/credentials' +import { Model, ModelDefaults } from '@/lib/types/models' +import { MigrationBanner, ModelTestResultDialog } from '@/components/settings' +import { EmbeddingModelChangeDialog } from '@/components/settings/EmbeddingModelChangeDialog' + +type ModelType = 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text' + +// Provider display names +const PROVIDER_DISPLAY_NAMES: Record = { + openai: 'OpenAI', + anthropic: 'Anthropic', + google: 'Google AI', + groq: 'Groq', + mistral: 'Mistral AI', + deepseek: 'DeepSeek', + xai: 'xAI (Grok)', + openrouter: 'OpenRouter', + voyage: 'Voyage AI', + elevenlabs: 'ElevenLabs', + ollama: 'Ollama', + azure: 'Azure OpenAI', + vertex: 'Google Vertex AI', + openai_compatible: 'OpenAI Compatible', +} + +// All providers in display order +const ALL_PROVIDERS = [ + 'openai', 'anthropic', 'google', 'groq', 'mistral', 'deepseek', + 'xai', 'openrouter', 'voyage', 'elevenlabs', 'ollama', + 'azure', 'vertex', 'openai_compatible', +] + +// Default modalities per provider +const PROVIDER_MODALITIES: Record = { + openai: ['language', 'embedding', 'text_to_speech', 'speech_to_text'], + anthropic: ['language'], + google: ['language', 'embedding', 'text_to_speech', 'speech_to_text'], + groq: ['language', 'speech_to_text'], + mistral: ['language', 'embedding'], + deepseek: ['language'], + xai: ['language'], + openrouter: ['language', 'embedding'], + voyage: ['embedding'], + elevenlabs: ['text_to_speech', 'speech_to_text'], + ollama: ['language', 'embedding'], + azure: ['language', 'embedding', 'text_to_speech', 'speech_to_text'], + vertex: ['language', 'embedding', 'text_to_speech'], + openai_compatible: ['language', 'embedding', 'text_to_speech', 'speech_to_text'], +} + +// Documentation links +const PROVIDER_DOCS: Record = { + openai: 'https://platform.openai.com/api-keys', + anthropic: 'https://console.anthropic.com/settings/keys', + google: 'https://aistudio.google.com/app/apikey', + groq: 'https://console.groq.com/keys', + mistral: 'https://console.mistral.ai/api-keys/', + deepseek: 'https://platform.deepseek.com/api_keys', + xai: 'https://console.x.ai/', + openrouter: 'https://openrouter.ai/keys', + voyage: 'https://dash.voyageai.com/api-keys', + elevenlabs: 'https://elevenlabs.io/app/settings/api-keys', + azure: 'https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI', + vertex: 'https://cloud.google.com/vertex-ai/docs/start/cloud-environment', + openai_compatible: 'https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/openai-compatible.md', +} + +const TYPE_ICONS: Record = { + language: , + embedding: , + text_to_speech: , + speech_to_text: , +} + +const TYPE_COLORS: Record = { + language: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300', + embedding: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300', + text_to_speech: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300', + speech_to_text: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-300', +} + +const TYPE_COLOR_INACTIVE = 'bg-muted text-muted-foreground opacity-50' + +const TYPE_LABELS: Record = { + language: 'Language', + embedding: 'Embedding', + text_to_speech: 'TTS', + speech_to_text: 'STT', +} + +// ============================================================================= +// Credential Form Dialog +// ============================================================================= + +function CredentialFormDialog({ + open, + onOpenChange, + provider, + credential, +}: { + open: boolean + onOpenChange: (open: boolean) => void + provider: string + credential?: Credential | null +}) { + const { t } = useTranslation() + const createCredential = useCreateCredential() + const updateCredential = useUpdateCredential() + const isEditing = !!credential + const isSubmitting = createCredential.isPending || updateCredential.isPending + + const isVertex = provider === 'vertex' + const isOllama = provider === 'ollama' + const isOpenAICompatible = provider === 'openai_compatible' + const requiresApiKey = !isVertex && !isOllama && !isOpenAICompatible + + const [name, setName] = useState('') + const [apiKey, setApiKey] = useState('') + const [baseUrl, setBaseUrl] = useState('') + const [showApiKey, setShowApiKey] = useState(false) + const [project, setProject] = useState('') + const [location, setLocation] = useState('') + const [credentialsPath, setCredentialsPath] = useState('') + // Modalities + const [modalities, setModalities] = useState([]) + + useEffect(() => { + if (credential) { + setName(credential.name || '') + setBaseUrl(credential.base_url || '') + setApiKey('') + setProject(credential.project || '') + setLocation(credential.location || '') + setCredentialsPath(credential.credentials_path || '') + setModalities(credential.modalities || []) + } else { + setName('') + setBaseUrl('') + setApiKey('') + setProject('') + setLocation('') + setCredentialsPath('') + setModalities(PROVIDER_MODALITIES[provider] || ['language']) + } + }, [credential, provider]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + const onSuccess = () => { + onOpenChange(false) + } + + if (isEditing && credential) { + const data: UpdateCredentialRequest = {} + if (name !== credential.name) data.name = name + if (apiKey.trim()) data.api_key = apiKey.trim() + if (baseUrl !== (credential.base_url || '')) data.base_url = baseUrl || undefined + if (JSON.stringify(modalities) !== JSON.stringify(credential.modalities)) data.modalities = modalities + if (isVertex) { + if (project !== (credential.project || '')) data.project = project.trim() || undefined + if (location !== (credential.location || '')) data.location = location.trim() || undefined + if (credentialsPath !== (credential.credentials_path || '')) data.credentials_path = credentialsPath.trim() || undefined + } + updateCredential.mutate({ credentialId: credential.id, data }, { onSuccess }) + } else { + const data: CreateCredentialRequest = { + name: name || `${PROVIDER_DISPLAY_NAMES[provider] || provider} Config`, + provider, + modalities, + api_key: apiKey.trim() || undefined, + base_url: baseUrl || undefined, + } + if (isVertex) { + data.project = project.trim() || undefined + data.location = location.trim() || undefined + data.credentials_path = credentialsPath.trim() || undefined + } + createCredential.mutate(data, { onSuccess }) + } + } + + const isValid = isEditing + ? true + : isVertex + ? name.trim() !== '' && project.trim() !== '' && location.trim() !== '' + : name.trim() !== '' && (!requiresApiKey || apiKey.trim() !== '') + + const docsUrl = PROVIDER_DOCS[provider] + + return ( + + + + + {isEditing + ? t.apiKeys.editConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider) + : t.apiKeys.addConfig.replace('{provider}', PROVIDER_DISPLAY_NAMES[provider] || provider)} + + +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder={`${PROVIDER_DISPLAY_NAMES[provider] || provider} Production`} + disabled={isSubmitting} + /> +

{t.apiKeys.configNameHint}

+
+ + {/* Vertex fields */} + {isVertex ? ( + <> +
+ + setProject(e.target.value)} + placeholder="my-gcp-project" + disabled={isSubmitting} + /> +
+
+ + setLocation(e.target.value)} + placeholder="us-central1" + disabled={isSubmitting} + /> +
+
+ + setCredentialsPath(e.target.value)} + placeholder="/path/to/service-account.json" + disabled={isSubmitting} + /> +
+ + ) : ( + /* API Key */ +
+ +
+ setApiKey(e.target.value)} + placeholder={isEditing ? '••••••••••••' : 'sk-...'} + disabled={isSubmitting} + autoComplete="off" + /> + +
+ {isEditing &&

{t.apiKeys.apiKeyEditHint}

} + {docsUrl && ( + + {t.apiKeys.getApiKey} → + + )} +
+ )} + + {/* Base URL (non-Vertex) */} + {!isVertex && ( +
+ + setBaseUrl(e.target.value)} + placeholder={isOllama ? 'http://localhost:11434' : 'https://api.example.com/v1'} + disabled={isSubmitting} + /> +

{t.apiKeys.baseUrlOverrideHint}

+
+ )} + + {/* Actions */} +
+ + +
+
+
+
+ ) +} + +// ============================================================================= +// Model Discovery Dialog +// ============================================================================= + +function DiscoverModelsDialog({ + open, + onOpenChange, + credential, +}: { + open: boolean + onOpenChange: (open: boolean) => void + credential: Credential +}) { + const { t } = useTranslation() + const discoverModels = useDiscoverModels() + const registerModels = useRegisterModels() + const [discoveredModels, setDiscoveredModels] = useState([]) + const [selectedModels, setSelectedModels] = useState>(new Set()) + const [hasDiscovered, setHasDiscovered] = useState(false) + const [discoveryError, setDiscoveryError] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [customModelSelected, setCustomModelSelected] = useState(false) + // Model type selector - default to credential's first modality + const [selectedType, setSelectedType] = useState( + (credential.modalities[0] as ModelType) || 'language' + ) + + useEffect(() => { + if (open && !hasDiscovered) { + setDiscoveryError(null) + discoverModels.mutate(credential.id, { + onSuccess: (result) => { + const seen = new Set() + const unique = result.discovered.filter(m => { + if (seen.has(m.name)) return false + seen.add(m.name) + return true + }) + setDiscoveredModels(unique) + setSelectedModels(new Set()) + setHasDiscovered(true) + }, + onError: (error: unknown) => { + setHasDiscovered(true) + const msg = error instanceof Error ? error.message : String(error) + setDiscoveryError(msg) + }, + }) + } + if (!open) { + setHasDiscovered(false) + setDiscoveredModels([]) + setSelectedModels(new Set()) + setDiscoveryError(null) + setSearchQuery('') + setCustomModelSelected(false) + setSelectedType((credential.modalities[0] as ModelType) || 'language') + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally only fires on open/close + }, [open]) + + // Reset custom selection when search changes + useEffect(() => { + setCustomModelSelected(false) + }, [searchQuery]) + + // Filter discovered models by search query + const filteredModels = useMemo(() => { + if (!searchQuery.trim()) return discoveredModels + const q = searchQuery.toLowerCase() + return discoveredModels.filter(m => m.name.toLowerCase().includes(q)) + }, [discoveredModels, searchQuery]) + + // Show custom model option when search doesn't exactly match any discovered model + const showCustomOption = useMemo(() => { + if (!searchQuery.trim()) return false + const q = searchQuery.trim().toLowerCase() + return !discoveredModels.some(m => m.name.toLowerCase() === q) + }, [discoveredModels, searchQuery]) + + const handleRegister = () => { + const selected = discoveredModels + .filter(m => selectedModels.has(m.name)) + .map(m => ({ + name: m.name, + provider: m.provider, + model_type: selectedType, + })) + if (customModelSelected && showCustomOption) { + selected.push({ + name: searchQuery.trim(), + provider: credential.provider, + model_type: selectedType, + }) + } + registerModels.mutate( + { credentialId: credential.id, models: selected }, + { onSuccess: () => onOpenChange(false) } + ) + } + + const totalSelected = selectedModels.size + (customModelSelected && showCustomOption ? 1 : 0) + + const toggleModel = (name: string) => { + setSelectedModels(prev => { + const next = new Set(prev) + if (next.has(name)) next.delete(name) + else next.add(name) + return next + }) + } + + const toggleAll = () => { + const filteredNames = filteredModels.map(m => m.name) + const allFilteredSelected = filteredNames.every(n => selectedModels.has(n)) + if (allFilteredSelected) { + setSelectedModels(prev => { + const next = new Set(prev) + filteredNames.forEach(n => next.delete(n)) + return next + }) + } else { + setSelectedModels(prev => { + const next = new Set(prev) + filteredNames.forEach(n => next.add(n)) + return next + }) + } + } + + return ( + + + + + {t.models.discoverModels} - {PROVIDER_DISPLAY_NAMES[credential.provider] || credential.provider} + + + {credential.name} + + + + {discoverModels.isPending ? ( +
+ +
+ ) : discoveryError ? ( + + + {discoveryError} + + ) : ( +
+ {/* Model type selector */} +
+ + +

{t.models.modelTypeHint}

+
+ + {/* Search input */} + setSearchQuery(e.target.value)} + /> + + {/* Select all / count (only when there are discovered models to select) */} + {filteredModels.length > 0 && ( +
+ +
+ )} + + {/* Model list */} +
+ {filteredModels.map((model) => ( + + ))} + + {/* Custom model option */} + {showCustomOption && ( + + )} + + {filteredModels.length === 0 && !showCustomOption && ( +

{t.models.noModelsFound}

+ )} +
+
+ )} + + + + + +
+
+ ) +} + +// ============================================================================= +// Delete Credential Dialog +// ============================================================================= + +function DeleteCredentialDialog({ + open, + onOpenChange, + credential, + allCredentials, +}: { + open: boolean + onOpenChange: (open: boolean) => void + credential: Credential + allCredentials: Credential[] +}) { + const { t } = useTranslation() + const deleteCredential = useDeleteCredential() + const [migrateToId, setMigrateToId] = useState('') + + const otherCredentials = allCredentials.filter( + c => c.id !== credential.id && c.provider === credential.provider + ) + + const handleDeleteWithModels = () => { + deleteCredential.mutate( + { credentialId: credential.id, options: { delete_models: true } }, + { onSuccess: () => onOpenChange(false) } + ) + } + + const handleMigrate = () => { + if (!migrateToId) return + deleteCredential.mutate( + { credentialId: credential.id, options: { migrate_to: migrateToId } }, + { onSuccess: () => onOpenChange(false) } + ) + } + + const handleDeleteOnly = () => { + deleteCredential.mutate( + { credentialId: credential.id }, + { onSuccess: () => onOpenChange(false) } + ) + } + + return ( + + + + {t.apiKeys.deleteConfig} + + {t.apiKeys.deleteConfigConfirm.replace('{name}', credential.name)} + + + + {credential.model_count > 0 && ( + + + + This credential has {credential.model_count} linked model(s). + {otherCredentials.length > 0 && ( +
+ + +
+ )} +
+
+ )} + + + + {credential.model_count > 0 && migrateToId && ( + + )} + + +
+
+ ) +} + +// ============================================================================= +// Credential Card (shows credential + its models) +// ============================================================================= + +function CredentialItem({ + credential, + models, + defaults, + allCredentials, +}: { + credential: Credential + models: Model[] + defaults: ModelDefaults | null + allCredentials: Credential[] +}) { + const { t } = useTranslation() + const { testCredential, isPending: isTestPending, testResults } = useTestCredential() + const { testModel, isPending: isModelTestPending, testingModelId, testResult: modelTestResult, testedModelName, clearResult: clearModelTestResult } = useTestModel() + const deleteModel = useDeleteModel() + const [editOpen, setEditOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) + const [discoverOpen, setDiscoverOpen] = useState(false) + // Full credential data needed for edit form + const { data: fullCredential } = useCredential(editOpen ? credential.id : '') + + const linkedModels = models.filter(m => m.credential === credential.id) + const activeTypes = new Set(linkedModels.map(m => m.type)) + const testResult = testResults[credential.id] + + // Extract translations used in model badge loops to avoid excessive Proxy accesses + const testModelLabel = t.models.testModel + const deleteModelLabel = t.models.deleteModel + + // Check which models are defaults + const defaultSlots: Record = {} + if (defaults) { + const slotMap: Record = { + 'Chat': defaults.default_chat_model, + 'Transform': defaults.default_transformation_model, + 'Tools': defaults.default_tools_model, + 'Large Ctx': defaults.large_context_model, + 'Embedding': defaults.default_embedding_model, + 'TTS': defaults.default_text_to_speech_model, + 'STT': defaults.default_speech_to_text_model, + } + for (const [slot, modelId] of Object.entries(slotMap)) { + if (modelId) defaultSlots[modelId] = slot + } + } + + return ( + <> +
+
+
+ {credential.name} +
+ {credential.modalities.map(mod => ( + + {TYPE_ICONS[mod as ModelType]} + {TYPE_LABELS[mod as ModelType] || mod} + + ))} +
+ {credential.has_api_key && ( + + + Key + + )} +
+
+ {testResult && ( + testResult.success + ? + : + )} + + + + +
+
+ + {/* Linked models grouped by type */} + {linkedModels.length > 0 && ( +
+ {(['language', 'embedding', 'text_to_speech', 'speech_to_text'] as ModelType[]) + .filter(type => linkedModels.some(m => m.type === type)) + .map(type => ( +
+ + {TYPE_ICONS[type]} + {TYPE_LABELS[type]} + +
+ {linkedModels.filter(m => m.type === type).map(model => { + const defaultSlot = defaultSlots[model.id] + return ( + + {model.name} + {defaultSlot && ({defaultSlot})} + + + + ) + })} +
+
+ ))} +
+ )} + + +
+ + {/* Edit dialog */} + {editOpen && ( + + )} + + {/* Delete dialog */} + {deleteOpen && ( + + )} + + {/* Discover models dialog */} + {discoverOpen && ( + + )} + + {/* Model test result dialog */} + { if (!open) clearModelTestResult() }} + result={modelTestResult} + modelName={testedModelName} + /> + + ) +} + +// ============================================================================= +// Provider Section (shows all credentials for a provider) +// ============================================================================= + +function ProviderSection({ + provider, + credentials, + models, + defaults, + allCredentials, + encryptionReady, +}: { + provider: string + credentials: Credential[] + models: Model[] + defaults: ModelDefaults | null + allCredentials: Credential[] + encryptionReady: boolean +}) { + const { t } = useTranslation() + const [addOpen, setAddOpen] = useState(false) + + const displayName = PROVIDER_DISPLAY_NAMES[provider] || provider + const modalities = PROVIDER_MODALITIES[provider] || ['language'] + const hasCredentials = credentials.length > 0 + + // Models linked to any credential of this provider + const providerModels = models.filter(m => + credentials.some(c => c.id === m.credential) + ) + const activeTypes = new Set(providerModels.map(m => m.type)) + + return ( + + +
+
+ {displayName} +
+ {modalities.map((type) => ( + + {TYPE_ICONS[type]} + {TYPE_LABELS[type]} + + ))} +
+
+
+ {hasCredentials ? ( + + + {t.apiKeys.configured} + + ) : ( + + + {t.apiKeys.notConfigured} + + )} +
+
+
+ + {credentials.map(cred => ( + + ))} + + + + + {addOpen && ( + + )} +
+ ) +} + +// ============================================================================= +// Default Models Section +// ============================================================================= + +function DefaultModelSelectors({ + models, + defaults, +}: { + models: Model[] + defaults: ModelDefaults +}) { + const { t } = useTranslation() + const updateDefaults = useUpdateModelDefaults() + const autoAssign = useAutoAssignDefaults() + const { setValue, watch } = useForm({ defaultValues: defaults }) + const generatedId = useId() + + const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false) + const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{ + key: keyof ModelDefaults; value: string; oldModelId?: string; newModelId?: string + } | null>(null) + + useEffect(() => { + if (defaults) { + Object.entries(defaults).forEach(([key, value]) => { + setValue(key as keyof ModelDefaults, value) + }) + } + }, [defaults, setValue]) + + interface DefaultConfig { + key: keyof ModelDefaults + label: string + description: string + modelType: ModelType + required?: boolean + id: string + } + + const primaryConfigs: DefaultConfig[] = [ + { key: 'default_chat_model', label: t.models.chatModelLabel, description: t.models.chatModelDesc, modelType: 'language', required: true, id: `${generatedId}-chat` }, + { key: 'default_embedding_model', label: t.models.embeddingModelLabel, description: t.models.embeddingModelDesc, modelType: 'embedding', required: true, id: `${generatedId}-embed` }, + { 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` }, + ] + + const advancedConfigs: DefaultConfig[] = [ + { key: 'default_transformation_model', label: t.models.transformationModelLabel, description: t.models.transformationModelDesc, modelType: 'language', required: true, id: `${generatedId}-transform` }, + { 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` }, + ] + + const defaultConfigs = [...primaryConfigs, ...advancedConfigs] + + const handleChange = (key: keyof ModelDefaults, value: string) => { + if (key === 'default_embedding_model') { + const current = defaults[key] + if (current && current !== value) { + setPendingEmbeddingChange({ key, value, oldModelId: current, newModelId: value }) + setShowEmbeddingDialog(true) + return + } + } + updateDefaults.mutate({ [key]: value || null }) + } + + const handleConfirmEmbeddingChange = () => { + if (pendingEmbeddingChange) { + updateDefaults.mutate({ [pendingEmbeddingChange.key]: pendingEmbeddingChange.value || null }) + setPendingEmbeddingChange(null) + } + } + + const getModelsForType = (type: ModelType) => models.filter(m => m.type === type) + + const missingRequired = defaultConfigs + .filter(c => { + if (!c.required) return false + const value = defaults[c.key] + if (!value) return true + return !models.filter(m => m.type === c.modelType).some(m => m.id === value) + }) + .map(c => c.label) + + return ( + + + {t.models.defaultAssignments} + {t.models.defaultAssignmentsDesc} + + + {missingRequired.length > 0 && ( + + + + {t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))} + + + + )} + + {/* Primary models: Chat, Embedding, TTS, STT */} +
+ {primaryConfigs.map(config => { + const available = getModelsForType(config.modelType) + const currentValue = watch(config.key) || undefined + const isValid = currentValue && available.some(m => m.id === currentValue) + + return ( +
+ +
+ + {!config.required && currentValue && ( + + )} +
+
+ ) + })} +
+ + {/* Advanced models: Transformation, Tools, Large Context */} +
+

{t.navigation.advanced}

+
+ {advancedConfigs.map(config => { + const available = getModelsForType(config.modelType) + const currentValue = watch(config.key) || undefined + const isValid = currentValue && available.some(m => m.id === currentValue) + + return ( +
+ +
+ + {!config.required && currentValue && ( + + )} +
+

{config.description}

+
+ ) + })} +
+
+
+ + { if (!open) { setPendingEmbeddingChange(null); setShowEmbeddingDialog(false) } }} + 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} + /> +
+ ) +} + +// ============================================================================= +// Main Page +// ============================================================================= + +export default function ApiKeysPage() { + const { t } = useTranslation() + + // Data + const { data: credentials, isLoading: credentialsLoading } = useCredentials() + const { data: models, isLoading: modelsLoading } = useModels() + const { data: defaults, isLoading: defaultsLoading } = useModelDefaults() + const { data: credentialStatus } = useCredentialStatus() + const { data: envStatus } = useEnvStatus() + + const encryptionReady = credentialStatus?.encryption_configured ?? true + + // Group credentials by provider + const credentialsByProvider = useMemo(() => { + const grouped: Record = {} + for (const provider of ALL_PROVIDERS) { + grouped[provider] = [] + } + if (credentials) { + for (const cred of credentials) { + if (!grouped[cred.provider]) grouped[cred.provider] = [] + grouped[cred.provider].push(cred) + } + } + return grouped + }, [credentials]) + + // Providers needing migration + 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]) + + // Sort: configured providers first + const sortedProviders = useMemo(() => { + return [...ALL_PROVIDERS].sort((a, b) => { + const aHas = (credentialsByProvider[a]?.length || 0) > 0 ? 1 : 0 + const bHas = (credentialsByProvider[b]?.length || 0) > 0 ? 1 : 0 + return bHas - aHas + }) + }, [credentialsByProvider]) + + const isLoading = credentialsLoading || modelsLoading || defaultsLoading + + if (isLoading) { + return ( + +
+ +
+
+ ) + } + + return ( + +
+
+ {/* Header */} +
+

+ + {t.apiKeys.title} +

+

{t.apiKeys.description}

+
+ + {/* Encryption warning */} + {!encryptionReady && ( + + + {t.apiKeys.encryptionRequired} + + + {t.apiKeys.encryptionRequiredDescription} + + + + )} + + {/* Migration banner */} + {encryptionReady && } + + {/* Default Model Selectors */} + {models && defaults && ( + + )} + + {/* Provider Cards */} +
+ {sortedProviders.map(provider => ( + + ))} +
+ + {/* Help link */} + +
+
+
+ ) +} diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index ab22afb..5f795ac 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -22,6 +22,7 @@ export default function SettingsPage() {
+ diff --git a/frontend/src/components/common/CommandPalette.tsx b/frontend/src/components/common/CommandPalette.tsx index 331aeee..5bac0f5 100644 --- a/frontend/src/components/common/CommandPalette.tsx +++ b/frontend/src/components/common/CommandPalette.tsx @@ -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'] }, diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx index c975d1a..ab52b7b 100644 --- a/frontend/src/components/layout/AppShell.tsx +++ b/frontend/src/components/layout/AppShell.tsx @@ -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) {
+ {children}
diff --git a/frontend/src/components/layout/AppSidebar.tsx b/frontend/src/components/layout/AppSidebar.tsx index f96a898..08eb938 100644 --- a/frontend/src/components/layout/AppSidebar.tsx +++ b/frontend/src/components/layout/AppSidebar.tsx @@ -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 }, diff --git a/frontend/src/components/layout/SetupBanner.tsx b/frontend/src/components/layout/SetupBanner.tsx new file mode 100644 index 0000000..237ab98 --- /dev/null +++ b/frontend/src/components/layout/SetupBanner.tsx @@ -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 ( +
+ + + + {t.setupBanner.encryptionRequired} + + + {t.setupBanner.encryptionRequiredDescription} + + {t.setupBanner.viewDocs} + + + + +
+ ) + } + + return ( +
+ + + + {t.setupBanner.migrationAvailable} + + + + {t.setupBanner.migrationDescription.replace('{count}', providersToMigrate.length.toString())} + + + + +
+ ) +} diff --git a/frontend/src/components/podcasts/GeneratePodcastDialog.tsx b/frontend/src/components/podcasts/GeneratePodcastDialog.tsx index e5692c3..81e4fad 100644 --- a/frontend/src/components/podcasts/GeneratePodcastDialog.tsx +++ b/frontend/src/components/podcasts/GeneratePodcastDialog.tsx @@ -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 + sourcesByNotebook: Record + notesByNotebook: Record + fetchingNotebookIds: Set + 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({

) : (
- {sources.map((source: any) => { + {sources.map((source: SourceListResponse) => { const mode = selection?.sources?.[source.id] ?? 'off' return (
) : (
- {notes.map((note: any) => { + {notes.map((note: NoteResponse) => { const mode = selection?.notes?.[note.id] ?? 'off' return (
([]) diff --git a/frontend/src/app/(dashboard)/models/components/EmbeddingModelChangeDialog.tsx b/frontend/src/components/settings/EmbeddingModelChangeDialog.tsx similarity index 97% rename from frontend/src/app/(dashboard)/models/components/EmbeddingModelChangeDialog.tsx rename to frontend/src/components/settings/EmbeddingModelChangeDialog.tsx index 05646d0..b13b93a 100644 --- a/frontend/src/app/(dashboard)/models/components/EmbeddingModelChangeDialog.tsx +++ b/frontend/src/components/settings/EmbeddingModelChangeDialog.tsx @@ -68,7 +68,7 @@ export function EmbeddingModelChangeDialog({

-

⚠️ {t.models.rebuildRequired}

+

{t.models.rebuildRequired}

{t.models.rebuildReason}

diff --git a/frontend/src/components/settings/MigrationBanner.tsx b/frontend/src/components/settings/MigrationBanner.tsx new file mode 100644 index 0000000..e23bcd5 --- /dev/null +++ b/frontend/src/components/settings/MigrationBanner.tsx @@ -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 ( + + + + {t.apiKeys.migrationAvailable} + + + + {t.apiKeys.migrationDescription.replace('{count}', providersToMigrate.length.toString())} + + + + + ) +} diff --git a/frontend/src/components/settings/ModelTestResultDialog.tsx b/frontend/src/components/settings/ModelTestResultDialog.tsx new file mode 100644 index 0000000..10be66a --- /dev/null +++ b/frontend/src/components/settings/ModelTestResultDialog.tsx @@ -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 ( + + + + + {result.success ? ( + + ) : ( + + )} + {result.success ? t.models.testModelSuccess : t.models.testModelFailed} + + + +
+

{modelName}

+

{result.message}

+ + {result.details && ( +
+              {result.details}
+            
+ )} +
+ + + + +
+
+ ) +} diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts new file mode 100644 index 0000000..b70ac99 --- /dev/null +++ b/frontend/src/components/settings/index.ts @@ -0,0 +1,3 @@ +export { MigrationBanner } from './MigrationBanner' +export { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog' +export { ModelTestResultDialog } from './ModelTestResultDialog' diff --git a/frontend/src/lib/api/CLAUDE.md b/frontend/src/lib/api/CLAUDE.md index 2307eb0..3bf3e43 100644 --- a/frontend/src/lib/api/CLAUDE.md +++ b/frontend/src/lib/api/CLAUDE.md @@ -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; source: Record; 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' })) +}) +``` diff --git a/frontend/src/lib/api/credentials.ts b/frontend/src/lib/api/credentials.ts new file mode 100644 index 0000000..bf5d166 --- /dev/null +++ b/frontend/src/lib/api/credentials.ts @@ -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 + source: Record + encryption_configured: boolean +} + +export type EnvStatus = Record + +export const credentialsApi = { + /** + * Get configuration status for all providers + */ + getStatus: async (): Promise => { + const response = await apiClient.get('/credentials/status') + return response.data + }, + + /** + * Get environment variable status for all providers + */ + getEnvStatus: async (): Promise => { + const response = await apiClient.get('/credentials/env-status') + return response.data + }, + + /** + * List all credentials, optionally filtered by provider + */ + list: async (provider?: string): Promise => { + const params = provider ? { provider } : {} + const response = await apiClient.get('/credentials', { params }) + return response.data + }, + + /** + * List credentials for a specific provider + */ + listByProvider: async (provider: string): Promise => { + const response = await apiClient.get(`/credentials/by-provider/${provider}`) + return response.data + }, + + /** + * Get a specific credential by ID + */ + get: async (credentialId: string): Promise => { + const response = await apiClient.get(`/credentials/${credentialId}`) + return response.data + }, + + /** + * Create a new credential + */ + create: async (data: CreateCredentialRequest): Promise => { + const response = await apiClient.post('/credentials', data) + return response.data + }, + + /** + * Update an existing credential + */ + update: async (credentialId: string, data: UpdateCredentialRequest): Promise => { + const response = await apiClient.put(`/credentials/${credentialId}`, data) + return response.data + }, + + /** + * Delete a credential + */ + delete: async ( + credentialId: string, + options?: { delete_models?: boolean; migrate_to?: string } + ): Promise => { + const params: Record = {} + if (options?.delete_models) params.delete_models = true + if (options?.migrate_to) params.migrate_to = options.migrate_to + const response = await apiClient.delete( + `/credentials/${credentialId}`, + { params } + ) + return response.data + }, + + /** + * Test connection for a credential + */ + test: async (credentialId: string): Promise => { + const response = await apiClient.post( + `/credentials/${credentialId}/test` + ) + return response.data + }, + + /** + * Discover models using a credential's API key + */ + discover: async (credentialId: string): Promise => { + const response = await apiClient.post( + `/credentials/${credentialId}/discover` + ) + return response.data + }, + + /** + * Register discovered models and link them to a credential + */ + registerModels: async ( + credentialId: string, + data: RegisterModelsRequest + ): Promise => { + const response = await apiClient.post( + `/credentials/${credentialId}/register-models`, + data + ) + return response.data + }, + + /** + * Migrate from ProviderConfig to individual credentials + */ + migrateFromProviderConfig: async (): Promise => { + const response = await apiClient.post( + '/credentials/migrate-from-provider-config' + ) + return response.data + }, + + /** + * Migrate from environment variables to credentials + */ + migrateFromEnv: async (): Promise => { + const response = await apiClient.post('/credentials/migrate-from-env') + return response.data + }, +} diff --git a/frontend/src/lib/api/models.ts b/frontend/src/lib/api/models.ts index 0175657..1eda410 100644 --- a/frontend/src/lib/api/models.ts +++ b/frontend/src/lib/api/models.ts @@ -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('/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(`/models/discover/${provider}`) + return response.data + }, + + /** + * Sync models for a specific provider (discover and register) + */ + syncProvider: async (provider: string) => { + const response = await apiClient.post(`/models/sync/${provider}`) + return response.data + }, + + /** + * Sync models for all configured providers + */ + syncAll: async () => { + const response = await apiClient.post('/models/sync') + return response.data + }, + + /** + * Get count of registered models for a provider + */ + getProviderModelCount: async (provider: string) => { + const response = await apiClient.get(`/models/count/${provider}`) + return response.data + }, + + /** + * Get all models for a specific provider + */ + getByProvider: async (provider: string) => { + const response = await apiClient.get(`/models/by-provider/${provider}`) + return response.data + }, + + /** + * Auto-assign default models based on available models + */ + autoAssign: async () => { + const response = await apiClient.post('/models/auto-assign') + return response.data + }, + + /** + * Test an individual model configuration + */ + testModel: async (modelId: string): Promise => { + const response = await apiClient.post(`/models/${modelId}/test`) + return response.data + }, } \ No newline at end of file diff --git a/frontend/src/lib/hooks/CLAUDE.md b/frontend/src/lib/hooks/CLAUDE.md index a90a8b5..e2d00a8 100644 --- a/frontend/src/lib/hooks/CLAUDE.md +++ b/frontend/src/lib/hooks/CLAUDE.md @@ -66,3 +66,128 @@ render(, { 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 + isPending, // boolean + testResults, // Record + 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 ( +
+ {credentials?.map(cred => ( +
+ {cred.name} ({cred.provider}) + + {testResults[cred.id]?.success && Connected!} +
+ ))} + + +
+ ) +} +``` + +### 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 diff --git a/frontend/src/lib/hooks/use-credentials.ts b/frontend/src/lib/hooks/use-credentials.ts new file mode 100644 index 0000000..f514f37 --- /dev/null +++ b/frontend/src/lib/hooks/use-credentials.ts @@ -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>({}) + + 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', + }) + }, + }) +} diff --git a/frontend/src/lib/hooks/use-models.ts b/frontend/src/lib/hooks/use-models.ts index 77f0d75..ff38c0c 100644 --- a/frontend/src/lib/hooks/use-models.ts +++ b/frontend/src/lib/hooks/use-models.ts @@ -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(null) + const [testedModelName, setTestedModelName] = useState('') + const [testingModelId, setTestingModelId] = useState(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, + } +} diff --git a/frontend/src/lib/locales/en-US/index.ts b/frontend/src/lib/locales/en-US/index.ts index 674d5f6..0c3c36e 100644 --- a/frontend/src/lib/locales/en-US/index.ts +++ b/frontend/src/lib/locales/en-US/index.ts @@ -910,6 +910,10 @@ export const enUS = { 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", @@ -954,5 +958,110 @@ 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", + autoAssignDesc: "Automatically assign the best available model for each slot", + 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", + testingModel: "Testing model...", + 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.", + loadFailed: "Failed to load API keys status", + 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", + sourceDatabase: "Database", + sourceEnvironment: "Environment", + enterApiKey: "Enter your API key", + enterBaseUrl: "Enter the base URL", + saveSuccess: "API key saved successfully", + deleteSuccess: "API key deleted successfully", + fromEnvironmentHint: "This key is set via environment variable. Save a new key to override it in the database.", + 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", + serviceType: "Service Type", + serviceLlm: "Language Model (LLM)", + serviceEmbedding: "Embedding", + serviceStt: "Speech to Text (STT)", + serviceTts: "Text to Speech (TTS)", + serviceEndpoints: "Service Endpoints (optional)", + azureEndpointsHint: "Configure different endpoints for each service type if needed.", + endpointPlaceholder: "https://your-resource.openai.azure.com/", + openaiCompatibleHint: "Configure an OpenAI-compatible API endpoint. Each service type can have its own configuration.", + baseUrlPlaceholder: "https://api.example.com/v1", + learnMore: "Learn how to configure API keys →", + testConnection: "Test Connection", + testing: "Testing...", + testSuccess: "Connection successful", + testFailed: "Connection test failed", + syncModels: "Sync Models", + syncing: "Syncing...", + syncSuccess: "Discovered {discovered} models, added {new} new", + syncNoNew: "Discovered {count} models, all already registered", + syncFailed: "Failed to sync models", + syncAllModels: "Sync All Providers", + syncAllSuccess: "Discovered {discovered} models across all providers, added {new} new", + modelsConfigured: "{count} models", + noModelsConfigured: "No models", + viewModels: "View Models", + supportedTypes: "Supported types", + typeLanguage: "Language", + typeEmbedding: "Embedding", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "API Endpoint", + getApiKey: "Get API Key", + vertexProject: "GCP Project ID", + vertexLocation: "Region", + vertexCredentials: "Service Account JSON Path", + vertexCredentialsHint: "Path to your Google Cloud service account JSON file inside the container.", + + // Multi-config translations + configsCount: "{count} configs", + configuredMultiple: "Configured", + addConfig: "Add Configuration", + editConfig: "Edit Configuration", + deleteConfig: "Delete Configuration", + setAsDefault: "Set as Default", + defaultBadge: "Default", + defaultDescription: "Default configuration for this provider", + configName: "Configuration Name", + configNameHint: "A descriptive name for this configuration (e.g., 'Production', 'Development')", + baseUrl: "Base URL", + baseUrlHint: "Default: {url}", + baseUrlOverrideHint: "Only change this if you need to override the provider's default API endpoint.", + ollamaApiKeyHint: "Only required for Ollama Cloud. Leave empty for local Ollama.", + noConfigs: "No configurations yet", + noConfigsHint: "Add a configuration to start using this provider", + deleteConfigConfirm: "Are you sure you want to delete '{name}'? This cannot be undone.", + setDefaultConfirm: "Set '{name}' as the default configuration?", + configSaveSuccess: "Configuration saved successfully", + configUpdateSuccess: "Configuration updated successfully", + configDeleteSuccess: "Configuration deleted successfully", + configSetDefaultSuccess: "Default configuration updated", + apiKeyHint: "Enter your API key for this configuration", + 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", + }, } diff --git a/frontend/src/lib/locales/it-IT/index.ts b/frontend/src/lib/locales/it-IT/index.ts index b4cdb47..beaa42f 100644 --- a/frontend/src/lib/locales/it-IT/index.ts +++ b/frontend/src/lib/locales/it-IT/index.ts @@ -909,6 +909,10 @@ export const itIT = { noModelsConfigured: "Nessun modello configurato", noProviderModelsConfigured: "Nessun modello {provider} configurato", showMore: "Mostra altri {count}", + discoverModels: "Scopri Modelli", + noModelsFound: "Nessun modello trovato per questo provider", + modelType: "Tipo di Modello", + modelTypeHint: "Seleziona il tipo per i modelli che vuoi aggiungere. Se hai bisogno di tipi diversi, aggiungili in lotti separati.", deleteModel: "Elimina modello", deleteModelDesc: "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.", defaultAssignments: "Assegnazioni modelli predefiniti", @@ -953,5 +957,104 @@ export const itIT = { proceedToRebuildPrompt: "Vuoi procedere alla pagina avanzate per avviare la ricostruzione ora?", changeModelOnly: "Cambia solo modello", changeAndRebuild: "Cambia e vai a ricostruzione", - } + testModel: "Testa Modello", + testModelSuccess: "Test del Modello Superato", + testModelFailed: "Test del Modello Fallito", + testingModel: "Test del modello in corso...", + searchOrAddModel: "Cerca o digita un nome modello...", + addCustomModel: 'Aggiungi "{name}"', + }, + apiKeys: { + title: "Configura la tua IA con le tue chiavi API", + description: "Salva le chiavi API in modo sicuro nel database per abilitare i provider IA in Open Notebook.", + loadFailed: "Impossibile caricare lo stato delle chiavi API", + encryptionRequired: "Chiave di crittografia non configurata", + encryptionRequiredDescription: "Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY su una stringa segreta qualsiasi per abilitare il salvataggio delle chiavi API nel database.", + configured: "Configurato", + notConfigured: "Non configurato", + sourceDatabase: "Database", + sourceEnvironment: "Variabile d'ambiente", + enterApiKey: "Inserisci la tua chiave API", + enterBaseUrl: "Inserisci l'URL base", + saveSuccess: "Chiave API salvata con successo", + deleteSuccess: "Chiave API eliminata con successo", + fromEnvironmentHint: "Questa chiave è impostata tramite variabile d'ambiente. Salva una nuova chiave per sovrascriverla nel database.", + migrationAvailable: "Variabili d'ambiente rilevate", + migrationDescription: "{count} chiave/i API configurata/e tramite variabili d'ambiente. Puoi migrarle nel database per una gestione più semplice.", + migrateToDatabase: "Migra nel database", + migrating: "Migrazione in corso...", + migrationSuccess: "{count} chiave/i API migrata/e con successo", + migrationErrors: "{count} chiave/i non migrata/e", + migrationNothingToMigrate: "Tutte le chiavi sono già nel database", + serviceType: "Tipo di servizio", + serviceLlm: "Modello linguistico (LLM)", + serviceEmbedding: "Embedding", + serviceStt: "Riconoscimento vocale (STT)", + serviceTts: "Sintesi vocale (TTS)", + serviceEndpoints: "Endpoint dei servizi (opzionale)", + azureEndpointsHint: "Se necessario, configura endpoint diversi per ogni tipo di servizio.", + endpointPlaceholder: "https://your-resource.openai.azure.com/", + openaiCompatibleHint: "Configura un endpoint API compatibile con OpenAI. Ogni tipo di servizio può avere la propria configurazione.", + baseUrlPlaceholder: "https://api.example.com/v1", + learnMore: "Scopri come configurare le chiavi API →", + testConnection: "Testa connessione", + testing: "Test in corso...", + testSuccess: "Connessione riuscita", + testFailed: "Test di connessione fallito", + syncModels: "Sincronizza modelli", + syncing: "Sincronizzazione...", + syncSuccess: "Trovati {discovered} modelli, aggiunti {new} nuovi", + syncNoNew: "Trovati {count} modelli, tutti già registrati", + syncFailed: "Sincronizzazione modelli fallita", + syncAllModels: "Sincronizza tutti i provider", + syncAllSuccess: "Trovati {discovered} modelli da tutti i provider, aggiunti {new} nuovi", + modelsConfigured: "{count} modelli", + noModelsConfigured: "Nessun modello", + viewModels: "Visualizza modelli", + supportedTypes: "Tipi supportati", + typeLanguage: "Linguistico", + typeEmbedding: "Embedding", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "Endpoint API", + getApiKey: "Ottieni chiave API", + vertexProject: "ID progetto GCP", + vertexLocation: "Regione", + vertexCredentials: "Percorso JSON account di servizio", + vertexCredentialsHint: "Percorso del file JSON dell'account di servizio Google Cloud all'interno del container.", + + // Traduzioni multi-configurazione + configsCount: "{count} configurazioni", + configuredMultiple: "Configurato", + addConfig: "Aggiungi configurazione", + editConfig: "Modifica configurazione", + deleteConfig: "Elimina configurazione", + setAsDefault: "Imposta come predefinito", + defaultBadge: "Predefinito", + defaultDescription: "Configurazione predefinita per questo provider", + configName: "Nome configurazione", + configNameHint: "Un nome descrittivo per questa configurazione (es. 'Produzione', 'Sviluppo')", + baseUrl: "URL base", + baseUrlHint: "Predefinito: {url}", + baseUrlOverrideHint: "Modifica solo se devi sovrascrivere l'endpoint API predefinito del provider.", + ollamaApiKeyHint: "Necessaria solo per Ollama Cloud. Lascia vuoto per Ollama locale.", + noConfigs: "Nessuna configurazione presente", + noConfigsHint: "Aggiungi una configurazione per iniziare a usare questo provider", + deleteConfigConfirm: "Sei sicuro di voler eliminare '{name}'? Questa azione non può essere annullata.", + setDefaultConfirm: "Impostare '{name}' come configurazione predefinita?", + configSaveSuccess: "Configurazione salvata con successo", + configUpdateSuccess: "Configurazione aggiornata con successo", + configDeleteSuccess: "Configurazione eliminata con successo", + configSetDefaultSuccess: "Configurazione predefinita aggiornata", + apiKeyHint: "Inserisci la chiave API per questa configurazione", + apiKeyEditHint: "Lascia vuoto per mantenere la chiave API esistente", + }, + setupBanner: { + encryptionRequired: "Chiave di crittografia non configurata", + encryptionRequiredDescription: "Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY per abilitare l'archiviazione sicura delle credenziali.", + migrationAvailable: "Migrazione chiavi API disponibile", + migrationDescription: "{count} provider hanno chiavi API impostate tramite variabili d'ambiente. Migrale nel database per una gestione più semplice.", + goToSettings: "Vai alle Impostazioni", + viewDocs: "Vedi documentazione", + }, } diff --git a/frontend/src/lib/locales/ja-JP/index.ts b/frontend/src/lib/locales/ja-JP/index.ts index 7f0671f..90aee8b 100644 --- a/frontend/src/lib/locales/ja-JP/index.ts +++ b/frontend/src/lib/locales/ja-JP/index.ts @@ -910,6 +910,10 @@ export const jaJP = { noModelsConfigured: "モデルが設定されていません", noProviderModelsConfigured: "{provider}モデルが設定されていません", showMore: "さらに{count}件表示", + discoverModels: "モデルを検出", + noModelsFound: "このプロバイダーからモデルが見つかりません", + modelType: "モデルタイプ", + modelTypeHint: "追加するモデルのタイプを選択してください。異なるタイプが必要な場合は、別々のバッチで追加してください。", deleteModel: "モデルを削除", deleteModelDesc: "「{name}」を削除しますか?この操作は元に戻せません。", defaultAssignments: "デフォルトモデル割り当て", @@ -954,5 +958,110 @@ export const jaJP = { proceedToRebuildPrompt: "今すぐ詳細設定ページで再構築を開始しますか?", changeModelOnly: "モデルのみ変更", changeAndRebuild: "変更して再構築へ", - } + autoAssign: "デフォルトを自動割り当て", + autoAssignDesc: "各スロットに最適なモデルを自動的に割り当てます", + autoAssigning: "割り当て中...", + autoAssignSuccess: "{count}件のデフォルトモデルを自動的に割り当てました", + autoAssignNoModels: "割り当て可能なモデルがありません。先にモデルを同期してください。", + autoAssignAlreadySet: "すべてのデフォルトモデルは既に設定されています", + testModel: "モデルをテスト", + testModelSuccess: "モデルテスト成功", + testModelFailed: "モデルテスト失敗", + testingModel: "モデルをテスト中...", + searchOrAddModel: "検索またはモデル名を入力...", + addCustomModel: '"{name}" を追加', + }, + apiKeys: { + title: "独自のAPIキーでAIを設定", + description: "APIキーをデータベースに安全に保存し、Open NotebookでAIプロバイダーを有効にします。", + loadFailed: "APIキーのステータスの読み込みに失敗しました", + encryptionRequired: "暗号化キーが設定されていません", + encryptionRequiredDescription: "OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数に任意の秘密文字列を設定して、データベースへのAPIキーの保存を有効にしてください。", + configured: "設定済み", + notConfigured: "未設定", + sourceDatabase: "データベース", + sourceEnvironment: "環境変数", + enterApiKey: "APIキーを入力してください", + enterBaseUrl: "ベースURLを入力してください", + saveSuccess: "APIキーを保存しました", + deleteSuccess: "APIキーを削除しました", + fromEnvironmentHint: "このキーは環境変数で設定されています。新しいキーを保存するとデータベースで上書きされます。", + migrationAvailable: "環境変数を検出", + migrationDescription: "{count}個のAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行できます。", + migrateToDatabase: "データベースに移行", + migrating: "移行中...", + migrationSuccess: "{count}個のAPIキーを移行しました", + migrationErrors: "{count}個のキーの移行に失敗しました", + migrationNothingToMigrate: "すべてのキーはすでにデータベースにあります", + serviceType: "サービスタイプ", + serviceLlm: "言語モデル(LLM)", + serviceEmbedding: "Embedding", + serviceStt: "音声認識(STT)", + serviceTts: "音声合成(TTS)", + serviceEndpoints: "サービスエンドポイント(任意)", + azureEndpointsHint: "必要に応じて、各サービスタイプに異なるエンドポイントを設定します。", + endpointPlaceholder: "https://your-resource.openai.azure.com/", + openaiCompatibleHint: "OpenAI互換のAPIエンドポイントを設定します。各サービスタイプに独自の設定が可能です。", + baseUrlPlaceholder: "https://api.example.com/v1", + learnMore: "APIキーの設定方法を確認 →", + testConnection: "接続テスト", + testing: "テスト中...", + testSuccess: "接続成功", + testFailed: "接続テストに失敗", + syncModels: "モデル同期", + syncing: "同期中...", + syncSuccess: "{discovered} モデルを発見、{new} 個を新規追加", + syncNoNew: "{count} モデルを発見、すべて登録済み", + syncFailed: "モデルの同期に失敗", + syncAllModels: "全プロバイダーを同期", + syncAllSuccess: "全プロバイダーで {discovered} モデルを発見、{new} 個を新規追加", + modelsConfigured: "{count} モデル", + noModelsConfigured: "モデルなし", + viewModels: "モデルを表示", + supportedTypes: "対応タイプ", + typeLanguage: "言語", + typeEmbedding: "埋め込み", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "APIエンドポイント", + getApiKey: "APIキーを取得", + vertexProject: "GCPプロジェクトID", + vertexLocation: "リージョン", + vertexCredentials: "サービスアカウントJSONパス", + vertexCredentialsHint: "コンテナ内のGoogle Cloudサービスアカウント JSON ファイルへのパス。", + + // Multi-config translations + configsCount: "{count} 設定", + configuredMultiple: "設定済み", + addConfig: "設定を追加", + editConfig: "設定を編集", + deleteConfig: "設定を削除", + setAsDefault: "デフォルトに設定", + defaultBadge: "デフォルト", + defaultDescription: "このプロバイダーのデフォルト設定", + configName: "設定名", + configNameHint: "この設定の説明的な名前(例:本番環境、開発環境)", + baseUrl: "ベースURL", + baseUrlHint: "デフォルト:{url}", + baseUrlOverrideHint: "プロバイダーのデフォルト API エンドポイントを上書きする場合のみ変更してください。", + ollamaApiKeyHint: "Ollama Cloud でのみ必要です。ローカル Ollama の場合は空のままにしてください。", + noConfigs: "設定がありません", + noConfigsHint: "このプロバイダーの使用を開始するには設定を追加してください", + deleteConfigConfirm: "「{name}」を削除してもよろしいですか?この操作は元に戻せません。", + setDefaultConfirm: "「{name}」をデフォルト設定にしますか?", + configSaveSuccess: "設定が正常に保存されました", + configUpdateSuccess: "設定が正常に変更されました", + configDeleteSuccess: "設定が正常に削除されました", + configSetDefaultSuccess: "デフォルト設定が更新されました", + apiKeyHint: "この設定のAPIキーを入力してください", + apiKeyEditHint: "既存のAPIキーを維持するには空白のままにしてください", + }, + setupBanner: { + encryptionRequired: "暗号化キーが設定されていません", + encryptionRequiredDescription: "OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数を設定して、安全な認証情報の保存を有効にしてください。", + migrationAvailable: "APIキーの移行が可能です", + migrationDescription: "{count} 個のプロバイダーのAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行してください。", + goToSettings: "設定へ移動", + viewDocs: "ドキュメントを見る", + }, } diff --git a/frontend/src/lib/locales/pt-BR/index.ts b/frontend/src/lib/locales/pt-BR/index.ts index eab0776..6b700cc 100644 --- a/frontend/src/lib/locales/pt-BR/index.ts +++ b/frontend/src/lib/locales/pt-BR/index.ts @@ -910,6 +910,10 @@ export const ptBR = { noModelsConfigured: "Nenhum modelo configurado", noProviderModelsConfigured: "Nenhum modelo {provider} configurado", showMore: "Mostrar mais {count}", + discoverModels: "Descobrir Modelos", + noModelsFound: "Nenhum modelo encontrado para este provedor", + modelType: "Tipo do Modelo", + modelTypeHint: "Selecione o tipo para os modelos que deseja adicionar. Se precisar de tipos diferentes, adicione em lotes separados.", deleteModel: "Excluir Modelo", deleteModelDesc: "Tem certeza que deseja excluir \"{name}\"? Esta ação não pode ser desfeita.", defaultAssignments: "Atribuições de Modelo Padrão", @@ -954,5 +958,110 @@ export const ptBR = { proceedToRebuildPrompt: "Gostaria de ir para a página Avançado para iniciar a reconstrução agora?", changeModelOnly: "Apenas Alterar Modelo", changeAndRebuild: "Alterar e Ir para Reconstrução", - } + autoAssign: "Atribuir Automaticamente", + autoAssignDesc: "Atribuir automaticamente o melhor modelo disponível para cada slot", + autoAssigning: "Atribuindo...", + autoAssignSuccess: "{count} modelos padrão atribuídos automaticamente", + autoAssignNoModels: "Nenhum modelo disponível para atribuir. Por favor, sincronize os modelos primeiro.", + autoAssignAlreadySet: "Todos os modelos padrão já estão configurados", + testModel: "Testar Modelo", + testModelSuccess: "Teste do Modelo Passou", + testModelFailed: "Teste do Modelo Falhou", + testingModel: "Testando modelo...", + searchOrAddModel: "Pesquisar ou digitar nome do modelo...", + addCustomModel: 'Adicionar "{name}"', + }, + apiKeys: { + title: "Configure sua IA com suas próprias chaves de API", + description: "Armazene chaves de API com segurança no banco de dados para habilitar provedores de IA no Open Notebook.", + loadFailed: "Falha ao carregar status das chaves de API", + encryptionRequired: "Chave de criptografia não configurada", + encryptionRequiredDescription: "Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY com qualquer string secreta para armazenar chaves de API no banco de dados.", + configured: "Configurado", + notConfigured: "Não configurado", + sourceDatabase: "Banco de dados", + sourceEnvironment: "Ambiente", + enterApiKey: "Digite sua chave de API", + enterBaseUrl: "Digite a URL base", + saveSuccess: "Chave de API salva com sucesso", + deleteSuccess: "Chave de API excluída com sucesso", + fromEnvironmentHint: "Esta chave é definida via variável de ambiente. Salve uma nova chave para sobrescrevê-la no banco de dados.", + migrationAvailable: "Variáveis de Ambiente Detectadas", + migrationDescription: "{count} chave(s) de API estão configuradas via variáveis de ambiente e podem ser migradas para o banco de dados para facilitar o gerenciamento.", + migrateToDatabase: "Migrar para Banco de Dados", + migrating: "Migrando...", + migrationSuccess: "{count} chave(s) de API migrada(s) com sucesso", + migrationErrors: "{count} chave(s) falhou ao migrar", + migrationNothingToMigrate: "Todas as chaves já estão no banco de dados", + serviceType: "Tipo de Serviço", + serviceLlm: "Modelo de Linguagem (LLM)", + serviceEmbedding: "Embedding", + serviceStt: "Speech to Text (STT)", + serviceTts: "Text to Speech (TTS)", + serviceEndpoints: "Endpoints de Serviço (opcional)", + azureEndpointsHint: "Configure endpoints diferentes para cada tipo de serviço se necessário.", + endpointPlaceholder: "https://seu-recurso.openai.azure.com/", + openaiCompatibleHint: "Configure um endpoint de API compatível com OpenAI. Cada tipo de serviço pode ter sua própria configuração.", + baseUrlPlaceholder: "https://api.exemplo.com/v1", + learnMore: "Saiba como configurar chaves de API →", + testConnection: "Testar Conexão", + testing: "Testando...", + testSuccess: "Conexão bem-sucedida", + testFailed: "Falha no teste de conexão", + syncModels: "Sincronizar Modelos", + syncing: "Sincronizando...", + syncSuccess: "Descobertos {discovered} modelos, {new} novos adicionados", + syncNoNew: "Descobertos {count} modelos, todos já registrados", + syncFailed: "Falha ao sincronizar modelos", + syncAllModels: "Sincronizar Todos os Provedores", + syncAllSuccess: "Descobertos {discovered} modelos em todos os provedores, {new} novos adicionados", + modelsConfigured: "{count} modelos", + noModelsConfigured: "Sem modelos", + viewModels: "Ver Modelos", + supportedTypes: "Tipos suportados", + typeLanguage: "Linguagem", + typeEmbedding: "Embedding", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "Endpoint da API", + getApiKey: "Obter Chave de API", + vertexProject: "ID do Projeto GCP", + vertexLocation: "Região", + vertexCredentials: "Caminho do JSON da Conta de Serviço", + vertexCredentialsHint: "Caminho para o arquivo JSON da conta de serviço do Google Cloud dentro do contêiner.", + + // Multi-config translations + configsCount: "{count} configurações", + configuredMultiple: "Configurado", + addConfig: "Adicionar Configuração", + editConfig: "Editar Configuração", + deleteConfig: "Excluir Configuração", + setAsDefault: "Definir como Padrão", + defaultBadge: "Padrão", + defaultDescription: "Configuração padrão para este provedor", + configName: "Nome da Configuração", + configNameHint: "Um nome descritivo para esta configuração (ex.: 'Produção', 'Desenvolvimento')", + baseUrl: "URL Base", + baseUrlHint: "Padrão: {url}", + baseUrlOverrideHint: "Altere apenas se precisar sobrescrever o endpoint padrão do provedor.", + ollamaApiKeyHint: "Necessária apenas para Ollama Cloud. Deixe vazio para Ollama local.", + noConfigs: "Sem configurações ainda", + noConfigsHint: "Adicione uma configuração para começar a usar este provedor", + deleteConfigConfirm: "Tem certeza de que deseja excluir '{name}'? Esta ação não pode ser desfeita.", + setDefaultConfirm: "Definir '{name}' como a configuração padrão?", + configSaveSuccess: "Configuração salva com sucesso", + configUpdateSuccess: "Configuração atualizada com sucesso", + configDeleteSuccess: "Configuração excluída com sucesso", + configSetDefaultSuccess: "Configuração padrão atualizada", + apiKeyHint: "Digite sua chave de API para esta configuração", + apiKeyEditHint: "Deixe em branco para manter a chave de API existente", + }, + setupBanner: { + encryptionRequired: "Chave de criptografia não configurada", + encryptionRequiredDescription: "Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY para habilitar o armazenamento seguro de credenciais.", + migrationAvailable: "Migração de chaves de API disponível", + migrationDescription: "{count} provedor(es) possuem chaves de API definidas por variáveis de ambiente. Migre-as para o banco de dados para facilitar o gerenciamento.", + goToSettings: "Ir para Configurações", + viewDocs: "Ver documentação", + }, } diff --git a/frontend/src/lib/locales/ru-RU/index.ts b/frontend/src/lib/locales/ru-RU/index.ts index 2dd39ff..9cf1e1a 100644 --- a/frontend/src/lib/locales/ru-RU/index.ts +++ b/frontend/src/lib/locales/ru-RU/index.ts @@ -910,6 +910,10 @@ export const ruRU = { noModelsConfigured: "Модели не настроены", noProviderModelsConfigured: "Модели {provider} не настроены", showMore: "Показать ещё {count}", + discoverModels: "Обнаружение моделей", + noModelsFound: "Модели от этого провайдера не найдены", + modelType: "Тип модели", + modelTypeHint: "Выберите тип для добавляемых моделей. Если нужны разные типы, добавляйте их отдельными партиями.", deleteModel: "Удалить модель", deleteModelDesc: "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.", defaultAssignments: "Назначение моделей по умолчанию", @@ -954,5 +958,104 @@ export const ruRU = { proceedToRebuildPrompt: "Хотите перейти на страницу «Дополнительно», чтобы начать пересоздание сейчас?", changeModelOnly: "Только изменить модель", changeAndRebuild: "Изменить и перейти к пересозданию", - } + testModel: "Тестировать модель", + testModelSuccess: "Тест модели пройден", + testModelFailed: "Тест модели не пройден", + testingModel: "Тестирование модели...", + searchOrAddModel: "Поиск или введите имя модели...", + addCustomModel: 'Добавить "{name}"', + }, + apiKeys: { + title: "Настройте ИИ с помощью собственных API-ключей", + description: "Храните API-ключи в базе данных для безопасного подключения провайдеров ИИ в Open Notebook.", + loadFailed: "Не удалось загрузить статус API-ключей", + encryptionRequired: "Ключ шифрования не настроен", + encryptionRequiredDescription: "Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY в любую секретную строку для хранения API-ключей в базе данных.", + configured: "Настроено", + notConfigured: "Не настроено", + sourceDatabase: "База данных", + sourceEnvironment: "Переменная окружения", + enterApiKey: "Введите ваш API-ключ", + enterBaseUrl: "Введите базовый URL", + saveSuccess: "API-ключ успешно сохранён", + deleteSuccess: "API-ключ успешно удалён", + fromEnvironmentHint: "Этот ключ задан через переменную окружения. Сохраните новый ключ, чтобы переопределить его в базе данных.", + migrationAvailable: "Обнаружены переменные окружения", + migrationDescription: "{count} API-ключ(ей) настроено через переменные окружения и может быть перенесено в базу данных для удобного управления.", + migrateToDatabase: "Перенести в базу данных", + migrating: "Перенос...", + migrationSuccess: "{count} API-ключ(ей) успешно перенесено", + migrationErrors: "{count} ключ(ей) не удалось перенести", + migrationNothingToMigrate: "Все ключи уже находятся в базе данных", + serviceType: "Тип сервиса", + serviceLlm: "Языковая модель (LLM)", + serviceEmbedding: "Эмбеддинг", + serviceStt: "Распознавание речи (STT)", + serviceTts: "Синтез речи (TTS)", + serviceEndpoints: "Эндпоинты сервисов (необязательно)", + azureEndpointsHint: "При необходимости настройте отдельные эндпоинты для каждого типа сервиса.", + endpointPlaceholder: "https://your-resource.openai.azure.com/", + openaiCompatibleHint: "Настройте совместимый с OpenAI API-эндпоинт. Каждый тип сервиса может иметь собственную конфигурацию.", + baseUrlPlaceholder: "https://api.example.com/v1", + learnMore: "Узнайте, как настроить API-ключи →", + testConnection: "Проверить подключение", + testing: "Проверка...", + testSuccess: "Подключение успешно", + testFailed: "Проверка подключения не удалась", + syncModels: "Синхронизировать модели", + syncing: "Синхронизация...", + syncSuccess: "Обнаружено {discovered} моделей, добавлено {new} новых", + syncNoNew: "Обнаружено {count} моделей, все уже зарегистрированы", + syncFailed: "Не удалось синхронизировать модели", + syncAllModels: "Синхронизировать всех провайдеров", + syncAllSuccess: "Обнаружено {discovered} моделей у всех провайдеров, добавлено {new} новых", + modelsConfigured: "{count} моделей", + noModelsConfigured: "Нет моделей", + viewModels: "Посмотреть модели", + supportedTypes: "Поддерживаемые типы", + typeLanguage: "Языковая", + typeEmbedding: "Эмбеддинг", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "API-эндпоинт", + getApiKey: "Получить API-ключ", + vertexProject: "ID проекта GCP", + vertexLocation: "Регион", + vertexCredentials: "Путь к JSON сервисного аккаунта", + vertexCredentialsHint: "Путь к JSON-файлу сервисного аккаунта Google Cloud внутри контейнера.", + + // Мультиконфигурация + configsCount: "{count} конфигураций", + configuredMultiple: "Настроено", + addConfig: "Добавить конфигурацию", + editConfig: "Редактировать конфигурацию", + deleteConfig: "Удалить конфигурацию", + setAsDefault: "Установить по умолчанию", + defaultBadge: "По умолчанию", + defaultDescription: "Конфигурация по умолчанию для этого провайдера", + configName: "Название конфигурации", + configNameHint: "Описательное название для этой конфигурации (например, «Продакшн», «Разработка»)", + baseUrl: "Базовый URL", + baseUrlHint: "По умолчанию: {url}", + baseUrlOverrideHint: "Изменяйте только если нужно переопределить стандартную конечную точку API провайдера.", + ollamaApiKeyHint: "Требуется только для Ollama Cloud. Оставьте пустым для локальной Ollama.", + noConfigs: "Конфигурации ещё не созданы", + noConfigsHint: "Добавьте конфигурацию, чтобы начать использовать этого провайдера", + deleteConfigConfirm: "Вы уверены, что хотите удалить «{name}»? Это действие необратимо.", + setDefaultConfirm: "Установить «{name}» как конфигурацию по умолчанию?", + configSaveSuccess: "Конфигурация успешно сохранена", + configUpdateSuccess: "Конфигурация успешно обновлена", + configDeleteSuccess: "Конфигурация успешно удалена", + configSetDefaultSuccess: "Конфигурация по умолчанию обновлена", + apiKeyHint: "Введите API-ключ для этой конфигурации", + apiKeyEditHint: "Оставьте пустым, чтобы сохранить текущий API-ключ", + }, + setupBanner: { + encryptionRequired: "Ключ шифрования не настроен", + encryptionRequiredDescription: "Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY для безопасного хранения учётных данных.", + migrationAvailable: "Доступна миграция API-ключей", + migrationDescription: "{count} провайдер(ов) имеют API-ключи, заданные через переменные окружения. Перенесите их в базу данных для удобного управления.", + goToSettings: "Перейти к настройкам", + viewDocs: "Документация", + }, } diff --git a/frontend/src/lib/locales/zh-CN/index.ts b/frontend/src/lib/locales/zh-CN/index.ts index 408dbd2..ea0c5a7 100644 --- a/frontend/src/lib/locales/zh-CN/index.ts +++ b/frontend/src/lib/locales/zh-CN/index.ts @@ -910,6 +910,10 @@ export const zhCN = { noModelsConfigured: "未配置模型", noProviderModelsConfigured: "未配置 {provider} 模型", showMore: "显示更多 ({count})", + discoverModels: "发现模型", + noModelsFound: "未从此提供商找到模型", + modelType: "模型类型", + modelTypeHint: "选择要添加的模型类型。如果需要不同类型,请分批添加。", deleteModel: "删除模型", deleteModelDesc: "确定要删除模型 “{name}” 吗?该操作无法撤销。", defaultAssignments: "默认模型分配", @@ -954,5 +958,110 @@ export const zhCN = { proceedToRebuildPrompt: "您想现在前往“高级设置”页面开始重建索引吗?", changeModelOnly: "仅更改模型", changeAndRebuild: "更改并前往重建", + autoAssign: "自动分配默认值", + autoAssignDesc: "为每个槽位自动分配最佳可用模型", + autoAssigning: "正在分配...", + autoAssignSuccess: "已自动分配 {count} 个默认模型", + autoAssignNoModels: "没有可分配的模型。请先同步模型。", + autoAssignAlreadySet: "所有默认模型已配置", + testModel: "测试模型", + testModelSuccess: "模型测试通过", + testModelFailed: "模型测试失败", + testingModel: "正在测试模型...", + searchOrAddModel: "搜索或输入模型名称...", + addCustomModel: '添加 "{name}"', + }, + apiKeys: { + title: "使用您自己的 API 密钥配置 AI", + description: "将 API 密钥安全地存储在数据库中,以在 Open Notebook 中启用 AI 服务商。", + loadFailed: "加载 API 密钥状态失败", + encryptionRequired: "未配置加密密钥", + encryptionRequiredDescription: "请将 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量设置为任意密钥字符串,以启用将 API 密钥存储到数据库。", + configured: "已配置", + notConfigured: "未配置", + sourceDatabase: "数据库", + sourceEnvironment: "环境变量", + enterApiKey: "输入您的 API 密钥", + enterBaseUrl: "输入基础 URL", + saveSuccess: "API 密钥保存成功", + deleteSuccess: "API 密钥删除成功", + fromEnvironmentHint: "此密钥通过环境变量设置。保存新密钥将在数据库中覆盖它。", + migrationAvailable: "检测到环境变量", + migrationDescription: "{count} 个 API 密钥通过环境变量配置,可以迁移到数据库以便于管理。", + migrateToDatabase: "迁移到数据库", + migrating: "迁移中...", + migrationSuccess: "{count} 个 API 密钥迁移成功", + migrationErrors: "{count} 个密钥迁移失败", + migrationNothingToMigrate: "所有密钥已在数据库中", + serviceType: "服务类型", + serviceLlm: "语言模型 (LLM)", + serviceEmbedding: "嵌入", + serviceStt: "语音转文字 (STT)", + serviceTts: "文字转语音 (TTS)", + serviceEndpoints: "服务端点(可选)", + azureEndpointsHint: "如有需要,为每种服务类型配置不同的端点。", + endpointPlaceholder: "https://your-resource.openai.azure.com/", + openaiCompatibleHint: "配置 OpenAI 兼容的 API 端点。每种服务类型可以有自己的配置。", + baseUrlPlaceholder: "https://api.example.com/v1", + learnMore: "了解如何配置 API 密钥 →", + testConnection: "测试连接", + testing: "测试中...", + testSuccess: "连接成功", + testFailed: "连接测试失败", + syncModels: "同步模型", + syncing: "同步中...", + syncSuccess: "发现 {discovered} 个模型,新增 {new} 个", + syncNoNew: "发现 {count} 个模型,全部已注册", + syncFailed: "同步模型失败", + syncAllModels: "同步所有提供商", + syncAllSuccess: "在所有提供商中发现 {discovered} 个模型,新增 {new} 个", + modelsConfigured: "{count} 个模型", + noModelsConfigured: "无模型", + viewModels: "查看模型", + supportedTypes: "支持的类型", + typeLanguage: "语言", + typeEmbedding: "嵌入", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "API 端点", + getApiKey: "获取 API 密钥", + vertexProject: "GCP 项目 ID", + vertexLocation: "区域", + vertexCredentials: "服务账户 JSON 路径", + vertexCredentialsHint: "容器内 Google Cloud 服务账户 JSON 文件的路径。", + + // Multi-config translations + configsCount: "{count} 个配置", + configuredMultiple: "已配置", + addConfig: "添加配置", + editConfig: "编辑配置", + deleteConfig: "删除配置", + setAsDefault: "设为默认", + defaultBadge: "默认", + defaultDescription: "此提供商的默认配置", + configName: "配置名称", + configNameHint: "此配置的描述性名称(例如:'生产环境'、'开发环境')", + baseUrl: "基础 URL", + baseUrlHint: "默认:{url}", + baseUrlOverrideHint: "仅在需要覆盖提供商默认 API 端点时更改此项。", + ollamaApiKeyHint: "仅 Ollama Cloud 需要 API 密钥。本地 Ollama 请留空。", + noConfigs: "暂无配置", + noConfigsHint: "添加配置以开始使用此提供商", + deleteConfigConfirm: "确定要删除 '{name}' 吗?此操作无法撤销。", + setDefaultConfirm: "将 '{name}' 设为默认配置?", + configSaveSuccess: "配置保存成功", + configUpdateSuccess: "配置更新成功", + configDeleteSuccess: "配置删除成功", + configSetDefaultSuccess: "默认配置已更新", + apiKeyHint: "输入此配置的 API 密钥", + apiKeyEditHint: "留空以保留现有 API 密钥", + }, + setupBanner: { + encryptionRequired: "未配置加密密钥", + encryptionRequiredDescription: "请设置 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量以启用安全凭据存储。", + migrationAvailable: "API 密钥迁移可用", + migrationDescription: "{count} 个服务商的 API 密钥通过环境变量设置。将它们迁移到数据库以便于管理。", + goToSettings: "前往设置", + viewDocs: "查看文档", }, } diff --git a/frontend/src/lib/locales/zh-TW/index.ts b/frontend/src/lib/locales/zh-TW/index.ts index 07fc601..3d7c128 100644 --- a/frontend/src/lib/locales/zh-TW/index.ts +++ b/frontend/src/lib/locales/zh-TW/index.ts @@ -910,6 +910,10 @@ export const zhTW = { noModelsConfigured: "未設定模型", noProviderModelsConfigured: "未設定 {provider} 模型", showMore: "顯示更多 ({count})", + discoverModels: "探索模型", + noModelsFound: "未從此提供商找到模型", + modelType: "模型類型", + modelTypeHint: "選擇要新增的模型類型。如果需要不同類型,請分批新增。", deleteModel: "刪除模型", deleteModelDesc: "確定要刪除模型 “{name}” 嗎?該操作無法撤銷。", defaultAssignments: "預設模型分配", @@ -954,5 +958,110 @@ export const zhTW = { proceedToRebuildPrompt: "您想現在前往“進階設定”頁面開始重建索引嗎?", changeModelOnly: "僅更改模型", changeAndRebuild: "更改並前往重建", + autoAssign: "自動指派預設值", + autoAssignDesc: "為每個插槽自動指派最佳可用模型", + autoAssigning: "正在指派...", + autoAssignSuccess: "已自動指派 {count} 個預設模型", + autoAssignNoModels: "沒有可指派的模型。請先同步模型。", + autoAssignAlreadySet: "所有預設模型已設定", + testModel: "測試模型", + testModelSuccess: "模型測試通過", + testModelFailed: "模型測試失敗", + testingModel: "正在測試模型...", + searchOrAddModel: "搜尋或輸入模型名稱...", + addCustomModel: '新增 "{name}"', + }, + apiKeys: { + title: "使用您自己的 API 金鑰設定 AI", + description: "將 API 金鑰安全地儲存在資料庫中,以在 Open Notebook 中啟用 AI 服務商。", + loadFailed: "載入 API 金鑰狀態失敗", + encryptionRequired: "未設定加密金鑰", + encryptionRequiredDescription: "請將 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數設定為任意密鑰字串,以啟用將 API 金鑰儲存至資料庫。", + configured: "已設定", + notConfigured: "未設定", + sourceDatabase: "資料庫", + sourceEnvironment: "環境變數", + enterApiKey: "輸入您的 API 金鑰", + enterBaseUrl: "輸入基礎 URL", + saveSuccess: "API 金鑰儲存成功", + deleteSuccess: "API 金鑰刪除成功", + fromEnvironmentHint: "此金鑰通過環境變數設定。儲存新金鑰將在資料庫中覆蓋它。", + migrationAvailable: "偵測到環境變數", + migrationDescription: "{count} 個 API 金鑰通過環境變數設定,可以遷移到資料庫以便於管理。", + migrateToDatabase: "遷移到資料庫", + migrating: "遷移中...", + migrationSuccess: "{count} 個 API 金鑰遷移成功", + migrationErrors: "{count} 個金鑰遷移失敗", + migrationNothingToMigrate: "所有金鑰已在資料庫中", + serviceType: "服務類型", + serviceLlm: "語言模型 (LLM)", + serviceEmbedding: "嵌入", + serviceStt: "語音轉文字 (STT)", + serviceTts: "文字轉語音 (TTS)", + serviceEndpoints: "服務端點(選填)", + azureEndpointsHint: "如有需要,為每種服務類型設定不同的端點。", + endpointPlaceholder: "https://your-resource.openai.azure.com/", + openaiCompatibleHint: "設定 OpenAI 相容的 API 端點。每種服務類型可以有自己的設定。", + baseUrlPlaceholder: "https://api.example.com/v1", + learnMore: "瞭解如何設定 API 金鑰 →", + testConnection: "測試連線", + testing: "測試中...", + testSuccess: "連線成功", + testFailed: "連線測試失敗", + syncModels: "同步模型", + syncing: "同步中...", + syncSuccess: "發現 {discovered} 個模型,新增 {new} 個", + syncNoNew: "發現 {count} 個模型,全部已註冊", + syncFailed: "同步模型失敗", + syncAllModels: "同步所有供應商", + syncAllSuccess: "在所有供應商中發現 {discovered} 個模型,新增 {new} 個", + modelsConfigured: "{count} 個模型", + noModelsConfigured: "無模型", + viewModels: "查看模型", + supportedTypes: "支援的類型", + typeLanguage: "語言", + typeEmbedding: "嵌入", + typeTts: "TTS", + typeStt: "STT", + apiEndpoint: "API 端點", + getApiKey: "取得 API 金鑰", + vertexProject: "GCP 專案 ID", + vertexLocation: "區域", + vertexCredentials: "服務帳戶 JSON 路徑", + vertexCredentialsHint: "容器內 Google Cloud 服務帳戶 JSON 檔案的路徑。", + + // Multi-config translations + configsCount: "{count} 個設定", + configuredMultiple: "已設定", + addConfig: "新增設定", + editConfig: "編輯設定", + deleteConfig: "刪除設定", + setAsDefault: "設為預設", + defaultBadge: "預設", + defaultDescription: "此供應商的預設設定", + configName: "設定名稱", + configNameHint: "此設定的描述性名稱(例如:'生產環境'、'開發環境')", + baseUrl: "基礎 URL", + baseUrlHint: "預設:{url}", + baseUrlOverrideHint: "僅在需要覆蓋提供商預設 API 端點時更改此項。", + ollamaApiKeyHint: "僅 Ollama Cloud 需要 API 金鑰。本地 Ollama 請留空。", + noConfigs: "暫無設定", + noConfigsHint: "新增設定以開始使用此供應商", + deleteConfigConfirm: "確定要刪除 '{name}' 嗎?此操作無法撤銷。", + setDefaultConfirm: "將 '{name}' 設為預設設定?", + configSaveSuccess: "設定儲存成功", + configUpdateSuccess: "設定更新成功", + configDeleteSuccess: "設定刪除成功", + configSetDefaultSuccess: "預設設定已更新", + apiKeyHint: "輸入此設定的 API 金鑰", + apiKeyEditHint: "留空以保留現有 API 金鑰", + }, + setupBanner: { + encryptionRequired: "未設定加密金鑰", + encryptionRequiredDescription: "請設定 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數以啟用安全憑據儲存。", + migrationAvailable: "API 金鑰遷移可用", + migrationDescription: "{count} 個供應商的 API 金鑰透過環境變數設定。將它們遷移到資料庫以便於管理。", + goToSettings: "前往設定", + viewDocs: "查看文件", }, } diff --git a/frontend/src/lib/types/models.ts b/frontend/src/lib/types/models.ts index 40d18b5..6e81609 100644 --- a/frontend/src/lib/types/models.ts +++ b/frontend/src/lib/types/models.ts @@ -3,6 +3,7 @@ export interface Model { name: string provider: string type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text' + credential?: string | null created: string updated: string } @@ -11,6 +12,7 @@ export interface CreateModelRequest { name: string provider: string type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text' + credential?: string } export interface ModelDefaults { @@ -27,4 +29,43 @@ export interface ProviderAvailability { available: string[] unavailable: string[] supported_types: Record +} + +// Model Discovery Types +export interface DiscoveredModel { + name: string + provider: string + model_type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text' + description?: string +} + +export interface ProviderSyncResult { + provider: string + discovered: number + new: number + existing: number +} + +export interface AllProvidersSyncResult { + results: Record + total_discovered: number + total_new: number +} + +export interface ProviderModelCount { + provider: string + counts: Record + total: number +} + +export interface AutoAssignResult { + assigned: Record // slot_name -> model_id + skipped: string[] // slots already assigned + missing: string[] // slots with no available models +} + +export interface ModelTestResult { + success: boolean + message: string + details?: string } \ No newline at end of file diff --git a/open_notebook/CLAUDE.md b/open_notebook/CLAUDE.md index 5389c4f..4ddbf70 100644 --- a/open_notebook/CLAUDE.md +++ b/open_notebook/CLAUDE.md @@ -1,242 +1,221 @@ -# Open Notebook Core Backend +# Open Notebook - Root CLAUDE.md -The `open_notebook` module is the heart of the system: a multi-layer backend orchestrating AI-powered research workflows. It bridges domain models, asynchronous database operations, LangGraph-based content processing, and multi-provider AI model management. +This file provides architectural guidance for contributors working on Open Notebook at the project level. -## Purpose +## Project Overview -Encapsulates the entire backend architecture: -1. **Data layer**: SurrealDB persistence with async CRUD and migrations -2. **Domain layer**: Research models (Notebook, Source, Note, etc.) with embedded relationships -3. **Workflow layer**: LangGraph state machines for content ingestion, chat, and transformations -4. **AI provisioning**: Multi-provider model management with smart fallback logic -5. **Support services**: Context building, tokenization, and utility functions +**Open Notebook** is an open-source, privacy-focused alternative to Google's Notebook LM. It's an AI-powered research assistant enabling users to upload multi-modal content (PDFs, audio, video, web pages), generate intelligent notes, search semantically, chat with AI models, and produce professional podcasts—all with complete control over data and choice of AI providers. -All components communicate through async/await patterns and use Pydantic for validation. +**Key Values**: Privacy-first, multi-provider AI support, fully self-hosted option, open-source transparency. -## Architecture Overview +--- + +## Three-Tier Architecture ``` -┌─────────────────────────────────────────────────────────────┐ -│ API / Streamlit UI │ -└──────────────────────┬──────────────────────────────────────┘ - │ - ┌──────────────────┴──────────────────┐ - │ │ -┌───▼────────────────────┐ ┌──────────▼────────────────┐ -│ Graphs (LangGraph) │ │ Domain Models (Data) │ -│ - source.py (ingestion) │ │ - Notebook, Source, Note │ -│ - chat.py │ │ - ChatSession, Asset │ -│ - ask.py (search) │ │ - SourceInsight, Embedding│ -│ - transformation.py │ │ - Transformation, Settings│ -└───┬────────────────────┘ │ - EpisodeProfile, Podcast │ - │ └──────────┬─────────────────┘ - │ │ - └───────────────────┬───────────────┘ - │ - ┌───────────────────┴────────────────────┐ - │ │ -┌───▼─────────────────┐ ┌──────────────▼──────┐ -│ AI Module (Models) │ │ Utils (Helpers) │ -│ - ModelManager │ │ - ContextBuilder │ -│ - DefaultModels │ │ - TokenUtils │ -│ - provision_langchain│ │ - TextUtils │ -│ - Multi-provider AI │ │ - VersionUtils │ -└───┬─────────────────┘ └──────────┬──────────┘ - │ │ - └───────────────────┬───────────────┘ - │ - ┌──────────────▼────────────────┐ - │ Database (SurrealDB) │ - │ - repository.py (CRUD ops) │ - │ - async_migrate.py (schema) │ - │ - Configuration │ - └────────────────────────────────┘ +┌─────────────────────────────────────────────────────────┐ +│ Frontend (React/Next.js) │ +│ frontend/ @ port 3000 │ +├─────────────────────────────────────────────────────────┤ +│ - Notebooks, sources, notes, chat, podcasts, search UI │ +│ - Zustand state management, TanStack Query (React Query)│ +│ - Shadcn/ui component library with Tailwind CSS │ +└────────────────────────┬────────────────────────────────┘ + │ HTTP REST +┌────────────────────────▼────────────────────────────────┐ +│ API (FastAPI) │ +│ api/ @ port 5055 │ +├─────────────────────────────────────────────────────────┤ +│ - REST endpoints for notebooks, sources, notes, chat │ +│ - LangGraph workflow orchestration │ +│ - Job queue for async operations (podcasts) │ +│ - Multi-provider AI provisioning via Esperanto │ +└────────────────────────┬────────────────────────────────┘ + │ SurrealQL +┌────────────────────────▼────────────────────────────────┐ +│ Database (SurrealDB) │ +│ Graph database @ port 8000 │ +├─────────────────────────────────────────────────────────┤ +│ - Records: Notebook, Source, Note, ChatSession, Credential│ +│ - Relationships: source-to-notebook, note-to-source │ +│ - Vector embeddings for semantic search │ +└─────────────────────────────────────────────────────────┘ ``` -## Component Catalog +--- -### Core Layers +## Useful sources -**See dedicated CLAUDE.md files for detailed patterns and usage:** +User documentation is at @docs/ -- **`database/`**: Async repository pattern (repo_query, repo_create, repo_upsert), connection pooling, and automatic schema migrations on API startup. See `database/CLAUDE.md`. +## Tech Stack -- **`domain/`**: Core data models using Pydantic with SurrealDB persistence. Two base classes: `ObjectModel` (mutable records with auto-increment IDs and embedding) and `RecordModel` (singleton configuration). Includes search functions (text_search, vector_search). See `domain/CLAUDE.md`. +### Frontend (`frontend/`) +- **Framework**: Next.js 16 (React 19) +- **Language**: TypeScript +- **State Management**: Zustand +- **Data Fetching**: TanStack Query (React Query) +- **Styling**: Tailwind CSS + Shadcn/ui +- **Build Tool**: Webpack (via Next.js) +- **i18n compatible**: All front-end changes must also consider the translation keys -- **`graphs/`**: LangGraph state machines for async workflows. Content ingestion (source.py), conversational agents (chat.py), search synthesis (ask.py), and transformations. Uses provision_langchain_model() for smart model selection with token-aware fallback. See `graphs/CLAUDE.md`. +### API Backend (`api/` + `open_notebook/`) +- **Framework**: FastAPI 0.104+ +- **Language**: Python 3.11+ +- **Workflows**: LangGraph state machines +- **Database**: SurrealDB async driver +- **AI Providers**: Esperanto library (8+ providers: OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI) +- **Job Queue**: Surreal-Commands for async jobs (podcasts) +- **Logging**: Loguru +- **Validation**: Pydantic v2 +- **Testing**: Pytest -- **`ai/`**: Centralized AI model lifecycle via Esperanto library. ModelManager factory with intelligent fallback (large context detection, type-specific defaults, config override). Supports 8+ providers (OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI). See `ai/CLAUDE.md`. +### Database +- **SurrealDB**: Graph database with built-in embedding storage and vector search +- **Schema Migrations**: Automatic on API startup via AsyncMigrationManager -- **`utils/`**: Cross-cutting utilities: ContextBuilder (flexible context assembly from sources/notes/insights with token budgeting), TextUtils (truncation, cleaning), TokenUtils (GPT token counting), VersionUtils (schema compatibility). See `utils/CLAUDE.md`. +### Additional Services +- **Content Processing**: content-core library (file/URL extraction) +- **Prompts**: AI-Prompter with Jinja2 templating +- **Podcast Generation**: podcast-creator library +- **Embeddings**: Multi-provider via Esperanto -- **`podcasts/`**: Podcast generation models: SpeakerProfile (TTS voice config), EpisodeProfile (generation settings), PodcastEpisode (job tracking via surreal-commands). See `podcasts/CLAUDE.md`. +--- -### Configuration & Exceptions +## Architecture Highlights -- **`config.py`**: Paths for data folder, uploads, LangGraph checkpoints, and tiktoken cache. Auto-creates directories. -- **`exceptions.py`**: Hierarchy of OpenNotebookError subclasses for database, file, network, authentication, and rate-limit failures. +### 1. Async-First Design +- All database queries, graph invocations, and API calls are async (await) +- SurrealDB async driver with connection pooling +- FastAPI handles concurrent requests efficiently -## Data Flow: Content Ingestion +### 2. LangGraph Workflows +- **source.py**: Content ingestion (extract → embed → save) +- **chat.py**: Conversational agent with message history +- **ask.py**: Search + synthesis (retrieve relevant sources → LLM) +- **transformation.py**: Custom transformations on sources +- All use `provision_langchain_model()` for smart model selection -``` -User uploads file/URL - │ - ▼ -┌─────────────────────────────────────┐ -│ source.py (LangGraph state machine) │ -├─────────────────────────────────────┤ -│ 1. content_process() │ -│ - extract_content() from file/URL│ -│ - Use ContentSettings defaults │ -│ - speech_to_text model from DB │ -│ │ -│ 2. save_source() │ -│ - Update Source with full_text │ -│ - Preserve title if empty │ -│ │ -│ 3. trigger_transformations() │ -│ - Parallel fan-out to each TXN │ -└────────────────┬────────────────────┘ - │ - ▼ - ┌──────────────┐ - │ transformation.py (parallel) - │ - Apply prompt to source text - │ - Generate insights - │ - Auto-embed results - └──────────────┘ - │ - ▼ - ┌────────────────────┐ - │ Database Storage │ - │ - Source.full_text │ - │ - SourceInsight │ - │ - Embeddings │ - │ - (async job) │ - └────────────────────┘ -``` +### 3. Multi-Provider AI +- **Esperanto library**: Unified interface to 8+ AI providers +- **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 -**Fire-and-forget embeddings**: Source.vectorize() returns command_id without awaiting; embedding happens asynchronously via surreal-commands job system. +### 4. Database Schema +- **Automatic migrations**: AsyncMigrationManager runs on API startup +- **SurrealDB graph model**: Records with relationships and embeddings +- **Vector search**: Built-in semantic search across all content +- **Transactions**: Repo functions handle ACID operations -## Data Flow: Chat & Search +### 5. Authentication +- **Current**: Simple password middleware (insecure, dev-only) +- **Production**: Replace with OAuth/JWT (see CONFIGURATION.md) -``` -User message in chat - │ - ▼ -┌──────────────────────────┐ -│ ContextBuilder │ -│ - Select sources/notes │ -│ - Token budget limiting │ -│ - Priority weighting │ -└──────────┬───────────────┘ - │ - ▼ -┌──────────────────────────────────┐ -│ chat.py or ask.py (LangGraph) │ -│ - Load context from above │ -│ - provision_langchain_model() │ -│ * Auto-upgrade for large text │ -│ * Apply model_id override │ -│ - Call LLM with context │ -│ - Store message in SqliteSaver │ -└──────────┬───────────────────────┘ - │ - ▼ - ┌──────────────┐ - │ LLM Response │ - │ (persisted) │ - └──────────────┘ -``` - -## Key Patterns Across Layers - -### Async/Await Everywhere -All database operations, model provisioning, and graph execution are async. Mix with sync code only via `asyncio.run()` or LangGraph's async bridges (see graphs/CLAUDE.md for workarounds). - -### Type-Driven Dispatch -Model types (language, embedding, speech_to_text, text_to_speech) drive factory logic in ModelManager. Domain model IDs encode their type: `notebook:uuid`, `source:uuid`, `note:uuid`. - -### Smart Fallback Logic -`provision_langchain_model()` auto-detects large contexts (105K+ tokens) and upgrades to dedicated large_context_model. Falls back to default_chat_model if specific type not found. - -### Fire-and-Forget Jobs -Time-consuming operations (embedding, podcast generation) return command_id immediately. Caller polls surreal-commands for status; no blocking. - -### Fire-and-Forget Embedding -Domain models submit embedding commands after save via `submit_command()` (non-blocking). Note.save() submits `embed_note`, Source.add_insight() submits `embed_insight`, Source.vectorize() submits `embed_source`. Search functions (text_search, vector_search) use embeddings for semantic matching. - -### Relationship Management -SurrealDB graph edges link entities: Notebook→Source (has), Source→Note (artifact), Note→Source (refers_to). See `relate()` in domain/base.py. - -## Integration Points - -**API startup** (`api/main.py`): -- AsyncMigrationManager.run_migration_up() on lifespan startup -- Ensures schema is current before handling requests - -**Streamlit UI** (`pages/stream_app/`): -- Calls domain models directly to fetch/create notebooks, sources, notes -- Invokes graphs (chat, source, ask) via async wrapper -- Relies on API for migrations (deprecated check in UI) - -**Background Jobs** (`surreal_commands`): -- Source.vectorize() submits async embedding job -- PodcastEpisode.get_job_status() polls job queue -- Decouples long-running operations from request flow +--- ## Important Quirks & Gotchas -1. **Token counting rough estimate**: Uses cl100k_base encoding; may differ 5-10% from actual model -2. **Large context threshold hard-coded**: 105,000 token limit for large_context_model upgrade (not configurable) -3. **Async loop gymnastics in graphs**: ThreadPoolExecutor workaround for LangGraph sync nodes calling async functions (fragile) -4. **DefaultModels always fresh**: get_instance() bypasses singleton cache to pick up live config changes -5. **Polymorphic model.get()**: Resolves subclass from ID prefix; fails silently if subclass not imported -6. **RecordID string inconsistency**: repo_update() accepts both "table:id" format and full RecordID -7. **Snapshot profiles**: podcast profiles stored as dicts, so config updates don't affect past episodes -8. **No connection pooling**: Each repo_* creates new connection (adequate for HTTP but inefficient for bulk) -9. **Circular import guard**: utils imports domain; domain must not import utils (breaks on import) -10. **SqliteSaver shared location**: LangGraph checkpoints from LANGGRAPH_CHECKPOINT_FILE env var; all graphs use same file +### API Startup +- **Migrations run automatically** on startup; check logs for errors +- **Must start API before UI**: UI depends on API for all data +- **SurrealDB must be running**: API fails without database connection -## How to Add New Feature +### Frontend-Backend Communication +- **Base API URL**: Configured in `.env.local` (default: http://localhost:5055) +- **CORS enabled**: Configured in `api/main.py` (allow all origins in dev) +- **Rate limiting**: Not built-in; add at proxy layer for production -**New data model**: -1. Create class inheriting from `ObjectModel` with `table_name` ClassVar -2. Define Pydantic fields and validators -3. Override `save()` to submit embedding command if searchable (use `submit_command("embed_*", id)`) -4. Add custom methods for domain logic (get_X, add_to_Y) -5. Register in domain/__init__.py exports +### LangGraph Workflows +- **Blocking operations**: Chat/podcast workflows may take minutes; no timeout +- **State persistence**: Uses SQLite checkpoint storage in `/data/sqlite-db/` +- **Model fallback**: If primary model fails, falls back to cheaper/smaller model -**New workflow**: -1. Create state machine in graphs/WORKFLOW.py using StateGraph -2. Import domain models and provision_langchain_model() -3. Define nodes as async functions taking State, returning dict -4. Compile with graph.compile() -5. Invoke from API endpoint or Streamlit page +### Podcast Generation +- **Async job queue**: `podcast_service.py` submits jobs but doesn't wait +- **Track status**: Use `/commands/{command_id}` endpoint to poll status +- **TTS failures**: Fall back to silent audio if speech synthesis fails -**New AI model type**: -1. Add type string to Model class -2. Add AIFactory.create_* method in Esperanto -3. Handle in ModelManager.get_model() -4. Add DefaultModels field + getter +### Content Processing +- **File extraction**: Uses content-core library; supports 50+ file types +- **URL handling**: Extracts text + metadata from web pages +- **Large files**: Content processing is sync; may block API briefly -## Key Dependencies +--- -- **surrealdb**: AsyncSurreal client, RecordID type -- **pydantic**: Validation, field_validator -- **langgraph**: StateGraph, Send, SqliteSaver, async/sync bridging -- **langchain_core**: Messages, OutputParser, RunnableConfig -- **esperanto**: Multi-provider AI model abstraction (OpenAI, Anthropic, Google, Groq, Ollama, etc.) -- **content-core**: File/URL content extraction -- **ai_prompter**: Jinja2 template rendering for prompts -- **surreal_commands**: Async job queue for embeddings, podcast generation -- **loguru**: Structured logging throughout -- **tiktoken**: GPT token encoding for context window estimation +## Component References -## Codebase Statistics +See dedicated CLAUDE.md files for detailed guidance: + +- **[frontend/CLAUDE.md](../frontend/CLAUDE.md)**: React/Next.js architecture, state management, API integration +- **[api/CLAUDE.md](../api/CLAUDE.md)**: FastAPI structure, service pattern, endpoint development +- **[domain/CLAUDE.md](domain/CLAUDE.md)**: Data models, repository pattern, search functions +- **[ai/CLAUDE.md](ai/CLAUDE.md)**: ModelManager, AI provider integration, Esperanto usage +- **[graphs/CLAUDE.md](graphs/CLAUDE.md)**: LangGraph workflow design, state machines +- **[database/CLAUDE.md](database/CLAUDE.md)**: SurrealDB operations, migrations, async patterns + +--- + +## Documentation Map + +- **[README.md](../README.md)**: Project overview, features, quick start +- **[docs/index.md](../docs/index.md)**: Complete user & deployment documentation +- **[CONFIGURATION.md](../CONFIGURATION.md)**: Environment variables, model configuration +- **[CONTRIBUTING.md](../CONTRIBUTING.md)**: Contribution guidelines +- **[MAINTAINER_GUIDE.md](../MAINTAINER_GUIDE.md)**: Release & maintenance procedures + +--- + +## Testing Strategy + +- **Unit tests**: `tests/test_domain.py`, `test_models_api.py` +- **Graph tests**: `tests/test_graphs.py` (workflow integration) +- **Utils tests**: `tests/test_utils.py`, `tests/test_chunking.py`, `tests/test_embedding.py` +- **Run all**: `uv run pytest tests/` +- **Coverage**: Check with `pytest --cov` + +--- + +## Common Tasks + +### Add a New API Endpoint +1. Create router in `api/routers/feature.py` +2. Create service in `api/feature_service.py` +3. Define schemas in `api/models.py` +4. Register router in `api/main.py` +5. Test via http://localhost:5055/docs + +### Add a New LangGraph Workflow +1. Create `open_notebook/graphs/workflow_name.py` +2. Define StateDict and node functions +3. Build graph with `.add_node()` / `.add_edge()` +4. Invoke in service: `graph.ainvoke({"input": ...}, config={"..."})` +5. Test with sample data in `tests/` + +### Add Database Migration +1. Create `migrations/XXX_description.surql` +2. Write SurrealQL schema changes +3. Create `migrations/XXX_description_down.surql` (optional rollback) +4. API auto-detects on startup; migration runs if newer than recorded version + +### Deploy to Production +1. Review [CONFIGURATION.md](CONFIGURATION.md) for security settings +2. Use `make docker-release` for multi-platform image +3. Push to Docker Hub / GitHub Container Registry +4. Deploy `docker compose --profile multi up` +5. Verify migrations via API logs + +--- + +## Support & Community + +- **Documentation**: https://open-notebook.ai +- **Discord**: https://discord.gg/37XJPXfz2w +- **Issues**: https://github.com/lfnovo/open-notebook/issues +- **License**: MIT (see LICENSE) + +--- + +**Last Updated**: January 2026 | **Project Version**: 1.2.4+ -- **Modules**: 6 core layers + support services -- **Async operations**: Database, AI provisioning, graph execution, embedding, job tracking -- **Supported AI providers**: 8+ (OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI, OpenRouter) -- **Domain models**: Notebook, Source, Note, SourceInsight, SourceEmbedding, ChatSession, Asset, Transformation, ContentSettings, EpisodeProfile, SpeakerProfile, PodcastEpisode -- **Graph workflows**: 6 (source, chat, source_chat, ask, transformation, prompt) diff --git a/open_notebook/ai/CLAUDE.md b/open_notebook/ai/CLAUDE.md index 604a54d..b6e3cc0 100644 --- a/open_notebook/ai/CLAUDE.md +++ b/open_notebook/ai/CLAUDE.md @@ -19,8 +19,10 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo ### models.py #### Model (ObjectModel) -- Database record: name, provider, type (language/embedding/speech_to_text/text_to_speech) +- Database record: name, provider, type (language/embedding/speech_to_text/text_to_speech), credential (optional link to Credential record) - `get_models_by_type()`: Async query to fetch all models of a specific type +- `get_credential_obj()`: Fetches linked Credential object (if credential field set) +- `get_by_credential(credential_id)`: Class method to find all models linked to a credential - Stores provider-model pairs for AI factory instantiation #### DefaultModels (RecordModel) @@ -31,7 +33,7 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo #### ModelManager - Stateless factory for instantiating AI models -- `get_model(model_id)`: Retrieves Model by ID, creates via AIFactory.create_* based on type +- `get_model(model_id)`: Retrieves Model by ID; if model has linked credential, uses `credential.to_esperanto_config()` for provider config; otherwise falls back to env var provisioning via `key_provider` - `get_defaults()`: Fetches DefaultModels configuration - `get_default_model(model_type)`: Smart lookup (e.g., "chat" → default_chat_model, "transformation" → default_transformation_model with fallback to chat) - `get_speech_to_text()`, `get_text_to_speech()`, `get_embedding_model()`: Type-specific convenience methods with assertions @@ -48,6 +50,24 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo - Returns LangChain-compatible model via `.to_langchain()` - Logs model selection decision +### key_provider.py + +#### API Key Provider (Credential→Env Fallback) +- **Purpose**: Provides API keys from database first, falls back to environment variables +- **Pattern**: Before Esperanto creates a model, keys are loaded from `Credential` records and set as environment variables +- **Integration point**: Called by `ModelManager.get_model()` as fallback when model has no linked credential + +#### Key Functions +- `get_api_key(provider)`: Get single API key (DB first, then env var) +- `provision_provider_keys(provider)`: Set env vars from DB config for a provider +- `provision_all_keys()`: Load all provider keys from DB into env vars (useful at startup) + +#### Provider Configuration Maps +- `PROVIDER_CONFIG`: Simple providers (openai, anthropic, google, groq, etc.) +- `VERTEX_CONFIG`: Google Vertex AI (project, location, credentials) +- `AZURE_CONFIG`: Azure OpenAI (api_key, endpoint, api_version, mode-specific endpoints) +- `OPENAI_COMPATIBLE_CONFIG`: Generic OpenAI-compatible (generic + mode-specific for LLM/EMBEDDING/STT/TTS) + ## Common Patterns - **Type dispatch**: Model.type field drives factory logic (4 model types) @@ -56,12 +76,14 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo - **Config override**: provision_langchain_model() accepts kwargs passed to AIFactory.create_* methods - **Token-based selection**: provision_langchain_model() detects large contexts and upgrades model automatically - **Type assertions**: get_speech_to_text(), get_embedding_model() assert returned type (safety check) +- **Credential→Env fallback**: If model has linked credential, config from `credential.to_esperanto_config()` is used directly; otherwise keys checked in database via key_provider, then environment variables; enables UI-based key management while maintaining backward compatibility ## Key Dependencies - `esperanto`: AIFactory.create_language(), create_embedding(), create_speech_to_text(), create_text_to_speech() - `open_notebook.database.repository`: repo_query, ensure_record_id - `open_notebook.domain.base`: ObjectModel, RecordModel base classes +- `open_notebook.domain.credential`: Credential for database-stored API keys - `open_notebook.utils`: token_count() for context size detection - `loguru`: Logging for model selection decisions @@ -75,6 +97,7 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo - **Esperanto caching**: Actual model instances cached by Esperanto (not by ModelManager); ModelManager stateless - **Fallback chain specificity**: "transformation" type falls back to default_chat_model if not explicitly set (convention-based) - **kwargs passed through**: provision_langchain_model() passes kwargs to AIFactory but doesn't validate what's accepted +- **Key provider sets env vars**: `provision_provider_keys()` modifies `os.environ` to inject DB-stored keys (from `Credential` records); Esperanto reads from env vars (only used as fallback when model has no linked credential) ## How to Extend @@ -107,3 +130,202 @@ langchain_model = await provision_langchain_model( temperature=0.7 ) ``` + +--- + +## Connection Testing (connection_tester.py) + +### Purpose + +Provides functionality to test if a provider's API key is valid by making minimal API calls. Used by the API Configuration UI to validate user-entered credentials before saving. + +### test_provider_connection() + +Main entry point for testing provider connectivity. + +```python +async def test_provider_connection( + provider: str, model_type: str = "language", + config_id: Optional[str] = None +) -> Tuple[bool, str] +``` + +**Returns**: `(success: bool, message: str)` - Success status and human-readable message. + +**Flow**: +1. If `config_id` provided: Loads credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()` for provider config +2. Looks up test model from `TEST_MODELS` dict +3. For URL-based providers (ollama, openai_compatible): Tests server connectivity +4. For Azure: Tests `/openai/models` endpoint with api_version +5. For API-based providers: Creates minimal model via Esperanto and makes test call +6. Returns user-friendly error messages for common failures + +### test_individual_model() + +Tests a specific Model instance by loading its linked credential (if any) and making a minimal API call. + +### TEST_MODELS Configuration + +Maps each provider to `(model_name, model_type)` for testing: + +```python +TEST_MODELS = { + "openai": ("gpt-3.5-turbo", "language"), + "anthropic": ("claude-3-haiku-20240307", "language"), + "google": ("gemini-1.5-flash", "language"), + "groq": ("llama-3.1-8b-instant", "language"), + "voyage": ("voyage-3-lite", "embedding"), + "elevenlabs": ("eleven_multilingual_v2", "text_to_speech"), + "ollama": (None, "language"), # Dynamic + # ... more providers +} +``` + +### Special Provider Handlers + +- **`_test_ollama_connection(base_url)`**: Tests Ollama server via `/api/tags` endpoint, returns model count +- **`_test_openai_compatible_connection(base_url, api_key)`**: Tests OpenAI-compatible servers via `/models` endpoint +- **`_get_ollama_models(base_url)`**: Fetches available models from Ollama server + +### Error Message Normalization + +The tester normalizes error messages for user-friendly display: +- `401/unauthorized` -> "Invalid API key" +- `403/forbidden` -> "API key lacks required permissions" +- `rate limit` -> "Rate limited - but connection works" (success) +- `model not found` -> "API key valid (test model not available)" (success) +- Connection/timeout errors -> Helpful troubleshooting messages + +--- + +## Key Provider (key_provider.py) + +### Purpose + +Unified interface for retrieving API keys with database-first, environment-fallback strategy. Enables UI-based key management while maintaining backward compatibility with `.env` files. Used as fallback when models don't have a directly linked credential. + +### Core Functions + +#### get_api_key(provider) + +```python +async def get_api_key(provider: str) -> Optional[str] +``` + +Gets API key for a provider. Checks database (`Credential` records) first, then environment variable. + +**Fallback Chain**: +1. Query `Credential` records from database for the given provider +2. Get api_key from default credential +3. Handle `SecretStr` (call `.get_secret_value()`) vs regular strings +4. If DB value exists and is non-empty, return it +5. Otherwise, return `os.environ.get(env_var)` + +#### provision_provider_keys(provider) + +```python +async def provision_provider_keys(provider: str) -> bool +``` + +Main entry point for DB->Env fallback. Sets environment variables from database config for a provider. Called before model provisioning to ensure Esperanto can read keys from env vars. + +**Returns**: `True` if any keys were set from database. + +**Usage**: +```python +# Before creating a model, ensure DB keys are in env vars +await provision_provider_keys("openai") +model = AIFactory.create_language(model_name="gpt-4", provider="openai") +``` + +#### provision_all_keys() + +```python +async def provision_all_keys() -> dict[str, bool] +``` + +Provisions all providers at once. Useful at application startup. + +### Provider Configuration Maps + +#### PROVIDER_CONFIG (Simple Providers) + +Single-field providers with API key only: + +```python +PROVIDER_CONFIG = { + "openai": {"env_var": "OPENAI_API_KEY", "config_field": "openai_api_key"}, + "anthropic": {"env_var": "ANTHROPIC_API_KEY", "config_field": "anthropic_api_key"}, + "google": {"env_var": "GOOGLE_API_KEY", "config_field": "google_api_key"}, + "groq": {"env_var": "GROQ_API_KEY", "config_field": "groq_api_key"}, + "mistral": {"env_var": "MISTRAL_API_KEY", "config_field": "mistral_api_key"}, + "deepseek": {"env_var": "DEEPSEEK_API_KEY", "config_field": "deepseek_api_key"}, + "xai": {"env_var": "XAI_API_KEY", "config_field": "xai_api_key"}, + "openrouter": {"env_var": "OPENROUTER_API_KEY", "config_field": "openrouter_api_key"}, + "voyage": {"env_var": "VOYAGE_API_KEY", "config_field": "voyage_api_key"}, + "elevenlabs": {"env_var": "ELEVENLABS_API_KEY", "config_field": "elevenlabs_api_key"}, + "ollama": {"env_var": "OLLAMA_API_BASE", "config_field": "ollama_api_base"}, +} +``` + +#### VERTEX_CONFIG (Google Vertex AI) + +Multi-field configuration for Vertex AI: + +```python +VERTEX_CONFIG = { + "project": {"env_var": "VERTEX_PROJECT", "config_field": "vertex_project"}, + "location": {"env_var": "VERTEX_LOCATION", "config_field": "vertex_location"}, + "credentials": {"env_var": "GOOGLE_APPLICATION_CREDENTIALS", "config_field": "google_application_credentials"}, +} +``` + +#### AZURE_CONFIG (Azure OpenAI) + +Generic and mode-specific endpoints for Azure: + +```python +AZURE_CONFIG = { + "api_key": {"env_var": "AZURE_OPENAI_API_KEY", "config_field": "azure_openai_api_key"}, + "api_version": {"env_var": "AZURE_OPENAI_API_VERSION", "config_field": "azure_openai_api_version"}, + "endpoint": {"env_var": "AZURE_OPENAI_ENDPOINT", "config_field": "azure_openai_endpoint"}, + # Mode-specific endpoints + "endpoint_llm": {"env_var": "AZURE_OPENAI_ENDPOINT_LLM", "config_field": "azure_openai_endpoint_llm"}, + "endpoint_embedding": {"env_var": "AZURE_OPENAI_ENDPOINT_EMBEDDING", "config_field": "azure_openai_endpoint_embedding"}, + "endpoint_stt": {"env_var": "AZURE_OPENAI_ENDPOINT_STT", "config_field": "azure_openai_endpoint_stt"}, + "endpoint_tts": {"env_var": "AZURE_OPENAI_ENDPOINT_TTS", "config_field": "azure_openai_endpoint_tts"}, +} +``` + +#### OPENAI_COMPATIBLE_CONFIG + +Generic and mode-specific configuration for OpenAI-compatible providers: + +```python +OPENAI_COMPATIBLE_CONFIG = { + # Generic + "api_key": {"env_var": "OPENAI_COMPATIBLE_API_KEY", "config_field": "openai_compatible_api_key"}, + "base_url": {"env_var": "OPENAI_COMPATIBLE_BASE_URL", "config_field": "openai_compatible_base_url"}, + # Mode-specific: LLM, Embedding, STT, TTS + "api_key_llm": {"env_var": "OPENAI_COMPATIBLE_API_KEY_LLM", "config_field": "openai_compatible_api_key_llm"}, + "base_url_llm": {"env_var": "OPENAI_COMPATIBLE_BASE_URL_LLM", "config_field": "openai_compatible_base_url_llm"}, + # ... similar for embedding, stt, tts +} +``` + +### Internal Helper Functions + +- **`_provision_simple_provider(provider)`**: Sets single env var for simple providers +- **`_provision_vertex()`**: Sets all Vertex AI env vars +- **`_provision_azure()`**: Sets all Azure OpenAI env vars (handles SecretStr) +- **`_provision_openai_compatible()`**: Sets all OpenAI-compatible env vars + +### Integration with ModelManager + +The credential system integrates with model provisioning in two ways: + +1. **Credential-linked models** (preferred): Model has `credential` field pointing to a Credential record. `ModelManager.get_model()` calls `credential.to_esperanto_config()` and passes config directly to Esperanto's `AIFactory.create_*` methods +2. **Env var fallback**: If model has no linked credential, `provision_provider_keys(provider)` sets env vars from DB credentials; Esperanto reads from env vars +3. **ConnectionTester** loads Credential directly via `Credential.get(config_id)` for testing + +The credential-linked approach is preferred as it allows multiple credentials per provider and avoids env var mutation. diff --git a/open_notebook/ai/connection_tester.py b/open_notebook/ai/connection_tester.py new file mode 100644 index 0000000..4eb92b0 --- /dev/null +++ b/open_notebook/ai/connection_tester.py @@ -0,0 +1,438 @@ +""" +Connection testing for AI providers. + +This module provides functionality to test if a provider's API key is valid +by making minimal API calls to each provider, and to test individual model +configurations end-to-end. +""" +import io +import os +import struct +from typing import List, Optional, Tuple + +import httpx +from esperanto.factory import AIFactory +from loguru import logger + +from open_notebook.domain.credential import Credential + +# Test models for each provider - uses minimal/cheapest models for testing +# Format: (model_name, model_type) +TEST_MODELS = { + "openai": ("gpt-3.5-turbo", "language"), + "anthropic": ("claude-3-haiku-20240307", "language"), + "google": ("gemini-2.0-flash", "language"), + "groq": ("llama-3.1-8b-instant", "language"), + "mistral": ("mistral-small-latest", "language"), + "deepseek": ("deepseek-chat", "language"), + "xai": ("grok-beta", "language"), + "openrouter": ("openai/gpt-3.5-turbo", "language"), + "voyage": ("voyage-3-lite", "embedding"), + "elevenlabs": ("eleven_multilingual_v2", "text_to_speech"), + "ollama": (None, "language"), # Dynamic - will use first available model + # Complex providers with additional configuration + "vertex": ("gemini-2.0-flash", "language"), # Uses Google Vertex AI + "azure": ("gpt-35-turbo", "language"), # Azure OpenAI deployment name + "openai_compatible": (None, "language"), # Dynamic - will use first available model +} + + +async def _test_azure_connection( + endpoint: Optional[str] = None, + api_key: Optional[str] = None, + api_version: Optional[str] = None, +) -> Tuple[bool, str]: + """ + Test Azure OpenAI connectivity by listing models. + + Azure requires deployment names which vary per user, so instead of + invoking a model, we list available models to validate credentials. + """ + test_endpoint = endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT") + test_api_key = api_key or os.environ.get("AZURE_OPENAI_API_KEY") + test_api_version = api_version or os.environ.get("AZURE_OPENAI_API_VERSION", "2024-06-01") + + if not test_endpoint: + return False, "No Azure endpoint configured" + if not test_api_key: + return False, "No Azure API key configured" + + # Strip trailing slash to avoid double-slash in URL + test_endpoint = test_endpoint.rstrip("/") + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{test_endpoint}/openai/models?api-version={test_api_version}", + headers={"api-key": test_api_key}, + ) + + if response.status_code == 200: + data = response.json() + models = data.get("data", []) + count = len(models) + if count > 0: + names = [m.get("id", "unknown") for m in models[:3]] + name_list = ", ".join(names) + if count > 3: + name_list += f" (+{count - 3} more)" + return True, f"Connected. {count} models: {name_list}" + else: + return True, "Connected successfully (no models found)" + elif response.status_code == 401: + return False, "Invalid API key" + elif response.status_code == 403: + return False, "API key lacks required permissions" + else: + return False, f"Azure returned status {response.status_code}" + + except httpx.ConnectError: + return False, "Cannot connect to Azure endpoint. Check the URL." + except httpx.TimeoutException: + return False, "Connection timed out. Check the endpoint URL." + except Exception as e: + return False, f"Connection error: {str(e)[:100]}" + + +async def _test_ollama_connection(base_url: str) -> Tuple[bool, str]: + """Test Ollama server connectivity.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Try /api/tags endpoint (standard Ollama) + response = await client.get(f"{base_url}/api/tags") + + if response.status_code == 200: + data = response.json() + models = data.get("models", []) + model_count = len(models) + + if model_count > 0: + model_names = [m.get("name", "unknown") for m in models[:3]] + model_list = ", ".join(model_names) + if model_count > 3: + model_list += f" (+{model_count - 3} more)" + return True, f"Connected. {model_count} models available: {model_list}" + else: + return True, "Connected successfully (no models listed)" + elif response.status_code == 401: + return False, "Invalid API key" + elif response.status_code == 403: + return False, "API key lacks required permissions" + else: + return False, f"Server returned status {response.status_code}" + + except httpx.ConnectError: + return False, "Cannot connect to Ollama. Check if Ollama server is running." + except httpx.TimeoutException: + return False, "Connection timed out. Check if Ollama server is accessible." + except Exception as e: + return False, f"Connection error: {str(e)[:100]}" + + +async def _test_openai_compatible_connection(base_url: str, api_key: Optional[str] = None) -> Tuple[bool, str]: + """Test OpenAI-compatible server connectivity.""" + try: + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + async with httpx.AsyncClient(timeout=10.0) as client: + # Try /models endpoint (standard OpenAI-compatible) + response = await client.get(f"{base_url}/models", headers=headers) + + if response.status_code == 200: + data = response.json() + models = data.get("data", []) + model_count = len(models) + + if model_count > 0: + model_names = [m.get("id", "unknown") for m in models[:3]] + model_list = ", ".join(model_names) + if model_count > 3: + model_list += f" (+{model_count - 3} more)" + return True, f"Connected. {model_count} models available: {model_list}" + else: + return True, "Connected successfully (no models listed)" + elif response.status_code == 401: + return False, "Invalid API key" + elif response.status_code == 403: + return False, "API key lacks required permissions" + else: + return False, f"Server returned status {response.status_code}" + + except httpx.ConnectError: + return False, "Cannot connect to server. Check the URL is correct." + except httpx.TimeoutException: + return False, "Connection timed out. Check if server is accessible." + except Exception as e: + return False, f"Connection error: {str(e)[:100]}" + +async def test_provider_connection( + provider: str, model_type: str = "language", config_id: Optional[str] = None +) -> Tuple[bool, str]: + """ + Test if a provider's API key is valid by making a minimal API call. + + Args: + provider: Provider name (openai, anthropic, etc.) + model_type: Type of model to test (language, embedding, etc.) + Note: This is overridden by TEST_MODELS if provider is in that dict. + config_id: Optional specific configuration ID to test (format: configId) + If provided, uses the configuration from ProviderConfig for this specific config. + + Returns: + Tuple of (success: bool, message: str) + """ + try: + # Get configuration - either specific config or default + api_key: Optional[str] = None + base_url: Optional[str] = None + endpoint: Optional[str] = None + api_version: Optional[str] = None + model_name: Optional[str] = None + + if config_id: + # Load specific credential from database + try: + cred = await Credential.get(config_id) + config = cred.to_esperanto_config() + api_key = config.get("api_key") + base_url = config.get("base_url") + endpoint = config.get("endpoint") + api_version = config.get("api_version") + except Exception: + return False, f"Credential not found: {config_id}" + + # Normalize provider name (handle hyphenated aliases) + normalized_provider = provider.replace("-", "_") + + # Special handling for URL-based providers (no API key, just connectivity) + if normalized_provider == "ollama": + # Use base_url from specific config, or environment variable + test_base_url = base_url or os.environ.get("OLLAMA_API_BASE", "http://localhost:11434") + return await _test_ollama_connection(test_base_url) + + if normalized_provider == "openai_compatible": + # Use base_url from specific config, or environment variable + test_base_url = base_url or os.environ.get("OPENAI_COMPATIBLE_BASE_URL") + test_api_key = api_key or os.environ.get("OPENAI_COMPATIBLE_API_KEY") + if not test_base_url: + return False, "No base URL configured for OpenAI-compatible provider" + return await _test_openai_compatible_connection(test_base_url, test_api_key) + + if normalized_provider == "azure": + return await _test_azure_connection(endpoint, api_key, api_version) + + # Get test model for provider + if normalized_provider not in TEST_MODELS: + return False, f"Unknown provider: {provider}" + + test_model, test_model_type = TEST_MODELS[normalized_provider] + + # Use model from config if provided, otherwise use TEST_MODELS default + model_to_use = model_name if model_name else test_model + + # For providers with dynamic model detection + if model_to_use is None: + if normalized_provider == "openai_compatible": + # OpenAI-compatible servers should already be tested via _test_openai_compatible_connection + test_base_url = base_url or os.environ.get("OPENAI_COMPATIBLE_BASE_URL", "") + test_api_key = api_key or os.environ.get("OPENAI_COMPATIBLE_API_KEY") + return await _test_openai_compatible_connection(test_base_url, test_api_key) + else: + return False, f"No test model configured for {provider}" + + # If we have a specific API key, set it in environment for this test + if api_key: + os.environ[f"{provider.upper()}_API_KEY"] = api_key + + # Try to create the model and make a minimal call + if test_model_type == "language": + model = AIFactory.create_language(model_name=model_to_use, provider=provider) + # Convert to LangChain and make a minimal call + lc_model = model.to_langchain() + await lc_model.ainvoke("Hi") + return True, "Connection successful" + + elif test_model_type == "embedding": + model = AIFactory.create_embedding(model_name=model_to_use, provider=provider) + # Embed a single short test string + await model.aembed(["test"]) + return True, "Connection successful" + + elif test_model_type == "text_to_speech": + # For TTS, we just verify the model can be created + # Making an actual TTS call would be more expensive + # Most TTS providers validate the key on model creation + AIFactory.create_text_to_speech( + model_name=model_to_use, provider=provider + ) + return True, "Connection successful (key format valid)" + + else: + return False, f"Unsupported model type for testing: {test_model_type}" + + except Exception as e: + error_msg = str(e) + + # Clean up common error messages for user-friendly display + if "401" in error_msg or "unauthorized" in error_msg.lower(): + return False, "Invalid API key" + elif "403" in error_msg or "forbidden" in error_msg.lower(): + return False, "API key lacks required permissions" + elif "rate" in error_msg.lower() and "limit" in error_msg.lower(): + # Rate limit means the key is valid but we hit limits + return True, "Rate limited - but connection works" + elif "connection" in error_msg.lower() or "network" in error_msg.lower(): + return False, "Connection error - check network/endpoint" + elif "timeout" in error_msg.lower(): + return False, "Connection timed out - check network/endpoint" + elif "not found" in error_msg.lower() and "model" in error_msg.lower(): + # Model not found but auth worked - this is actually a success for connectivity + return True, "API key valid (test model not available)" + elif provider == "ollama" and "connection refused" in error_msg.lower(): + return False, "Ollama not running - check if Ollama server is started" + else: + logger.debug(f"Test connection error for {provider}: {e}") + # Truncate long error messages + truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg + return False, f"Error: {truncated}" + + +# Default voices for TTS testing per provider +# ElevenLabs excluded: uses voice_id (not name), looked up dynamically +DEFAULT_TEST_VOICES = { + "openai": "alloy", + "azure": "alloy", + "google": "Kore", + "vertex": "Kore", + "openai_compatible": "alloy", +} + + +def _generate_test_wav() -> io.BytesIO: + """Generate a minimal 0.5s silence WAV file in memory (16kHz, 16-bit mono).""" + sample_rate = 16000 + num_samples = sample_rate // 2 # 0.5 seconds + bits_per_sample = 16 + num_channels = 1 + byte_rate = sample_rate * num_channels * bits_per_sample // 8 + block_align = num_channels * bits_per_sample // 8 + data_size = num_samples * block_align + + buf = io.BytesIO() + # RIFF header + buf.write(b"RIFF") + buf.write(struct.pack(" Tuple[bool, str]: + """Normalize common error patterns into user-friendly messages.""" + lower = error_msg.lower() + + if "401" in error_msg or "unauthorized" in lower: + return False, "Invalid API key" + elif "403" in error_msg or "forbidden" in lower: + return False, "API key lacks required permissions" + elif "rate" in lower and "limit" in lower: + return True, "Rate limited - but connection works" + elif "not found" in lower and "model" in lower: + return False, "Model not found on this provider" + elif "connection" in lower or "network" in lower: + return False, "Connection error - check network/endpoint" + elif "timeout" in lower: + return False, "Connection timed out - check network/endpoint" + + return False, error_msg + + +async def test_individual_model(model) -> Tuple[bool, str]: + """ + Test a specific model configuration end-to-end by making a real API call. + + Args: + model: A Model instance (from open_notebook.ai.models) + + Returns: + Tuple of (success: bool, message: str) + """ + from open_notebook.ai.models import ModelManager + + try: + manager = ModelManager() + esp_model = await manager.get_model(model.id) + + if esp_model is None: + return False, "Could not create model instance" + + if model.type == "language": + response = await esp_model.achat_complete( + messages=[{"role": "user", "content": "Hi!"}] + ) + text = response.content[:100] if response.content else "(empty response)" + return True, f"Response: {text}" + + elif model.type == "embedding": + result = await esp_model.aembed(["This is a test."]) + if result and len(result) > 0: + dims = len(result[0]) + return True, f"Embedding dimensions: {dims}" + return True, "Embedding successful" + + elif model.type == "text_to_speech": + # For ElevenLabs, look up first available voice (API uses voice_id, not name) + voice = DEFAULT_TEST_VOICES.get(model.provider) + if not voice and hasattr(esp_model, "available_voices"): + try: + voices = esp_model.available_voices + if voices: + voice = next(iter(voices.keys())) + except Exception: + pass + if not voice: + voice = "alloy" # fallback + + result = await esp_model.agenerate_speech( + text="Hello from Open Notebook", voice=voice + ) + if result and hasattr(result, "content"): + size = len(result.content) + return True, f"Audio generated: {size} bytes" + return True, "Speech generation successful" + + elif model.type == "speech_to_text": + audio_file = _generate_test_wav() + result = await esp_model.atranscribe( + audio_file=audio_file, language="en" + ) + text = str(result.text) if hasattr(result, "text") else str(result) + return True, f"Transcription: {text[:100]}" + + else: + return False, f"Unsupported model type: {model.type}" + + except Exception as e: + error_msg = str(e) + success, normalized = _normalize_error_message(error_msg) + if success: + return True, normalized + logger.debug(f"Test individual model error for {model.id}: {e}") + return False, normalized diff --git a/open_notebook/ai/key_provider.py b/open_notebook/ai/key_provider.py new file mode 100644 index 0000000..fb4e497 --- /dev/null +++ b/open_notebook/ai/key_provider.py @@ -0,0 +1,297 @@ +""" +API Key Provider - Database-first with environment fallback. + +This module provides a unified interface for retrieving API keys and provider +configuration. It reads from Credential records (individual per-provider +credentials) and falls back to environment variables for backward compatibility. + +Usage: + from open_notebook.ai.key_provider import provision_provider_keys + + # Call before model provisioning to set env vars from DB + await provision_provider_keys("openai") +""" + +import os +from typing import Optional + +from loguru import logger + +from open_notebook.domain.credential import Credential + + +# ============================================================================= +# Provider Configuration Mapping +# ============================================================================= +# Maps provider names to their environment variable names. +# This is the single source of truth for provider-to-env-var mapping. + +PROVIDER_CONFIG = { + # Simple providers (just API key) + "openai": { + "env_var": "OPENAI_API_KEY", + }, + "anthropic": { + "env_var": "ANTHROPIC_API_KEY", + }, + "google": { + "env_var": "GOOGLE_API_KEY", + }, + "groq": { + "env_var": "GROQ_API_KEY", + }, + "mistral": { + "env_var": "MISTRAL_API_KEY", + }, + "deepseek": { + "env_var": "DEEPSEEK_API_KEY", + }, + "xai": { + "env_var": "XAI_API_KEY", + }, + "openrouter": { + "env_var": "OPENROUTER_API_KEY", + }, + "voyage": { + "env_var": "VOYAGE_API_KEY", + }, + "elevenlabs": { + "env_var": "ELEVENLABS_API_KEY", + }, + # URL-based providers + "ollama": { + "env_var": "OLLAMA_API_BASE", + }, +} + + +async def _get_default_credential(provider: str) -> Optional[Credential]: + """Get the first credential for a provider from the database.""" + try: + credentials = await Credential.get_by_provider(provider) + if credentials: + return credentials[0] + except Exception as e: + logger.debug(f"Could not load credential from database for {provider}: {e}") + return None + + +async def get_api_key(provider: str) -> Optional[str]: + """ + Get API key for a provider. Checks database first, then env var. + + Args: + provider: Provider name (openai, anthropic, etc.) + + Returns: + API key string or None if not configured + """ + cred = await _get_default_credential(provider) + if cred and cred.api_key: + logger.debug(f"Using {provider} API key from Credential") + return cred.api_key.get_secret_value() + + # Fall back to environment variable + config_info = PROVIDER_CONFIG.get(provider.lower()) + if config_info: + env_value = os.environ.get(config_info["env_var"]) + if env_value: + logger.debug(f"Using {provider} API key from environment variable") + return env_value + + return None + + +async def _provision_simple_provider(provider: str) -> bool: + """ + Set environment variable for a simple provider from DB config. + + Returns: + True if key was set from database, False otherwise + """ + provider_lower = provider.lower() + config_info = PROVIDER_CONFIG.get(provider_lower) + if not config_info: + return False + + env_var = config_info["env_var"] + + cred = await _get_default_credential(provider_lower) + if not cred: + return False + + # Set API key / primary env var + if cred.api_key: + os.environ[env_var] = cred.api_key.get_secret_value() + logger.debug(f"Set {env_var} from Credential") + + # Set base URL if present + if cred.base_url: + provider_upper = provider_lower.upper() + os.environ[f"{provider_upper}_API_BASE"] = cred.base_url + logger.debug(f"Set {provider_upper}_API_BASE from Credential") + + return True + + +async def _provision_vertex() -> bool: + """ + Set environment variables for Google Vertex AI from DB config. + + Returns: + True if any keys were set from database + """ + any_set = False + + cred = await _get_default_credential("vertex") + if not cred: + return False + + if cred.project: + os.environ["VERTEX_PROJECT"] = cred.project + logger.debug("Set VERTEX_PROJECT from Credential") + any_set = True + if cred.location: + os.environ["VERTEX_LOCATION"] = cred.location + logger.debug("Set VERTEX_LOCATION from Credential") + any_set = True + if cred.credentials_path: + os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = cred.credentials_path + logger.debug("Set GOOGLE_APPLICATION_CREDENTIALS from Credential") + any_set = True + + return any_set + + +async def _provision_azure() -> bool: + """ + Set environment variables for Azure OpenAI from DB config. + + Returns: + True if any keys were set from database + """ + any_set = False + + cred = await _get_default_credential("azure") + if not cred: + return False + + if cred.api_key: + os.environ["AZURE_OPENAI_API_KEY"] = cred.api_key.get_secret_value() + logger.debug("Set AZURE_OPENAI_API_KEY from Credential") + any_set = True + if cred.api_version: + os.environ["AZURE_OPENAI_API_VERSION"] = cred.api_version + logger.debug("Set AZURE_OPENAI_API_VERSION from Credential") + any_set = True + if cred.endpoint: + os.environ["AZURE_OPENAI_ENDPOINT"] = cred.endpoint + logger.debug("Set AZURE_OPENAI_ENDPOINT from Credential") + any_set = True + if cred.endpoint_llm: + os.environ["AZURE_OPENAI_ENDPOINT_LLM"] = cred.endpoint_llm + logger.debug("Set AZURE_OPENAI_ENDPOINT_LLM from Credential") + any_set = True + if cred.endpoint_embedding: + os.environ["AZURE_OPENAI_ENDPOINT_EMBEDDING"] = cred.endpoint_embedding + logger.debug("Set AZURE_OPENAI_ENDPOINT_EMBEDDING from Credential") + any_set = True + if cred.endpoint_stt: + os.environ["AZURE_OPENAI_ENDPOINT_STT"] = cred.endpoint_stt + logger.debug("Set AZURE_OPENAI_ENDPOINT_STT from Credential") + any_set = True + if cred.endpoint_tts: + os.environ["AZURE_OPENAI_ENDPOINT_TTS"] = cred.endpoint_tts + logger.debug("Set AZURE_OPENAI_ENDPOINT_TTS from Credential") + any_set = True + + return any_set + + +async def _provision_openai_compatible() -> bool: + """ + Set environment variables for OpenAI-Compatible providers from DB config. + + Returns: + True if any keys were set from database + """ + any_set = False + + cred = await _get_default_credential("openai_compatible") + if not cred: + return False + + if cred.api_key: + os.environ["OPENAI_COMPATIBLE_API_KEY"] = cred.api_key.get_secret_value() + logger.debug("Set OPENAI_COMPATIBLE_API_KEY from Credential") + any_set = True + if cred.base_url: + os.environ["OPENAI_COMPATIBLE_BASE_URL"] = cred.base_url + logger.debug("Set OPENAI_COMPATIBLE_BASE_URL from Credential") + any_set = True + + return any_set + + +async def provision_provider_keys(provider: str) -> bool: + """ + Provision environment variables from database for a specific provider. + + This function checks if the provider has a Credential record stored in the + database and sets the corresponding environment variables. If the database + doesn't have the configuration, existing environment variables remain unchanged. + + This is the main entry point for the DB->Env fallback mechanism. + + Args: + provider: Provider name (openai, anthropic, azure, vertex, + openai-compatible, etc.) + + Returns: + True if any keys were set from database, False otherwise + + Example: + # Before provisioning a model, ensure DB keys are in env vars + await provision_provider_keys("openai") + model = AIFactory.create_language(model_name="gpt-4", provider="openai") + """ + # Normalize provider name + provider_lower = provider.lower() + + # Handle complex providers with multiple config fields + if provider_lower == "vertex": + return await _provision_vertex() + elif provider_lower == "azure": + return await _provision_azure() + elif provider_lower in ("openai-compatible", "openai_compatible"): + return await _provision_openai_compatible() + + # Handle simple providers + return await _provision_simple_provider(provider_lower) + + +async def provision_all_keys() -> dict[str, bool]: + """ + Provision environment variables from database for all providers. + + NOTE: This function is deprecated for request-time use because it can leave + stale env vars after key deletion. Keys should only be provisioned at startup + or via provision_provider_keys() for specific providers. + + Useful at application startup to load all DB-stored keys into environment. + + Returns: + Dict mapping provider names to whether keys were set from DB + """ + results: dict[str, bool] = {} + + # Simple providers + for provider in PROVIDER_CONFIG.keys(): + results[provider] = await provision_provider_keys(provider) + + # Complex providers + results["vertex"] = await provision_provider_keys("vertex") + results["azure"] = await provision_provider_keys("azure") + results["openai_compatible"] = await provision_provider_keys("openai_compatible") + + return results diff --git a/open_notebook/ai/model_discovery.py b/open_notebook/ai/model_discovery.py new file mode 100644 index 0000000..726d70c --- /dev/null +++ b/open_notebook/ai/model_discovery.py @@ -0,0 +1,756 @@ +""" +Model Discovery - Automatic model fetching from AI providers. + +This module provides functionality to discover available models from configured +AI providers and automatically register them in the database. +""" + +import asyncio +import os +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +import httpx +from loguru import logger + +from open_notebook.ai.models import Model +from open_notebook.domain.credential import Credential +from open_notebook.database.repository import repo_query + + +@dataclass +class DiscoveredModel: + """Represents a model discovered from a provider.""" + + name: str + provider: str + model_type: str # language, embedding, speech_to_text, text_to_speech + description: Optional[str] = None + + +# ============================================================================= +# Provider-Specific Model Type Classification +# ============================================================================= +# These mappings help classify models by their capabilities based on naming patterns + +OPENAI_MODEL_TYPES = { + "language": [ + "gpt-4", + "gpt-3.5", + "o1", + "o3", + "chatgpt", + "text-davinci", + "davinci", + "curie", + "babbage", + "ada", + ], + "embedding": ["text-embedding", "embedding"], + "speech_to_text": ["whisper"], + "text_to_speech": ["tts"], +} + +ANTHROPIC_MODELS = { + # Static list since Anthropic doesn't have a model listing API + "language": [ + "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", + ], +} + +GOOGLE_MODEL_TYPES = { + "language": ["gemini", "palm", "bison", "chat"], + "embedding": ["embedding", "textembedding"], +} + +OLLAMA_MODEL_TYPES = { + # Ollama models can do multiple things, classify by common names + "language": [ + "llama", + "mistral", + "mixtral", + "codellama", + "phi", + "gemma", + "qwen", + "deepseek", + "vicuna", + "falcon", + "orca", + "neural", + "dolphin", + "openchat", + "starling", + "solar", + "yi", + "nous", + "wizard", + "zephyr", + "tinyllama", + ], + "embedding": ["nomic-embed", "mxbai-embed", "all-minilm", "bge-", "e5-"], +} + +MISTRAL_MODEL_TYPES = { + "language": [ + "mistral", + "mixtral", + "codestral", + "ministral", + "pixtral", + "open-mistral", + "open-mixtral", + ], + "embedding": ["mistral-embed"], +} + +GROQ_MODEL_TYPES = { + "language": ["llama", "mixtral", "gemma", "whisper"], + "speech_to_text": ["whisper"], +} + +DEEPSEEK_MODEL_TYPES = { + "language": ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"], +} + +XAI_MODEL_TYPES = { + "language": ["grok"], +} + +VOYAGE_MODEL_TYPES = { + "embedding": ["voyage"], +} + +ELEVENLABS_MODEL_TYPES = { + "text_to_speech": ["eleven"], +} + + +def classify_model_type(model_name: str, provider: str) -> str: + """ + Classify a model into a type based on its name and provider. + + Returns one of: language, embedding, speech_to_text, text_to_speech + """ + name_lower = model_name.lower() + + type_mappings = { + "openai": OPENAI_MODEL_TYPES, + "google": GOOGLE_MODEL_TYPES, + "ollama": OLLAMA_MODEL_TYPES, + "mistral": MISTRAL_MODEL_TYPES, + "groq": GROQ_MODEL_TYPES, + "deepseek": DEEPSEEK_MODEL_TYPES, + "xai": XAI_MODEL_TYPES, + "voyage": VOYAGE_MODEL_TYPES, + "elevenlabs": ELEVENLABS_MODEL_TYPES, + } + + mapping = type_mappings.get(provider, {}) + + # Check each type in order of specificity + for model_type in ["speech_to_text", "text_to_speech", "embedding", "language"]: + patterns = mapping.get(model_type, []) + for pattern in patterns: + if pattern in name_lower: + return model_type + + # Default to language for unknown models + return "language" + + +# ============================================================================= +# Provider-Specific Model Discovery Functions +# ============================================================================= + + +async def discover_openai_models() -> List[DiscoveredModel]: + """Fetch available models from OpenAI API.""" + api_key = os.environ.get("OPENAI_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.openai.com/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + model_type = classify_model_type(model_id, "openai") + models.append( + DiscoveredModel( + name=model_id, + provider="openai", + model_type=model_type, + ) + ) + except Exception as e: + logger.warning(f"Failed to discover OpenAI models: {e}") + + return models + + +async def discover_anthropic_models() -> List[DiscoveredModel]: + """Return static list of Anthropic models (no discovery API available).""" + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + return [] + + # Anthropic doesn't have a model listing API, so we use a static list + models = [] + for model_name in ANTHROPIC_MODELS.get("language", []): + models.append( + DiscoveredModel( + name=model_name, + provider="anthropic", + model_type="language", + ) + ) + return models + + +async def discover_google_models() -> List[DiscoveredModel]: + """Fetch available models from Google Gemini API.""" + api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + # Build URL without logging the key to avoid exposure + url = "https://generativelanguage.googleapis.com/v1/models" + headers = {"X-Goog-Api-Key": api_key} + response = await client.get(url, headers=headers, timeout=30.0) + response.raise_for_status() + data = response.json() + + for model in data.get("models", []): + # Google returns full path like "models/gemini-1.5-flash" + model_name = model.get("name", "").replace("models/", "") + if model_name: + model_type = classify_model_type(model_name, "google") + # Check supported generation methods for better classification + methods = model.get("supportedGenerationMethods", []) + if "embedContent" in methods: + model_type = "embedding" + elif "generateContent" in methods: + model_type = "language" + + models.append( + DiscoveredModel( + name=model_name, + provider="google", + model_type=model_type, + description=model.get("displayName"), + ) + ) + except Exception as e: + # Log without exposing the API key in the message + logger.warning(f"Failed to discover Google models: {type(e).__name__}") + + return models + + +async def discover_ollama_models() -> List[DiscoveredModel]: + """Fetch available models from local Ollama instance.""" + base_url = os.environ.get("OLLAMA_API_BASE", "http://localhost:11434") + if not base_url: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + f"{base_url}/api/tags", + timeout=10.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("models", []): + model_name = model.get("name", "") + if model_name: + model_type = classify_model_type(model_name, "ollama") + models.append( + DiscoveredModel( + name=model_name, + provider="ollama", + model_type=model_type, + ) + ) + except Exception as e: + logger.warning(f"Failed to discover Ollama models: {e}") + + return models + + +async def discover_groq_models() -> List[DiscoveredModel]: + """Fetch available models from Groq API.""" + api_key = os.environ.get("GROQ_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.groq.com/openai/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + model_type = classify_model_type(model_id, "groq") + models.append( + DiscoveredModel( + name=model_id, + provider="groq", + model_type=model_type, + ) + ) + except Exception as e: + logger.warning(f"Failed to discover Groq models: {e}") + + return models + + +async def discover_mistral_models() -> List[DiscoveredModel]: + """Fetch available models from Mistral API.""" + api_key = os.environ.get("MISTRAL_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.mistral.ai/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + model_type = classify_model_type(model_id, "mistral") + # Check capabilities if available + capabilities = model.get("capabilities", {}) + if capabilities.get("completion_chat"): + model_type = "language" + + models.append( + DiscoveredModel( + name=model_id, + provider="mistral", + model_type=model_type, + ) + ) + except Exception as e: + logger.warning(f"Failed to discover Mistral models: {e}") + + return models + + +async def discover_deepseek_models() -> List[DiscoveredModel]: + """Fetch available models from DeepSeek API.""" + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.deepseek.com/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + model_type = classify_model_type(model_id, "deepseek") + models.append( + DiscoveredModel( + name=model_id, + provider="deepseek", + model_type=model_type, + ) + ) + except Exception as e: + logger.warning(f"Failed to discover DeepSeek models: {e}") + + return models + + +async def discover_xai_models() -> List[DiscoveredModel]: + """Fetch available models from xAI API.""" + api_key = os.environ.get("XAI_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://api.x.ai/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + model_type = classify_model_type(model_id, "xai") + models.append( + DiscoveredModel( + name=model_id, + provider="xai", + model_type=model_type, + ) + ) + except Exception as e: + logger.warning(f"Failed to discover xAI models: {e}") + + return models + + +async def discover_openrouter_models() -> List[DiscoveredModel]: + """Fetch available models from OpenRouter API.""" + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + response = await client.get( + "https://openrouter.ai/api/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + # OpenRouter models are typically language models + models.append( + DiscoveredModel( + name=model_id, + provider="openrouter", + model_type="language", + description=model.get("name"), + ) + ) + except Exception as e: + logger.warning(f"Failed to discover OpenRouter models: {e}") + + return models + + +async def discover_voyage_models() -> List[DiscoveredModel]: + """Return static list of Voyage AI models (embedding only).""" + api_key = os.environ.get("VOYAGE_API_KEY") + if not api_key: + return [] + + # Voyage AI specializes in embeddings + voyage_models = [ + "voyage-3", + "voyage-3-lite", + "voyage-code-3", + "voyage-finance-2", + "voyage-law-2", + "voyage-multilingual-2", + ] + + return [ + DiscoveredModel(name=m, provider="voyage", model_type="embedding") + for m in voyage_models + ] + + +async def discover_elevenlabs_models() -> List[DiscoveredModel]: + """Return static list of ElevenLabs TTS models.""" + api_key = os.environ.get("ELEVENLABS_API_KEY") + if not api_key: + return [] + + # ElevenLabs specializes in TTS + elevenlabs_models = [ + "eleven_multilingual_v2", + "eleven_turbo_v2_5", + "eleven_turbo_v2", + "eleven_monolingual_v1", + "eleven_multilingual_v1", + ] + + return [ + DiscoveredModel(name=m, provider="elevenlabs", model_type="text_to_speech") + for m in elevenlabs_models + ] + + +async def discover_openai_compatible_models() -> List[DiscoveredModel]: + """ + Fetch available models from an OpenAI-compatible API endpoint. + Uses the configured base_url from the database or environment variable. + """ + api_key = None + base_url = None + + # Try to get config from Credential database first + try: + credentials = await Credential.get_by_provider("openai_compatible") + if credentials: + cred = credentials[0] + config = cred.to_esperanto_config() + api_key = config.get("api_key") + base_url = config.get("base_url", "").rstrip("/") + except Exception as e: + logger.warning(f"Failed to read openai_compatible config from Credential: {e}") + + # Fall back to environment variables + if not api_key: + api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY") + if not base_url: + base_url = os.environ.get("OPENAI_COMPATIBLE_BASE_URL", "").rstrip("/") + + if not base_url: + logger.warning("No base_url configured for openai_compatible provider") + return [] + + models = [] + try: + async with httpx.AsyncClient() as client: + headers = {} + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + + response = await client.get( + f"{base_url}/models", + headers=headers, + timeout=30.0, + ) + response.raise_for_status() + data = response.json() + + for model in data.get("data", []): + model_id = model.get("id", "") + if model_id: + # Classify based on model name patterns + model_type = classify_model_type(model_id, "openai") + models.append( + DiscoveredModel( + name=model_id, + provider="openai_compatible", + model_type=model_type, + ) + ) + except httpx.HTTPStatusError as e: + logger.warning(f"Failed to discover openai_compatible models: HTTP {e.response.status_code}") + except Exception as e: + logger.warning(f"Failed to discover openai_compatible models: {e}") + + return models + + +# ============================================================================= +# Main Discovery Functions +# ============================================================================= + +# Map provider names to their discovery functions +PROVIDER_DISCOVERY_FUNCTIONS = { + "openai": discover_openai_models, + "anthropic": discover_anthropic_models, + "google": discover_google_models, + "ollama": discover_ollama_models, + "groq": discover_groq_models, + "mistral": discover_mistral_models, + "deepseek": discover_deepseek_models, + "xai": discover_xai_models, + "openrouter": discover_openrouter_models, + "voyage": discover_voyage_models, + "elevenlabs": discover_elevenlabs_models, + "openai_compatible": discover_openai_compatible_models, + "azure": None, # Azure requires credential-based discovery (different auth) + "vertex": None, # Vertex requires credential-based discovery (service account) +} + + +async def discover_provider_models(provider: str) -> List[DiscoveredModel]: + """ + Discover available models for a specific provider. + + Args: + provider: Provider name (openai, anthropic, etc.) + + Returns: + List of discovered models + """ + discover_func = PROVIDER_DISCOVERY_FUNCTIONS.get(provider) + if discover_func is None: + if provider in PROVIDER_DISCOVERY_FUNCTIONS: + logger.info( + f"Provider '{provider}' requires credential-based discovery. " + f"Use the /credentials/{{id}}/discover endpoint instead." + ) + else: + logger.warning(f"No discovery function for provider: {provider}") + return [] + + return await discover_func() + + +async def sync_provider_models( + provider: str, auto_register: bool = True +) -> Tuple[int, int, int]: + """ + Sync models for a provider: discover and optionally register in database. + + Args: + provider: Provider name + auto_register: If True, automatically create Model records in database + + Returns: + Tuple of (discovered_count, new_count, existing_count) + """ + discovered = await discover_provider_models(provider) + discovered_count = len(discovered) + new_count = 0 + existing_count = 0 + + if not auto_register: + return discovered_count, 0, 0 + + if not discovered: + return 0, 0, 0 + + # Batch fetch existing models to avoid N+1 query pattern + try: + existing_models = await repo_query( + "SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model " + "WHERE string::lowercase(provider) = $provider", + {"provider": provider.lower()}, + ) + # Create a set of (name, type) tuples for O(1) lookup + existing_keys = set() + for m in existing_models: + existing_keys.add((m.get("name", ""), m.get("type", ""))) + except Exception as e: + logger.warning(f"Failed to fetch existing models for {provider}: {e}") + existing_keys = set() + + for model in discovered: + model_key = (model.name.lower(), model.model_type.lower()) + + # Check if model already exists using pre-fetched data + if model_key in existing_keys: + existing_count += 1 + continue + + # Create new model + try: + new_model = Model( + name=model.name, + provider=model.provider, + type=model.model_type, + ) + await new_model.save() + new_count += 1 + logger.info(f"Registered new model: {model.provider}/{model.name} ({model.model_type})") + except Exception as e: + logger.warning(f"Failed to register model {model.name}: {e}") + + logger.info( + f"Synced {provider}: {discovered_count} discovered, " + f"{new_count} new, {existing_count} existing" + ) + return discovered_count, new_count, existing_count + + +async def sync_all_providers() -> Dict[str, Tuple[int, int, int]]: + """ + Sync models for all configured providers. + + Returns: + Dict mapping provider names to (discovered, new, existing) tuples + """ + results = {} + + # Run discovery for all providers in parallel + tasks = [] + providers = list(PROVIDER_DISCOVERY_FUNCTIONS.keys()) + + for provider in providers: + tasks.append(sync_provider_models(provider, auto_register=True)) + + task_results = await asyncio.gather(*tasks, return_exceptions=True) + + for provider, result in zip(providers, task_results): + if isinstance(result, Exception): + logger.error(f"Error syncing {provider}: {result}") + results[provider] = (0, 0, 0) + else: + results[provider] = result + + return results + + +async def get_provider_model_count(provider: str) -> Dict[str, int]: + """ + Get count of registered models for a provider, grouped by type. + + Args: + provider: Provider name (case-insensitive) + + Returns: + Dict mapping model type to count + """ + # Use case-insensitive comparison by lowercasing the provider + result = await repo_query( + "SELECT type, count() as count FROM model WHERE string::lowercase(provider) = string::lowercase($provider) GROUP BY type", + {"provider": provider}, + ) + + counts = { + "language": 0, + "embedding": 0, + "speech_to_text": 0, + "text_to_speech": 0, + } + + for row in result: + model_type = row.get("type") + count = row.get("count", 0) + if model_type in counts: + counts[model_type] = count + + return counts diff --git a/open_notebook/ai/models.py b/open_notebook/ai/models.py index 0bd15f2..0a7ddbb 100644 --- a/open_notebook/ai/models.py +++ b/open_notebook/ai/models.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Dict, Optional, Union +from typing import Any, ClassVar, Dict, Optional, Union from esperanto import ( AIFactory, @@ -17,9 +17,11 @@ ModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeech class Model(ObjectModel): table_name: ClassVar[str] = "model" + nullable_fields: ClassVar[set[str]] = {"credential"} name: str provider: str type: str + credential: Optional[str] = None @classmethod async def get_models_by_type(cls, model_type): @@ -28,6 +30,33 @@ class Model(ObjectModel): ) return [Model(**model) for model in models] + @classmethod + async def get_by_credential(cls, credential_id: str): + """Get all models linked to a specific credential.""" + models = await repo_query( + "SELECT * FROM model WHERE credential=$cred_id;", + {"cred_id": ensure_record_id(credential_id)}, + ) + return [Model(**model) for model in models] + + def _prepare_save_data(self) -> Dict[str, Any]: + data = super()._prepare_save_data() + if data.get("credential"): + data["credential"] = ensure_record_id(data["credential"]) + return data + + async def get_credential_obj(self): + """Get the Credential object linked to this model, if any.""" + if not self.credential: + return None + from open_notebook.domain.credential import Credential + + try: + return await Credential.get(self.credential) + except Exception: + logger.warning(f"Could not load credential {self.credential} for model {self.id}") + return None + class DefaultModels(RecordModel): record_id: ClassVar[str] = "open_notebook:default_models" @@ -87,30 +116,60 @@ class ModelManager: ]: raise ValueError(f"Invalid model type: {model.type}") + # Build config from credential if linked, otherwise fall back to env vars + config: dict = {} + if model.credential: + credential = await model.get_credential_obj() + if credential: + config = credential.to_esperanto_config() + logger.debug( + f"Using credential '{credential.name}' for model {model.name}" + ) + else: + logger.warning( + f"Model {model.id} has credential {model.credential} but it could not be loaded. " + f"Falling back to env vars." + ) + # Fall back to env var provisioning + from open_notebook.ai.key_provider import provision_provider_keys + + await provision_provider_keys(model.provider) + else: + # No credential linked - use env var fallback + from open_notebook.ai.key_provider import provision_provider_keys + + await provision_provider_keys(model.provider) + + # Merge any additional kwargs (e.g. temperature) + config.update(kwargs) + + # Normalize provider name: DB stores underscores but Esperanto expects hyphens + provider = model.provider.replace("_", "-") + # Create model based on type (Esperanto will cache the instance) if model.type == "language": return AIFactory.create_language( model_name=model.name, - provider=model.provider, - config=kwargs, + provider=provider, + config=config, ) elif model.type == "embedding": return AIFactory.create_embedding( model_name=model.name, - provider=model.provider, - config=kwargs, + provider=provider, + config=config, ) elif model.type == "speech_to_text": return AIFactory.create_speech_to_text( model_name=model.name, - provider=model.provider, - config=kwargs, + provider=provider, + config=config, ) elif model.type == "text_to_speech": return AIFactory.create_text_to_speech( model_name=model.name, - provider=model.provider, - config=kwargs, + provider=provider, + config=config, ) else: raise ValueError(f"Invalid model type: {model.type}") diff --git a/open_notebook/database/CLAUDE.md b/open_notebook/database/CLAUDE.md index babfc0f..a143bc7 100644 --- a/open_notebook/database/CLAUDE.md +++ b/open_notebook/database/CLAUDE.md @@ -50,7 +50,7 @@ Both leverage connection context manager for lifecycle management and automatic - `run_one_down()`: Rollback latest migration - `AsyncMigrationManager`: Main orchestrator - - Loads 9 up migrations + 9 down migrations (hard-coded in __init__) + - Loads 12 up migrations + 12 down migrations (hard-coded in __init__; migrations 11-12 add credential table and model-credential link) - `get_current_version()`: Query max version from _sbl_migrations table - `needs_migration()`: Boolean check (current < total migrations available) - `run_migration_up()`: Run all pending migrations with logging @@ -87,7 +87,7 @@ Both leverage connection context manager for lifecycle management and automatic ## Important Quirks & Gotchas - **No connection pooling**: Each repo_* operation creates new connection; adequate for HTTP request-scoped operations but inefficient for bulk workloads -- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-9 explicitly; adding new migration requires code change (not auto-discovery) +- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-12 explicitly; adding new migration requires code change (not auto-discovery) - **Record ID format inconsistency**: repo_update() accepts both `table:id` format and full RecordID; path handling can be subtle - **ISO date parsing**: repo_update() parses `created` field from string to datetime if present; assumes ISO format - **Timestamp overwrite risk**: repo_create() always sets new timestamps; can't preserve original created time on reimport diff --git a/open_notebook/database/async_migrate.py b/open_notebook/database/async_migrate.py index 5121c53..2de354b 100644 --- a/open_notebook/database/async_migrate.py +++ b/open_notebook/database/async_migrate.py @@ -106,6 +106,12 @@ class AsyncMigrationManager: AsyncMigration.from_file("open_notebook/database/migrations/8.surrealql"), AsyncMigration.from_file("open_notebook/database/migrations/9.surrealql"), AsyncMigration.from_file("open_notebook/database/migrations/10.surrealql"), + AsyncMigration.from_file( + "open_notebook/database/migrations/11.surrealql" + ), + AsyncMigration.from_file( + "open_notebook/database/migrations/12.surrealql" + ), ] self.down_migrations = [ AsyncMigration.from_file( @@ -138,6 +144,12 @@ class AsyncMigrationManager: AsyncMigration.from_file( "open_notebook/database/migrations/10_down.surrealql" ), + AsyncMigration.from_file( + "open_notebook/database/migrations/11_down.surrealql" + ), + AsyncMigration.from_file( + "open_notebook/database/migrations/12_down.surrealql" + ), ] self.runner = AsyncMigrationRunner( up_migrations=self.up_migrations, diff --git a/open_notebook/database/migrations/11.surrealql b/open_notebook/database/migrations/11.surrealql new file mode 100644 index 0000000..f5e195d --- /dev/null +++ b/open_notebook/database/migrations/11.surrealql @@ -0,0 +1,10 @@ +-- Migration 11: Create provider configuration singleton record +-- This record stores multiple API key configurations per provider +-- The data is managed by the ProviderConfig RecordModel class + +-- Create the provider configs singleton record for multi-config support +-- This record stores multiple API key configurations per provider +-- The data is managed by the ProviderConfig RecordModel class +UPSERT open_notebook:provider_configs CONTENT { + credentials: {} +}; diff --git a/open_notebook/database/migrations/11_down.surrealql b/open_notebook/database/migrations/11_down.surrealql new file mode 100644 index 0000000..4e75201 --- /dev/null +++ b/open_notebook/database/migrations/11_down.surrealql @@ -0,0 +1,4 @@ +-- Rollback Migration 11: Remove provider configuration records + +-- Remove provider configs singleton (if exists) +DELETE open_notebook:provider_configs; diff --git a/open_notebook/database/migrations/12.surrealql b/open_notebook/database/migrations/12.surrealql new file mode 100644 index 0000000..c5aca94 --- /dev/null +++ b/open_notebook/database/migrations/12.surrealql @@ -0,0 +1,29 @@ +-- Migration 12: Create credential table and add credential link to model table +-- Individual credential records replace the ProviderConfig singleton +-- Each credential stores API key and provider-specific configuration + + +DEFINE TABLE credential SCHEMAFULL; +DEFINE FIELD name ON credential TYPE string; +DEFINE FIELD provider ON credential TYPE string; +DEFINE FIELD modalities ON credential TYPE array DEFAULT []; +DEFINE FIELD modalities.* ON credential TYPE string; +DEFINE FIELD api_key ON credential TYPE option; +DEFINE FIELD base_url ON credential TYPE option; +DEFINE FIELD endpoint ON credential TYPE option; +DEFINE FIELD api_version ON credential TYPE option; +DEFINE FIELD endpoint_llm ON credential TYPE option; +DEFINE FIELD endpoint_embedding ON credential TYPE option; +DEFINE FIELD endpoint_stt ON credential TYPE option; +DEFINE FIELD endpoint_tts ON credential TYPE option; +DEFINE FIELD project ON credential TYPE option; +DEFINE FIELD location ON credential TYPE option; +DEFINE FIELD credentials_path ON credential TYPE option; +DEFINE FIELD created ON credential TYPE option DEFAULT time::now(); +DEFINE FIELD updated ON credential TYPE option DEFAULT time::now(); + +-- Index for fast provider lookups +DEFINE INDEX idx_credential_provider ON credential FIELDS provider; + +-- Add optional credential link to model table +DEFINE FIELD credential ON model TYPE option>; diff --git a/open_notebook/database/migrations/12_down.surrealql b/open_notebook/database/migrations/12_down.surrealql new file mode 100644 index 0000000..c16b28b --- /dev/null +++ b/open_notebook/database/migrations/12_down.surrealql @@ -0,0 +1,5 @@ +-- Rollback Migration 12: Remove credential table and credential field from model + +REMOVE FIELD credential ON TABLE model; +REMOVE INDEX idx_credential_provider ON credential; +REMOVE TABLE credential; diff --git a/open_notebook/domain/CLAUDE.md b/open_notebook/domain/CLAUDE.md index 3ec6289..79678f4 100644 --- a/open_notebook/domain/CLAUDE.md +++ b/open_notebook/domain/CLAUDE.md @@ -53,6 +53,23 @@ Two base classes support different persistence patterns: **ObjectModel** (mutabl - **Transformation**: Reusable prompts for content transformation - **DefaultPrompts**: Singleton with transformation instructions +### credential.py +- **Credential**: Individual credential records for API keys and provider configuration + - **One record per credential**: Each credential (e.g., "My OpenAI Key", "Work Anthropic") is a separate `Credential` record in SurrealDB + - **Fields**: name, provider, modalities (list), api_key (SecretStr), base_url, endpoint, api_version, endpoint_llm/embedding/stt/tts, project, location, credentials_path + - **SecretStr protection**: API key field uses Pydantic's `SecretStr` (values masked in logs/repr) + - **Encryption integration**: Uses `encrypt_value()`/`decrypt_value()` from `open_notebook.utils.encryption` + - Keys encrypted with Fernet before database storage + - Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` environment variable (warns if not set) + - **Key methods**: + - `to_esperanto_config()`: Builds config dict for Esperanto's AIFactory methods + - `get_by_provider(provider)`: Class method to fetch all credentials for a provider + - `get_linked_models()`: Returns all Model records linked to this credential + - **Custom serialization**: `_prepare_save_data()` extracts SecretStr values and encrypts before storage + - **Decryption on read**: `get()` and `get_all()` overridden to decrypt api_key after fetch + +- **Note**: `provider_config.py` still exists for legacy migration support (migrating old ProviderConfig records to Credential) + ## Important Patterns - **Async/await**: All DB operations async; always use await diff --git a/open_notebook/domain/__init__.py b/open_notebook/domain/__init__.py index e69de29..d9de250 100644 --- a/open_notebook/domain/__init__.py +++ b/open_notebook/domain/__init__.py @@ -0,0 +1,7 @@ +""" +Domain models for Open Notebook. + +This module exports the core domain models used throughout the application. +""" + +__all__: list[str] = [] diff --git a/open_notebook/domain/credential.py b/open_notebook/domain/credential.py new file mode 100644 index 0000000..a99cd3e --- /dev/null +++ b/open_notebook/domain/credential.py @@ -0,0 +1,199 @@ +""" +Credential domain model for storing individual provider credentials. + +Each credential is a standalone record in the 'credential' table, replacing +the old ProviderConfig singleton. Credentials store API keys (encrypted at +rest) and provider-specific configuration fields. + +Usage: + cred = Credential( + name="Production", + provider="openai", + modalities=["language", "embedding"], + api_key=SecretStr("sk-..."), + ) + await cred.save() +""" + +from datetime import datetime +from typing import Any, ClassVar, Dict, List, Optional + +from loguru import logger +from pydantic import SecretStr + +from open_notebook.database.repository import ensure_record_id, repo_query +from open_notebook.domain.base import ObjectModel +from open_notebook.utils.encryption import decrypt_value, encrypt_value + + +class Credential(ObjectModel): + """ + Individual credential record for an AI provider. + + Each record stores authentication and configuration for a single provider + account. Models link to credentials via the credential field. + """ + + table_name: ClassVar[str] = "credential" + nullable_fields: ClassVar[set[str]] = { + "api_key", + "base_url", + "endpoint", + "api_version", + "endpoint_llm", + "endpoint_embedding", + "endpoint_stt", + "endpoint_tts", + "project", + "location", + "credentials_path", + } + + name: str + provider: str + modalities: List[str] = [] + api_key: Optional[SecretStr] = None + 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 + + def to_esperanto_config(self) -> Dict[str, Any]: + """ + Build config dict for AIFactory.create_*() calls. + + Returns a dict that can be passed as the 'config' parameter to + Esperanto's AIFactory methods, overriding env var lookup. + """ + config: Dict[str, Any] = {} + if self.api_key: + config["api_key"] = self.api_key.get_secret_value() + if self.base_url: + config["base_url"] = self.base_url + if self.endpoint: + config["endpoint"] = self.endpoint + if self.api_version: + config["api_version"] = self.api_version + if self.endpoint_llm: + config["endpoint_llm"] = self.endpoint_llm + if self.endpoint_embedding: + config["endpoint_embedding"] = self.endpoint_embedding + if self.endpoint_stt: + config["endpoint_stt"] = self.endpoint_stt + if self.endpoint_tts: + config["endpoint_tts"] = self.endpoint_tts + if self.project: + config["project"] = self.project + if self.location: + config["location"] = self.location + if self.credentials_path: + config["credentials_path"] = self.credentials_path + return config + + @classmethod + async def get_by_provider(cls, provider: str) -> List["Credential"]: + """Get all credentials for a provider.""" + results = await repo_query( + "SELECT * FROM credential WHERE string::lowercase(provider) = string::lowercase($provider) ORDER BY created ASC", + {"provider": provider}, + ) + credentials = [] + for row in results: + try: + cred = cls._from_db_row(row) + credentials.append(cred) + except Exception as e: + logger.warning(f"Skipping invalid credential: {e}") + return credentials + + @classmethod + async def get(cls, id: str) -> "Credential": + """Override get() to handle api_key decryption.""" + instance = await super().get(id) + # Pydantic auto-wraps the raw DB string in SecretStr, so we need + # to extract, decrypt, and re-wrap regardless of type. + if instance.api_key: + raw = ( + instance.api_key.get_secret_value() + if isinstance(instance.api_key, SecretStr) + else instance.api_key + ) + decrypted = decrypt_value(raw) + object.__setattr__(instance, "api_key", SecretStr(decrypted)) + return instance + + @classmethod + async def get_all(cls, order_by=None) -> List["Credential"]: + """Override get_all() to handle api_key decryption.""" + instances = await super().get_all(order_by=order_by) + for instance in instances: + if instance.api_key: + raw = ( + instance.api_key.get_secret_value() + if isinstance(instance.api_key, SecretStr) + else instance.api_key + ) + decrypted = decrypt_value(raw) + object.__setattr__(instance, "api_key", SecretStr(decrypted)) + return instances + + async def get_linked_models(self) -> list: + """Get all models linked to this credential.""" + if not self.id: + return [] + from open_notebook.ai.models import Model + + results = await repo_query( + "SELECT * FROM model WHERE credential = $cred_id", + {"cred_id": ensure_record_id(self.id)}, + ) + return [Model(**row) for row in results] + + def _prepare_save_data(self) -> Dict[str, Any]: + """Override to encrypt api_key before storage.""" + data = {} + for key, value in self.model_dump().items(): + if key == "api_key": + # Handle SecretStr: extract, encrypt, store + if self.api_key: + secret_value = self.api_key.get_secret_value() + data["api_key"] = encrypt_value(secret_value) + else: + data["api_key"] = None + elif value is not None or key in self.__class__.nullable_fields: + data[key] = value + + return data + + async def save(self) -> None: + """Save credential, handling api_key re-hydration after DB round-trip.""" + # Remember the original SecretStr before save + original_api_key = self.api_key + + await super().save() + + # After save, the api_key field may be set to the encrypted string + # from the DB result. Restore the original SecretStr. + if original_api_key: + object.__setattr__(self, "api_key", original_api_key) + elif self.api_key and isinstance(self.api_key, str): + # Decrypt if DB returned an encrypted string + decrypted = decrypt_value(self.api_key) + object.__setattr__(self, "api_key", SecretStr(decrypted)) + + @classmethod + def _from_db_row(cls, row: dict) -> "Credential": + """Create a Credential from a database row, decrypting api_key.""" + api_key_val = row.get("api_key") + if api_key_val and isinstance(api_key_val, str): + decrypted = decrypt_value(api_key_val) + row["api_key"] = SecretStr(decrypted) + elif api_key_val is None: + row["api_key"] = None + return cls(**row) diff --git a/open_notebook/domain/provider_config.py b/open_notebook/domain/provider_config.py new file mode 100644 index 0000000..08a0d8a --- /dev/null +++ b/open_notebook/domain/provider_config.py @@ -0,0 +1,444 @@ +""" +Provider Configuration domain model for storing multiple credentials per provider. + +This module provides the ProviderConfig singleton model that stores multiple +API key configurations per provider. Each ProviderCredential contains a complete +set of configuration options for a provider (api_key, base_url, model, etc.). + +Encryption is enabled when OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable +is set. If not set, keys are stored as plain text with a warning logged. +""" + +from datetime import datetime +from typing import ClassVar, Dict, List, Optional + +from pydantic import Field, SecretStr, field_validator + +from open_notebook.database.repository import ensure_record_id, repo_query, repo_upsert +from open_notebook.domain.base import RecordModel +from open_notebook.utils.encryption import decrypt_value, encrypt_value + + +class ProviderCredential: + """ + A single provider configuration item containing api_key and related settings. + + This class represents one complete configuration for an AI provider. + Multiple configurations can exist for the same provider, allowing users + to have different credentials for different environments (dev, prod, etc.). + + Attributes: + id: Unique identifier for this configuration + name: Human-readable name for this configuration + provider: Provider name (e.g., "openai", "anthropic") + is_default: Whether this is the default configuration for the provider + api_key: The API key (stored as SecretStr for in-memory protection) + base_url: Base URL for the provider API + model: Default model to use for this provider + api_version: API version string (for providers that need it) + endpoint: Generic endpoint URL + endpoint_llm: Endpoint URL for LLM service + endpoint_embedding: Endpoint URL for embedding service + endpoint_stt: Endpoint URL for speech-to-text service + endpoint_tts: Endpoint URL for text-to-speech service + project: Project ID (for Vertex AI) + location: Location/region (for Vertex AI) + credentials_path: Path to credentials file (for Vertex AI) + created: Timestamp when this config was created + updated: Timestamp when this config was last updated + """ + + def __init__( + self, + id: str, + name: str, + provider: str, + is_default: bool = False, + api_key: Optional[SecretStr] = None, + base_url: Optional[str] = None, + model: Optional[str] = None, + api_version: Optional[str] = None, + endpoint: 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, + created: Optional[str] = None, + updated: Optional[str] = None, + ): + self.id = id + self.name = name + self.provider = provider + self.is_default = is_default + self.api_key = api_key + self.base_url = base_url + self.model = model + self.api_version = api_version + self.endpoint = endpoint + self.endpoint_llm = endpoint_llm + self.endpoint_embedding = endpoint_embedding + self.endpoint_stt = endpoint_stt + self.endpoint_tts = endpoint_tts + self.project = project + self.location = location + self.credentials_path = credentials_path + self.created = created or datetime.now().strftime("%Y-%m-%d %H:%M:%S") + self.updated = updated or datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + def to_dict(self, encrypted: bool = False) -> dict: + """ + Convert the credential to a dictionary for storage. + + Args: + encrypted: If True, api_key is encrypted; otherwise it's a SecretStr + + Returns: + Dictionary representation of the credential + """ + data = { + "id": self.id, + "name": self.name, + "provider": self.provider, + "is_default": self.is_default, + "base_url": self.base_url, + "model": self.model, + "api_version": self.api_version, + "endpoint": self.endpoint, + "endpoint_llm": self.endpoint_llm, + "endpoint_embedding": self.endpoint_embedding, + "endpoint_stt": self.endpoint_stt, + "endpoint_tts": self.endpoint_tts, + "project": self.project, + "location": self.location, + "credentials_path": self.credentials_path, + "created": self.created, + "updated": self.updated, + } + + if self.api_key: + if encrypted: + data["api_key"] = encrypt_value(self.api_key.get_secret_value()) + else: + data["api_key"] = self.api_key.get_secret_value() + + return data + + @classmethod + def from_dict(cls, data: dict, decrypted: bool = False) -> "ProviderCredential": + """ + Create a ProviderCredential from a dictionary. + + Args: + data: Dictionary containing credential data + decrypted: If True, api_key is already decrypted; otherwise wrap in SecretStr + + Returns: + ProviderCredential instance + """ + api_key = None + if "api_key" in data and data["api_key"]: + if isinstance(data["api_key"], SecretStr): + # Already a SecretStr - use as-is + api_key = data["api_key"] + elif decrypted: + # Decrypted string from DB - wrap in SecretStr + api_key = SecretStr(data["api_key"]) + else: + # Encrypted string from DB - wrap in SecretStr (will be decrypted later) + api_key = SecretStr(data["api_key"]) + + return cls( + id=data["id"], + name=data["name"], + provider=data["provider"], + is_default=data.get("is_default", False), + api_key=api_key, + base_url=data.get("base_url"), + model=data.get("model"), + api_version=data.get("api_version"), + endpoint=data.get("endpoint"), + endpoint_llm=data.get("endpoint_llm"), + endpoint_embedding=data.get("endpoint_embedding"), + endpoint_stt=data.get("endpoint_stt"), + endpoint_tts=data.get("endpoint_tts"), + project=data.get("project"), + location=data.get("location"), + credentials_path=data.get("credentials_path"), + created=data.get("created"), + updated=data.get("updated"), + ) + + +class ProviderConfig(RecordModel): + """ + Singleton configuration for multiple provider credentials. + + Uses RecordModel pattern with a fixed record_id. Stores a dictionary + of ProviderCredential objects organized by provider name. + + Usage: + config = await ProviderConfig.get_instance() + credentials = config.credentials.get("openai", []) + default = config.get_default_config("openai") + """ + + record_id: ClassVar[str] = "open_notebook:provider_configs" + + # Store credentials organized by provider name + # Structure: {"openai": [ProviderCredential, ...], "anthropic": [...], ...} + credentials: Dict[str, List[ProviderCredential]] = Field( + default_factory=dict, + description="Provider credentials organized by provider name", + ) + + @classmethod + async def get_instance(cls) -> "ProviderConfig": + """ + Always fetch fresh configuration from database. + + Overrides parent caching behavior to ensure we always get the latest + configuration values. + + Returns: + ProviderConfig: Fresh instance with current database values + """ + result = await repo_query( + "SELECT * FROM ONLY $record_id", + {"record_id": ensure_record_id(cls.record_id)}, + ) + + if result: + if isinstance(result, list) and len(result) > 0: + data = result[0] + elif isinstance(result, dict): + data = result + else: + data = {} + else: + data = {} + + # Initialize credentials from database data + credentials: Dict[str, List[ProviderCredential]] = {} + creds_data = data.get("credentials") + if creds_data and isinstance(creds_data, dict): + for provider, provider_creds in creds_data.items(): + if isinstance(provider_creds, list): + credentials[provider] = [] + for cred_data in provider_creds: + try: + # Decrypt api_key if it's a string + api_key_val = cred_data.get("api_key") + if api_key_val and isinstance(api_key_val, str): + decrypted = decrypt_value(api_key_val) + cred_data["api_key"] = SecretStr(decrypted) + else: + # Keep as SecretStr or None + if api_key_val: + cred_data["api_key"] = SecretStr(api_key_val) + else: + cred_data["api_key"] = None + + credentials[provider].append( + ProviderCredential( + id=cred_data.get("id", ""), + name=cred_data.get("name", "Default"), + provider=cred_data.get("provider", provider), + is_default=cred_data.get("is_default", False), + api_key=cred_data.get("api_key"), + base_url=cred_data.get("base_url"), + model=cred_data.get("model"), + api_version=cred_data.get("api_version"), + endpoint=cred_data.get("endpoint"), + endpoint_llm=cred_data.get("endpoint_llm"), + endpoint_embedding=cred_data.get( + "endpoint_embedding" + ), + endpoint_stt=cred_data.get("endpoint_stt"), + endpoint_tts=cred_data.get("endpoint_tts"), + project=cred_data.get("project"), + location=cred_data.get("location"), + credentials_path=cred_data.get("credentials_path"), + created=cred_data.get("created"), + updated=cred_data.get("updated"), + ) + ) + except Exception: + # Skip invalid credentials + continue + + # Create instance using model_validate to properly initialize Pydantic model + instance = cls.model_validate({"credentials": credentials}) + + # Mark as loaded from database + object.__setattr__(instance, "_db_loaded", True) + + return instance + + def get_default_config(self, provider: str) -> Optional[ProviderCredential]: + """ + Get the default configuration for a provider. + + Args: + provider: Provider name (e.g., "openai", "anthropic") + + Returns: + The default ProviderCredential, or None if not found + """ + provider_lower = provider.lower() + credentials = self.credentials.get(provider_lower, []) + + # First, try to find explicitly marked default + for cred in credentials: + if cred.is_default: + return cred + + # If no explicit default, return first config + if credentials: + return credentials[0] + + return None + + def get_config( + self, provider: str, config_id: str + ) -> Optional[ProviderCredential]: + """ + Get a specific configuration by ID. + + Args: + provider: Provider name + config_id: Configuration ID + + Returns: + The ProviderCredential if found, None otherwise + """ + provider_lower = provider.lower() + credentials = self.credentials.get(provider_lower, []) + + for cred in credentials: + if cred.id == config_id: + return cred + + return None + + def add_config(self, provider: str, credential: ProviderCredential) -> None: + """ + Add a new configuration for a provider. + + If this is the first config for the provider, it becomes the default. + When adding a new config to an existing provider, the new config becomes + the default and previous default is unset. + + Args: + provider: Provider name (normalized to lowercase) + credential: ProviderCredential to add + """ + provider_lower = provider.lower() + credential.provider = provider_lower + + if provider_lower not in self.credentials: + self.credentials[provider_lower] = [] + + # When adding a new config to an existing provider, make it the default + # and unset the previous default + if self.credentials[provider_lower]: + for cred in self.credentials[provider_lower]: + cred.is_default = False + credential.is_default = True + + # If this is the first config, make it default + if not self.credentials[provider_lower]: + credential.is_default = True + + self.credentials[provider_lower].append(credential) + + def delete_config(self, provider: str, config_id: str) -> bool: + """ + Delete a configuration. + + Cannot delete the default configuration unless it's the only one. + + Args: + provider: Provider name + config_id: Configuration ID to delete + + Returns: + True if deleted, False if not found + """ + provider_lower = provider.lower() + credentials = self.credentials.get(provider_lower, []) + + for i, cred in enumerate(credentials): + if cred.id == config_id: + # Cannot delete default if there are other configs + if cred.is_default and len(credentials) > 1: + return False + + del credentials[i] + return True + + return False + + def set_default_config(self, provider: str, config_id: str) -> bool: + """ + Set a configuration as the default for a provider. + + Args: + provider: Provider name + config_id: Configuration ID to make default + + Returns: + True if successful, False if config not found + """ + provider_lower = provider.lower() + credentials = self.credentials.get(provider_lower, []) + + for cred in credentials: + if cred.id == config_id: + # Unset all other defaults + for other in credentials: + other.is_default = False + + # Set this one as default + cred.is_default = True + cred.updated = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + return True + + return False + + def _prepare_save_data(self) -> dict: + """ + Prepare data for database storage. + + SecretStr values are extracted, encrypted, and stored as strings. + Encryption is performed using Fernet symmetric encryption if + OPEN_NOTEBOOK_ENCRYPTION_KEY is configured. + """ + data = {"credentials": {}} + + for provider, credentials in self.credentials.items(): + data["credentials"][provider] = [] + for cred in credentials: + cred.updated = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + data["credentials"][provider].append(cred.to_dict(encrypted=True)) + + return data + + async def save(self) -> "ProviderConfig": + """ + Save the configuration to the database. + + Uses _prepare_save_data() to properly handle SecretStr conversion + and encryption. + """ + data = self._prepare_save_data() + await repo_upsert("open_notebook", self.record_id, data) + return self + + @classmethod + def _clear_for_test(cls) -> None: + """Clear the singleton instance for testing purposes.""" + if cls.record_id in cls._instances: + del cls._instances[cls.record_id] diff --git a/open_notebook/utils/CLAUDE.md b/open_notebook/utils/CLAUDE.md index d6a6de0..f22f648 100644 --- a/open_notebook/utils/CLAUDE.md +++ b/open_notebook/utils/CLAUDE.md @@ -192,3 +192,37 @@ context_items = await builder.build() for item in context_items: print(f"{item.type}:{item.id} ({item.token_count} tokens)") ``` + +### encryption.py +- **get_secret_from_env(var_name)**: Retrieve secret from environment with Docker secrets support (checks VAR_FILE first, then VAR) +- **get_fernet()**: Get Fernet instance if encryption key is configured +- **encrypt_value(value)**: Encrypt a string using Fernet symmetric encryption +- **decrypt_value(value)**: Decrypt a Fernet-encrypted string; gracefully falls back to original value for legacy/unencrypted data +**Purpose**: Provides field-level encryption for sensitive data (API keys) stored in the database. Uses Fernet symmetric encryption (AES-128-CBC with HMAC-SHA256) for authenticated encryption. + +**Key behavior**: +- Key source: OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets) → OPEN_NOTEBOOK_ENCRYPTION_KEY (env var) +- Accepts **any string**: always derived to a Fernet key via SHA-256 +- No default key — encryption is unavailable until the env var is set +- Graceful fallback on decryption: InvalidToken errors (legacy unencrypted data) return the original value +- Lazy-loaded key: initialized on first use, not at import time + +**Security considerations**: +- OPEN_NOTEBOOK_ENCRYPTION_KEY must be set explicitly (no default) +- Docker secrets pattern supported for secure key injection in containerized environments +- Key rotation would require re-encrypting all stored keys (not currently implemented) +- Encryption is transparent to callers; unencrypted legacy data continues to work + +**Usage Example**: +```python +from open_notebook.utils.encryption import encrypt_value, decrypt_value + +# Encrypt before storing in database +encrypted_api_key = encrypt_value(api_key) + +# Decrypt when reading from database +decrypted_api_key = decrypt_value(encrypted_api_key) + +# Set any string as encryption key: +# OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase +``` diff --git a/open_notebook/utils/__init__.py b/open_notebook/utils/__init__.py index 622288b..a31c1bd 100644 --- a/open_notebook/utils/__init__.py +++ b/open_notebook/utils/__init__.py @@ -6,6 +6,7 @@ To avoid circular imports, import functions directly: - from open_notebook.utils import token_count, compare_versions - from open_notebook.utils.chunking import chunk_text, detect_content_type, ContentType - from open_notebook.utils.embedding import generate_embedding, generate_embeddings +- from open_notebook.utils.encryption import encrypt_value, decrypt_value """ from .chunking import ( @@ -21,6 +22,10 @@ from .embedding import ( generate_embeddings, mean_pool_embeddings, ) +from .encryption import ( + decrypt_value, + encrypt_value, +) from .text_utils import ( clean_thinking_content, parse_thinking_content, @@ -58,4 +63,7 @@ __all__ = [ "compare_versions", "get_installed_version", "get_version_from_github", + # Encryption utils + "decrypt_value", + "encrypt_value", ] diff --git a/open_notebook/utils/embedding.py b/open_notebook/utils/embedding.py index 107eb02..7d8a93c 100644 --- a/open_notebook/utils/embedding.py +++ b/open_notebook/utils/embedding.py @@ -10,15 +10,18 @@ All embedding operations in the application should use these functions to ensure consistent behavior and proper handling of large content. """ -from typing import List, Optional +from typing import TYPE_CHECKING, List, Optional import numpy as np from loguru import logger -from open_notebook.ai.models import model_manager - from .chunking import CHUNK_SIZE, ContentType, chunk_text +# Lazy import to avoid circular dependency: +# utils -> embedding -> models -> key_provider -> provider_config -> utils +if TYPE_CHECKING: + from open_notebook.ai.models import ModelManager + async def mean_pool_embeddings(embeddings: List[List[float]]) -> List[float]: """ @@ -99,6 +102,9 @@ async def generate_embeddings( if not texts: return [] + # Lazy import to avoid circular dependency + from open_notebook.ai.models import model_manager + embedding_model = await model_manager.get_embedding_model() if not embedding_model: raise ValueError( diff --git a/open_notebook/utils/encryption.py b/open_notebook/utils/encryption.py new file mode 100644 index 0000000..87cadd9 --- /dev/null +++ b/open_notebook/utils/encryption.py @@ -0,0 +1,198 @@ +""" +Field-level encryption for sensitive data using API keys. + +This module provides encryption/decryption for API keys stored in the database. +Fernet uses AES-128-CBC with HMAC-SHA256 for authenticated encryption. + +OPEN_NOTEBOOK_ENCRYPTION_KEY accepts **any string**. A Fernet key is derived +from it via SHA-256, so users can set a simple passphrase like +``OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret`` and it will work. + +Usage: + # Encrypt before storing + encrypted = encrypt_value(api_key) + + # Decrypt when reading + decrypted = decrypt_value(encrypted) +""" + +import base64 +import hashlib +import os +from pathlib import Path +from typing import Optional + +from cryptography.fernet import Fernet, InvalidToken +from loguru import logger + + +def get_secret_from_env(var_name: str) -> Optional[str]: + """ + Get a secret from environment, supporting Docker secrets pattern. + + Checks for VAR_FILE first (Docker secrets), then falls back to VAR. + + Args: + var_name: Base name of the environment variable (e.g., "OPEN_NOTEBOOK_ENCRYPTION_KEY") + + Returns: + The secret value, or None if not configured. + """ + # Check for _FILE variant first (Docker secrets) + file_path = os.environ.get(f"{var_name}_FILE") + if file_path: + try: + path = Path(file_path) + if path.exists() and path.is_file(): + secret = path.read_text().strip() + if secret: + logger.debug(f"Loaded {var_name} from file: {file_path}") + return secret + else: + logger.warning(f"{var_name}_FILE points to empty file: {file_path}") + else: + logger.warning(f"{var_name}_FILE path does not exist: {file_path}") + except Exception as e: + logger.error(f"Failed to read {var_name} from file {file_path}: {e}") + + # Fall back to direct environment variable + return os.environ.get(var_name) + + +def _get_or_create_encryption_key() -> str: + """ + Get encryption key from environment, requires explicit configuration. + + Priority: + 1. OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets) + 2. OPEN_NOTEBOOK_ENCRYPTION_KEY (environment variable) + + For production deployments, you MUST set OPEN_NOTEBOOK_ENCRYPTION_KEY explicitly! + + Returns: + Encryption key string. + + Raises: + ValueError: If no encryption key is configured. + """ + # First check environment/Docker secrets + key = get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY") + if key: + return key + + raise ValueError( + "OPEN_NOTEBOOK_ENCRYPTION_KEY is not set. " + "Set this environment variable to any secret string to enable " + "encrypted storage of API keys in the database." + ) + + +# Lazy-loaded encryption key: initialized on first use, not at import time. +# This prevents the entire app from crashing if the key is not yet configured +# when other modules import from this file. +_ENCRYPTION_KEY: Optional[str] = None + + +def _get_encryption_key() -> str: + """Get the encryption key, initializing lazily on first call.""" + global _ENCRYPTION_KEY + if _ENCRYPTION_KEY is None: + _ENCRYPTION_KEY = _get_or_create_encryption_key() + return _ENCRYPTION_KEY + + +def _ensure_fernet_key(key: str) -> str: + """ + Derive a valid Fernet key from an arbitrary string via SHA-256. + + Any string is accepted as input. The key is derived by hashing it with + SHA-256 and encoding the result as URL-safe base64. + """ + derived = hashlib.sha256(key.encode()).digest() + return base64.urlsafe_b64encode(derived).decode() + + +def get_fernet() -> Fernet: + """ + Get Fernet instance with the configured encryption key. + + Returns: + Fernet instance. + + Raises: + ValueError: If encryption key is not configured. + """ + return Fernet(_ensure_fernet_key(_get_encryption_key()).encode()) + + +def encrypt_value(value: str) -> str: + """ + Encrypt a string value using Fernet symmetric encryption. + + Args: + value: The plain text string to encrypt. + + Returns: + Base64-encoded encrypted string. + + Raises: + ValueError: If encryption is not configured. + """ + fernet = get_fernet() + return fernet.encrypt(value.encode()).decode() + + +def looks_like_fernet_token(s: str) -> bool: + """ + Check if string looks like a Fernet encrypted token. + + Fernet tokens are versioned (1 byte) + timestamp (8 bytes) + IV (16 bytes) + + ciphertext (variable, multiple of 16 with PKCS7 padding) + HMAC (32 bytes). + Minimum decoded size is 73 bytes (1+8+16+16+32) for the smallest payload. + """ + if len(s) < 100: # Base64 of 73 bytes = ~100 chars minimum + return False + try: + decoded = base64.urlsafe_b64decode(s) + # Fernet: version(1) + timestamp(8) + IV(16) + ciphertext(>=16) + HMAC(32) + # Minimum 73 bytes, ciphertext must be multiple of 16 (AES block size) + if len(decoded) < 73: + return False + ciphertext_len = len(decoded) - 1 - 8 - 16 - 32 + return ciphertext_len > 0 and ciphertext_len % 16 == 0 + except Exception: + return False + + +def decrypt_value(value: str) -> str: + """ + Decrypt a Fernet-encrypted string value. + + Handles graceful fallback for legacy unencrypted data. + + Args: + value: The encrypted string (or plain text for legacy data). + + Returns: + Decrypted plain text string, or original value if not encrypted. + + Raises: + ValueError: If encryption is not configured or if decryption fails + for what appears to be encrypted data (wrong key). + """ + fernet = get_fernet() + + try: + return fernet.decrypt(value.encode()).decode() + except InvalidToken: + if looks_like_fernet_token(value): + # Looks like encrypted data but failed to decrypt - likely wrong key + raise ValueError( + "Decryption failed: data appears to be encrypted but key is incorrect. " + "Check OPEN_NOTEBOOK_ENCRYPTION_KEY configuration." + ) + # Not a valid token - treat as legacy plaintext + return value + except Exception as e: + logger.error(f"Decryption failed: {e}") + raise ValueError(f"Decryption failed: {str(e)}") diff --git a/pyproject.toml b/pyproject.toml index 6886fc6..a60b59a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-notebook" -version = "1.6.2" +version = "1.7.0-rc1" description = "An open source implementation of a research assistant, inspired by Google Notebook LM" authors = [ {name = "Luis Novo", email = "lfnovo@gmail.com"} @@ -29,7 +29,6 @@ dependencies = [ "langchain-groq>=1.1.1", "langchain_mistralai>=1.1.1", "langchain_deepseek>=1.0.0", - "langchain-google-vertexai>=3.2.0", "tomli>=2.0.2", "python-dotenv>=1.0.1", "httpx[socks]>=0.27.0", diff --git a/tests/conftest.py b/tests/conftest.py index cab9135..adf8334 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,6 +14,18 @@ from pathlib import Path # Set to empty string instead of deleting to prevent it from being reloaded os.environ["OPEN_NOTEBOOK_PASSWORD"] = "" +# Load environment variables from .env file +# This must be done BEFORE any imports that depend on environment variables +from dotenv import load_dotenv + +# Load .env file from project root +dotenv_path = Path(__file__).parent.parent / ".env" +if dotenv_path.exists(): + load_dotenv(dotenv_path) + print(f"Loaded environment variables from {dotenv_path}") +else: + print(f"Warning: .env file not found at {dotenv_path}") + # Add the project root to the Python path project_root = Path(__file__).parent.parent sys.path.insert(0, str(project_root)) diff --git a/tests/test_chunking.py b/tests/test_chunking.py index 5864ded..c717c11 100644 --- a/tests/test_chunking.py +++ b/tests/test_chunking.py @@ -15,7 +15,6 @@ from open_notebook.utils.chunking import ( detect_content_type_from_heuristics, ) - # ============================================================================ # TEST SUITE 1: Content Type Detection from Extension # ============================================================================ diff --git a/tests/test_embedding.py b/tests/test_embedding.py index 30d30db..61cb6e0 100644 --- a/tests/test_embedding.py +++ b/tests/test_embedding.py @@ -12,7 +12,6 @@ from open_notebook.utils.embedding import ( mean_pool_embeddings, ) - # ============================================================================ # TEST SUITE 1: Mean Pooling # ============================================================================ @@ -118,7 +117,7 @@ class TestGenerateEmbeddings: from unittest.mock import AsyncMock, patch with patch( - "open_notebook.utils.embedding.model_manager.get_embedding_model", + "open_notebook.ai.models.model_manager.get_embedding_model", new_callable=AsyncMock, return_value=None, ): @@ -134,7 +133,7 @@ class TestGenerateEmbeddings: mock_model.aembed = AsyncMock(return_value=[[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]) with patch( - "open_notebook.utils.embedding.model_manager.get_embedding_model", + "open_notebook.ai.models.model_manager.get_embedding_model", new_callable=AsyncMock, return_value=mock_model, ): @@ -171,7 +170,7 @@ class TestGenerateEmbedding: mock_model.aembed = AsyncMock(return_value=[[0.1, 0.2, 0.3]]) with patch( - "open_notebook.utils.embedding.model_manager.get_embedding_model", + "open_notebook.ai.models.model_manager.get_embedding_model", new_callable=AsyncMock, return_value=mock_model, ): @@ -198,7 +197,7 @@ class TestGenerateEmbedding: ) with patch( - "open_notebook.utils.embedding.model_manager.get_embedding_model", + "open_notebook.ai.models.model_manager.get_embedding_model", new_callable=AsyncMock, return_value=mock_model, ): @@ -219,7 +218,7 @@ class TestGenerateEmbedding: mock_model.aembed = AsyncMock(return_value=[[0.1, 0.2, 0.3]]) with patch( - "open_notebook.utils.embedding.model_manager.get_embedding_model", + "open_notebook.ai.models.model_manager.get_embedding_model", new_callable=AsyncMock, return_value=mock_model, ): diff --git a/tests/test_url_validation.py b/tests/test_url_validation.py new file mode 100644 index 0000000..843c70b --- /dev/null +++ b/tests/test_url_validation.py @@ -0,0 +1,130 @@ +""" +Test URL validation for SSRF protection in API key configuration. + +Note: The validation is intentionally permissive for self-hosted scenarios. +It only blocks: +- Invalid schemes (must be http or https) +- Malformed URLs +- Link-local addresses (169.254.x.x) - used for cloud metadata endpoints + +Localhost and private IPs are ALLOWED because this is a self-hosted application +where users commonly run local services (Ollama, LM Studio, etc.). +""" + +import pytest + +from api.credentials_service import validate_url + + +class TestUrlValidation: + """Test suite for URL validation to prevent SSRF attacks.""" + + def test_valid_https_url(self): + """Valid HTTPS URLs should pass.""" + validate_url("https://api.openai.com", "openai") + validate_url("https://example.com/api", "anthropic") + # Should not raise + + def test_valid_http_url(self): + """Valid HTTP URLs should pass.""" + validate_url("http://example.com", "openai") + # Should not raise + + def test_invalid_scheme(self): + """URLs with invalid schemes should be rejected.""" + with pytest.raises(ValueError, match="Invalid URL scheme"): + validate_url("ftp://example.com", "openai") + + with pytest.raises(ValueError, match="Invalid URL scheme"): + validate_url("file:///etc/passwd", "openai") + + def test_localhost_allowed_for_self_hosted(self): + """Localhost should be allowed for self-hosted services.""" + # This is a self-hosted app, localhost is valid for local services + validate_url("http://localhost:8000", "openai") + validate_url("http://127.0.0.1:8000", "azure") + # Should not raise + + def test_localhost_allowed_for_ollama(self): + """Localhost should be allowed for Ollama provider.""" + validate_url("http://localhost:11434", "ollama") + validate_url("http://127.0.0.1:11434", "ollama") + # Should not raise + + def test_private_ip_allowed_for_self_hosted(self): + """Private IP addresses should be allowed for self-hosted scenarios.""" + # This is a self-hosted app, private IPs are valid for internal services + validate_url("http://10.0.0.1", "openai") + validate_url("http://172.16.0.1:8080", "anthropic") + validate_url("http://192.168.1.1", "azure") + # Should not raise + + def test_private_ip_allowed_for_ollama(self): + """Private IP addresses should be allowed for Ollama provider.""" + validate_url("http://192.168.1.100:11434", "ollama") + validate_url("http://10.0.0.50:11434", "ollama") + # Should not raise + + def test_loopback_allowed_for_self_hosted(self): + """Loopback addresses should be allowed for self-hosted scenarios.""" + validate_url("http://127.0.0.2", "openai") + # Should not raise + + def test_link_local_rejection(self): + """Link-local addresses should be rejected (cloud metadata protection).""" + with pytest.raises(ValueError, match="Link-local addresses"): + validate_url("http://169.254.169.254", "openai") + + # Also reject for ollama - link-local is never valid + with pytest.raises(ValueError, match="Link-local addresses"): + validate_url("http://169.254.169.254", "ollama") + + def test_ipv6_localhost_allowed(self): + """IPv6 localhost should be allowed for self-hosted scenarios.""" + validate_url("http://[::1]:8000", "openai") + # Should not raise + + def test_empty_url(self): + """Empty URLs should not raise (handled elsewhere).""" + validate_url("", "openai") + # None is handled by the function's early return check + # Should not raise + + def test_invalid_url_format(self): + """Malformed URLs should be rejected.""" + with pytest.raises(ValueError): + validate_url("not-a-url", "openai") + + def test_public_hostnames_allowed(self): + """Public hostnames should be allowed.""" + validate_url("https://api.openai.com/v1", "openai") + validate_url("https://api.anthropic.com", "anthropic") + validate_url("https://generativelanguage.googleapis.com", "google") + validate_url("https://api.groq.com", "groq") + # Should not raise + + def test_azure_specific_urls(self): + """Azure OpenAI endpoints should be validated.""" + validate_url( + "https://my-resource.openai.azure.com", "azure" + ) + # Localhost is allowed for self-hosted + validate_url("http://localhost:8000", "azure") + # Should not raise + + def test_openai_compatible_urls(self): + """OpenAI-compatible provider URLs should be validated.""" + validate_url("https://api.together.xyz", "openai_compatible") + # Private IPs are allowed for self-hosted + validate_url("http://192.168.1.1:8080", "openai_compatible") + # Should not raise + + def test_ipv4_mapped_ipv6_link_local_rejected(self): + """IPv4-mapped IPv6 addresses pointing to link-local should be rejected.""" + with pytest.raises(ValueError, match="Link-local addresses"): + validate_url("http://[::ffff:169.254.169.254]", "openai") + + def test_ipv4_mapped_ipv6_private_allowed(self): + """IPv4-mapped IPv6 addresses pointing to private IPs should be allowed.""" + validate_url("http://[::ffff:192.168.1.1]", "openai") + # Should not raise - private IPs allowed for self-hosted diff --git a/uv.lock b/uv.lock index 1cd0cb7..2043b95 100644 --- a/uv.lock +++ b/uv.lock @@ -241,31 +241,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] -[[package]] -name = "bottleneck" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/14/d8/6d641573e210768816023a64966d66463f2ce9fc9945fa03290c8a18f87c/bottleneck-1.6.0.tar.gz", hash = "sha256:028d46ee4b025ad9ab4d79924113816f825f62b17b87c9e1d0d8ce144a4a0e31", size = 104311, upload-time = "2025-09-08T16:30:38.617Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/96/9d51012d729f97de1e75aad986f3ba50956742a40fc99cbab4c2aa896c1c/bottleneck-1.6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:69ef4514782afe39db2497aaea93b1c167ab7ab3bc5e3930500ef9cf11841db7", size = 100400, upload-time = "2025-09-08T16:29:44.464Z" }, - { url = "https://files.pythonhosted.org/packages/16/f4/4fcbebcbc42376a77e395a6838575950587e5eb82edf47d103f8daa7ba22/bottleneck-1.6.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:727363f99edc6dc83d52ed28224d4cb858c07a01c336c7499c0c2e5dd4fd3e4a", size = 375920, upload-time = "2025-09-08T16:29:45.52Z" }, - { url = "https://files.pythonhosted.org/packages/36/13/7fa8cdc41cbf2dfe0540f98e1e0caf9ffbd681b1a0fc679a91c2698adaf9/bottleneck-1.6.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:847671a9e392220d1dfd2ff2524b4d61ec47b2a36ea78e169d2aa357fd9d933a", size = 367922, upload-time = "2025-09-08T16:29:46.743Z" }, - { url = "https://files.pythonhosted.org/packages/13/7d/dccfa4a2792c1bdc0efdde8267e527727e517df1ff0d4976b84e0268c2f9/bottleneck-1.6.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:daef2603ab7b4ec4f032bb54facf5fa92dacd3a264c2fd9677c9fc22bcb5a245", size = 361379, upload-time = "2025-09-08T16:29:48.042Z" }, - { url = "https://files.pythonhosted.org/packages/93/42/21c0fad823b71c3a8904cbb847ad45136d25573a2d001a9cff48d3985fab/bottleneck-1.6.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fc7f09bda980d967f2e9f1a746eda57479f824f66de0b92b9835c431a8c922d4", size = 371911, upload-time = "2025-09-08T16:29:49.366Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b0/830ff80f8c74577d53034c494639eac7a0ffc70935c01ceadfbe77f590c2/bottleneck-1.6.0-cp311-cp311-win32.whl", hash = "sha256:1f78bad13ad190180f73cceb92d22f4101bde3d768f4647030089f704ae7cac7", size = 107831, upload-time = "2025-09-08T16:29:51.397Z" }, - { url = "https://files.pythonhosted.org/packages/6f/42/01d4920b0aa51fba503f112c90714547609bbe17b6ecfc1c7ae1da3183df/bottleneck-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f2adef59fdb9edf2983fe3a4c07e5d1b677c43e5669f4711da2c3daad8321ad", size = 113358, upload-time = "2025-09-08T16:29:52.602Z" }, - { url = "https://files.pythonhosted.org/packages/8d/72/7e3593a2a3dd69ec831a9981a7b1443647acb66a5aec34c1620a5f7f8498/bottleneck-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3bb16a16a86a655fdbb34df672109a8a227bb5f9c9cf5bb8ae400a639bc52fa3", size = 100515, upload-time = "2025-09-08T16:29:55.141Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d4/e7bbea08f4c0f0bab819d38c1a613da5f194fba7b19aae3e2b3a27e78886/bottleneck-1.6.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0fbf5d0787af9aee6cef4db9cdd14975ce24bd02e0cc30155a51411ebe2ff35f", size = 377451, upload-time = "2025-09-08T16:29:56.718Z" }, - { url = "https://files.pythonhosted.org/packages/fe/80/a6da430e3b1a12fd85f9fe90d3ad8fe9a527ecb046644c37b4b3f4baacfc/bottleneck-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d08966f4a22384862258940346a72087a6f7cebb19038fbf3a3f6690ee7fd39f", size = 368303, upload-time = "2025-09-08T16:29:57.834Z" }, - { url = "https://files.pythonhosted.org/packages/30/11/abd30a49f3251f4538430e5f876df96f2b39dabf49e05c5836820d2c31fe/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:604f0b898b43b7bc631c564630e936a8759d2d952641c8b02f71e31dbcd9deaa", size = 361232, upload-time = "2025-09-08T16:29:59.104Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ac/1c0e09d8d92b9951f675bd42463ce76c3c3657b31c5bf53ca1f6dd9eccff/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d33720bad761e642abc18eda5f188ff2841191c9f63f9d0c052245decc0faeb9", size = 373234, upload-time = "2025-09-08T16:30:00.488Z" }, - { url = "https://files.pythonhosted.org/packages/fb/ea/382c572ae3057ba885d484726bb63629d1f63abedf91c6cd23974eb35a9b/bottleneck-1.6.0-cp312-cp312-win32.whl", hash = "sha256:a1e5907ec2714efbe7075d9207b58c22ab6984a59102e4ecd78dced80dab8374", size = 108020, upload-time = "2025-09-08T16:30:01.773Z" }, - { url = "https://files.pythonhosted.org/packages/48/ad/d71da675eef85ac153eef5111ca0caa924548c9591da00939bcabba8de8e/bottleneck-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:81e3822499f057a917b7d3972ebc631ac63c6bbcc79ad3542a66c4c40634e3a6", size = 113493, upload-time = "2025-09-08T16:30:02.872Z" }, -] - [[package]] name = "bs4" version = "0.0.2" @@ -851,28 +826,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/c9/97cc5aae1648dcb851958a3ddf73ccd7dbe5650d95203ecb4d7720b4cdbf/fsspec-2026.1.0-py3-none-any.whl", hash = "sha256:cb76aa913c2285a3b49bdd5fc55b1d7c708d7208126b60f2eb8194fe1b4cbdcc", size = 201838, upload-time = "2026-01-09T15:21:34.041Z" }, ] -[[package]] -name = "google-api-core" -version = "2.29.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-auth" }, - { name = "googleapis-common-protos" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0d/10/05572d33273292bac49c2d1785925f7bc3ff2fe50e3044cf1062c1dde32e/google_api_core-2.29.0.tar.gz", hash = "sha256:84181be0f8e6b04006df75ddfe728f24489f0af57c96a529ff7cf45bc28797f7", size = 177828, upload-time = "2026-01-08T22:21:39.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/b6/85c4d21067220b9a78cfb81f516f9725ea6befc1544ec9bd2c1acd97c324/google_api_core-2.29.0-py3-none-any.whl", hash = "sha256:d30bc60980daa36e314b5d5a3e5958b0200cb44ca8fa1be2b614e932b75a3ea9", size = 173906, upload-time = "2026-01-08T22:21:36.093Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, - { name = "grpcio-status" }, -] - [[package]] name = "google-auth" version = "2.47.0" @@ -891,114 +844,6 @@ requests = [ { name = "requests" }, ] -[[package]] -name = "google-cloud-aiplatform" -version = "1.134.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docstring-parser" }, - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-bigquery" }, - { name = "google-cloud-resource-manager" }, - { name = "google-cloud-storage" }, - { name = "google-genai" }, - { name = "packaging" }, - { name = "proto-plus" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d3/24/de4f21d0728d640b57bf7bbcd7460827a4daf9eaca61cb5b91be608c40bc/google_cloud_aiplatform-1.134.0.tar.gz", hash = "sha256:964cea117ca1ffc71742970e1091985adac72dfe76e1a1614a02a8cda50d6992", size = 9931075, upload-time = "2026-01-20T19:19:58.867Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/f4/6863f3951eb07afd790fe6f8f1a5984224f7df836546a34ed29ab0cfe9af/google_cloud_aiplatform-1.134.0-py2.py3-none-any.whl", hash = "sha256:f249ae67d622deca486310e0021093764892ac357fb744b9e79548f490017ddc", size = 8189190, upload-time = "2026-01-20T19:19:55.997Z" }, -] - -[[package]] -name = "google-cloud-bigquery" -version = "3.40.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-resumable-media" }, - { name = "packaging" }, - { name = "python-dateutil" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/94/0a/62438ca138a095945468968696d9cca75a4cfd059e810402e70b0236d8ba/google_cloud_bigquery-3.40.0.tar.gz", hash = "sha256:b3ccb11caf0029f15b29569518f667553fe08f6f1459b959020c83fbbd8f2e68", size = 509287, upload-time = "2026-01-08T01:07:26.065Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/6a/90a04270dd60cc70259b73744f6e610ae9a158b21ab50fb695cca0056a3d/google_cloud_bigquery-3.40.0-py3-none-any.whl", hash = "sha256:0469bcf9e3dad3cab65b67cce98180c8c0aacf3253d47f0f8e976f299b49b5ab", size = 261335, upload-time = "2026-01-08T01:07:23.761Z" }, -] - -[[package]] -name = "google-cloud-core" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a6/03/ef0bc99d0e0faf4fdbe67ac445e18cdaa74824fd93cd069e7bb6548cb52d/google_cloud_core-2.5.0.tar.gz", hash = "sha256:7c1b7ef5c92311717bd05301aa1a91ffbc565673d3b0b4163a52d8413a186963", size = 36027, upload-time = "2025-10-29T23:17:39.513Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/89/20/bfa472e327c8edee00f04beecc80baeddd2ab33ee0e86fd7654da49d45e9/google_cloud_core-2.5.0-py3-none-any.whl", hash = "sha256:67d977b41ae6c7211ee830c7912e41003ea8194bff15ae7d72fd6f51e57acabc", size = 29469, upload-time = "2025-10-29T23:17:38.548Z" }, -] - -[[package]] -name = "google-cloud-resource-manager" -version = "1.16.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core", extra = ["grpc"] }, - { name = "google-auth" }, - { name = "grpc-google-iam-v1" }, - { name = "grpcio" }, - { name = "proto-plus" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4e/7f/db00b2820475793a52958dc55fe9ec2eb8e863546e05fcece9b921f86ebe/google_cloud_resource_manager-1.16.0.tar.gz", hash = "sha256:cc938f87cc36c2672f062b1e541650629e0d954c405a4dac35ceedee70c267c3", size = 459840, upload-time = "2026-01-15T13:04:07.726Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/ff/4b28bcc791d9d7e4ac8fea00fbd90ccb236afda56746a3b4564d2ae45df3/google_cloud_resource_manager-1.16.0-py3-none-any.whl", hash = "sha256:fb9a2ad2b5053c508e1c407ac31abfd1a22e91c32876c1892830724195819a28", size = 400218, upload-time = "2026-01-15T13:02:47.378Z" }, -] - -[[package]] -name = "google-cloud-storage" -version = "3.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-api-core" }, - { name = "google-auth" }, - { name = "google-cloud-core" }, - { name = "google-crc32c" }, - { name = "google-resumable-media" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/90/4398cecc2704cb066bc7dee6111a5c93c59bcd6fb751f0541315655774a8/google_cloud_storage-3.8.0.tar.gz", hash = "sha256:cc67952dce84ebc9d44970e24647a58260630b7b64d72360cedaf422d6727f28", size = 17273792, upload-time = "2026-01-14T00:45:31.289Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/11/db/326279870d349fb9592263343dca4ad76088c17c88ba97b0f64c1088276c/google_cloud_storage-3.8.0-py3-none-any.whl", hash = "sha256:78cfeae7cac2ca9441d0d0271c2eb4ebfa21aa4c6944dd0ccac0389e81d955a7", size = 312430, upload-time = "2026-01-14T00:45:28.689Z" }, -] - -[[package]] -name = "google-crc32c" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" }, - { url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" }, - { url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" }, - { url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" }, - { url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" }, - { url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" }, - { url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" }, - { url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" }, -] - [[package]] name = "google-genai" version = "1.60.0" @@ -1020,35 +865,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/31/e5/384b1f383917b5f0ae92e28f47bc27b16e3d26cd9bacb25e9f8ecab3c8fe/google_genai-1.60.0-py3-none-any.whl", hash = "sha256:967338378ffecebec19a8ed90cf8797b26818bacbefd7846a9280beb1099f7f3", size = 719431, upload-time = "2026-01-21T22:17:28.086Z" }, ] -[[package]] -name = "google-resumable-media" -version = "2.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "google-crc32c" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/64/d7/520b62a35b23038ff005e334dba3ffc75fcf583bee26723f1fd8fd4b6919/google_resumable_media-2.8.0.tar.gz", hash = "sha256:f1157ed8b46994d60a1bc432544db62352043113684d4e030ee02e77ebe9a1ae", size = 2163265, upload-time = "2025-11-17T15:38:06.659Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/0b/93afde9cfe012260e9fe1522f35c9b72d6ee222f316586b1f23ecf44d518/google_resumable_media-2.8.0-py3-none-any.whl", hash = "sha256:dd14a116af303845a8d932ddae161a26e86cc229645bc98b39f026f9b1717582", size = 81340, upload-time = "2025-11-17T15:38:05.594Z" }, -] - -[[package]] -name = "googleapis-common-protos" -version = "1.72.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e5/7b/adfd75544c415c487b33061fe7ae526165241c1ea133f9a9125a56b39fd8/googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5", size = 147433, upload-time = "2025-11-06T18:29:24.087Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, -] - -[package.optional-dependencies] -grpc = [ - { name = "grpcio" }, -] - [[package]] name = "greenlet" version = "3.3.0" @@ -1088,65 +904,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5f/d6/645a081750e43f858b7d09dce5d8e1e76cf11e7e4bdba81252e04f78963d/groq-0.37.1-py3-none-any.whl", hash = "sha256:b49f8c8898c55eaec9f71f1342f3fcacc9560d67a08ce5f35fbfb84e8dacd3da", size = 137494, upload-time = "2025-12-04T18:08:05.801Z" }, ] -[[package]] -name = "grpc-google-iam-v1" -version = "0.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos", extra = ["grpc"] }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/1e/1011451679a983f2f5c6771a1682542ecb027776762ad031fd0d7129164b/grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389", size = 23745, upload-time = "2025-10-15T21:14:53.318Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6", size = 32690, upload-time = "2025-10-15T21:14:51.72Z" }, -] - -[[package]] -name = "grpcio" -version = "1.76.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, - { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, - { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, - { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, - { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, - { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, - { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, - { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, - { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, - { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, - { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, - { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, - { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, - { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, - { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, - { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, - { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, - { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, -] - -[[package]] -name = "grpcio-status" -version = "1.76.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "googleapis-common-protos" }, - { name = "grpcio" }, - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/46/e9f19d5be65e8423f886813a2a9d0056ba94757b0c5007aa59aed1a961fa/grpcio_status-1.76.0.tar.gz", hash = "sha256:25fcbfec74c15d1a1cb5da3fab8ee9672852dc16a5a9eeb5baf7d7a9952943cd", size = 13679, upload-time = "2025-10-21T16:28:52.545Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/cc/27ba60ad5a5f2067963e6a858743500df408eb5855e98be778eaef8c9b02/grpcio_status-1.76.0-py3-none-any.whl", hash = "sha256:380568794055a8efbbd8871162df92012e0228a5f6dffaf57f2a00c534103b18", size = 14425, upload-time = "2025-10-21T16:28:40.853Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -1729,27 +1486,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/51/39942c0083139652494bb354dddf0ed397703a4882302f7b48aeca531c96/langchain_google_genai-4.2.0-py3-none-any.whl", hash = "sha256:856041aaafceff65a4ef0d5acf5731f2db95229ff041132af011aec51e8279d9", size = 66452, upload-time = "2026-01-13T20:41:16.296Z" }, ] -[[package]] -name = "langchain-google-vertexai" -version = "3.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bottleneck" }, - { name = "google-cloud-aiplatform" }, - { name = "google-cloud-storage" }, - { name = "httpx" }, - { name = "httpx-sse" }, - { name = "langchain-core" }, - { name = "numexpr" }, - { name = "pyarrow" }, - { name = "pydantic" }, - { name = "validators" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/5f/55a5b568104c32e265970d4217083d76252c2140f532f382bb42f35886a8/langchain_google_vertexai-3.2.1.tar.gz", hash = "sha256:8913e8aa7ca300eb7d9b8681ba2487dad787debe2511a903a249dc03709720d2", size = 360287, upload-time = "2026-01-05T21:47:58.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/90/e2b1493df6ad06a7c1194f6d8238ddbd7fedc0cfe1e32d1c3980903c9d05/langchain_google_vertexai-3.2.1-py3-none-any.whl", hash = "sha256:57a25680290060c896fb740bdaafa987e0518b599f793b3dfb2d07a7aec97bd8", size = 103650, upload-time = "2026-01-05T21:47:57.136Z" }, -] - [[package]] name = "langchain-groq" version = "1.1.1" @@ -2309,33 +2045,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a3/f1/0578d65b4e3dc572967fd702221ea1f42e1e60accfb6b0dd8d8f15410139/nodejs_wheel_binaries-24.13.0-py2.py3-none-win_arm64.whl", hash = "sha256:2e3431d869d6b2dbeef1d469ad0090babbdcc8baaa72c01dd3cc2c6121c96af5", size = 39054688, upload-time = "2026-01-14T11:05:30.739Z" }, ] -[[package]] -name = "numexpr" -version = "2.14.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cb/2f/fdba158c9dbe5caca9c3eca3eaffffb251f2fb8674bf8e2d0aed5f38d319/numexpr-2.14.1.tar.gz", hash = "sha256:4be00b1086c7b7a5c32e31558122b7b80243fe098579b170967da83f3152b48b", size = 119400, upload-time = "2025-10-13T16:17:27.351Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b2/a3/67999bdd1ed1f938d38f3fedd4969632f2f197b090e50505f7cc1fa82510/numexpr-2.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2d03fcb4644a12f70a14d74006f72662824da5b6128bf1bcd10cc3ed80e64c34", size = 163195, upload-time = "2025-10-13T16:16:31.212Z" }, - { url = "https://files.pythonhosted.org/packages/25/95/d64f680ea1fc56d165457287e0851d6708800f9fcea346fc1b9957942ee6/numexpr-2.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2773ee1133f77009a1fc2f34fe236f3d9823779f5f75450e183137d49f00499f", size = 152088, upload-time = "2025-10-13T16:16:33.186Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7f/3bae417cb13ae08afd86d08bb0301c32440fe0cae4e6262b530e0819aeda/numexpr-2.14.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebe4980f9494b9f94d10d2e526edc29e72516698d3bf95670ba79415492212a4", size = 451126, upload-time = "2025-10-13T16:13:22.248Z" }, - { url = "https://files.pythonhosted.org/packages/4c/1a/edbe839109518364ac0bd9e918cf874c755bb2c128040e920f198c494263/numexpr-2.14.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a381e5e919a745c9503bcefffc1c7f98c972c04ec58fc8e999ed1a929e01ba6", size = 442012, upload-time = "2025-10-13T16:14:51.416Z" }, - { url = "https://files.pythonhosted.org/packages/66/b1/be4ce99bff769a5003baddac103f34681997b31d4640d5a75c0e8ed59c78/numexpr-2.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d08856cfc1b440eb1caaa60515235369654321995dd68eb9377577392020f6cb", size = 1415975, upload-time = "2025-10-13T16:13:26.088Z" }, - { url = "https://files.pythonhosted.org/packages/e7/33/b33b8fdc032a05d9ebb44a51bfcd4b92c178a2572cd3e6c1b03d8a4b45b2/numexpr-2.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03130afa04edf83a7b590d207444f05a00363c9b9ea5d81c0f53b1ea13fad55a", size = 1464683, upload-time = "2025-10-13T16:14:58.87Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b2/ddcf0ac6cf0a1d605e5aecd4281507fd79a9628a67896795ab2e975de5df/numexpr-2.14.1-cp311-cp311-win32.whl", hash = "sha256:db78fa0c9fcbaded3ae7453faf060bd7a18b0dc10299d7fcd02d9362be1213ed", size = 166838, upload-time = "2025-10-13T16:17:06.765Z" }, - { url = "https://files.pythonhosted.org/packages/64/72/4ca9bd97b2eb6dce9f5e70a3b6acec1a93e1fb9b079cb4cba2cdfbbf295d/numexpr-2.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:e9b2f957798c67a2428be96b04bce85439bed05efe78eb78e4c2ca43737578e7", size = 160069, upload-time = "2025-10-13T16:17:08.752Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/c473fc04a371f5e2f8c5749e04505c13e7a8ede27c09e9f099b2ad6f43d6/numexpr-2.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ebae0ab18c799b0e6b8c5a8d11e1fa3848eb4011271d99848b297468a39430", size = 162790, upload-time = "2025-10-13T16:16:34.903Z" }, - { url = "https://files.pythonhosted.org/packages/45/93/b6760dd1904c2a498e5f43d1bb436f59383c3ddea3815f1461dfaa259373/numexpr-2.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47041f2f7b9e69498fb311af672ba914a60e6e6d804011caacb17d66f639e659", size = 152196, upload-time = "2025-10-13T16:16:36.593Z" }, - { url = "https://files.pythonhosted.org/packages/72/94/cc921e35593b820521e464cbbeaf8212bbdb07f16dc79fe283168df38195/numexpr-2.14.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d686dfb2c1382d9e6e0ee0b7647f943c1886dba3adbf606c625479f35f1956c1", size = 452468, upload-time = "2025-10-13T16:13:29.531Z" }, - { url = "https://files.pythonhosted.org/packages/d9/43/560e9ba23c02c904b5934496486d061bcb14cd3ebba2e3cf0e2dccb6c22b/numexpr-2.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee6d4fbbbc368e6cdd0772734d6249128d957b3b8ad47a100789009f4de7083", size = 443631, upload-time = "2025-10-13T16:15:02.473Z" }, - { url = "https://files.pythonhosted.org/packages/7b/6c/78f83b6219f61c2c22d71ab6e6c2d4e5d7381334c6c29b77204e59edb039/numexpr-2.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a2839efa25f3c8d4133252ea7342d8f81226c7c4dda81f97a57e090b9d87a48", size = 1417670, upload-time = "2025-10-13T16:13:33.464Z" }, - { url = "https://files.pythonhosted.org/packages/0e/bb/1ccc9dcaf46281568ce769888bf16294c40e98a5158e4b16c241de31d0d3/numexpr-2.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9f9137f1351b310436662b5dc6f4082a245efa8950c3b0d9008028df92fefb9b", size = 1466212, upload-time = "2025-10-13T16:15:12.828Z" }, - { url = "https://files.pythonhosted.org/packages/31/9f/203d82b9e39dadd91d64bca55b3c8ca432e981b822468dcef41a4418626b/numexpr-2.14.1-cp312-cp312-win32.whl", hash = "sha256:36f8d5c1bd1355df93b43d766790f9046cccfc1e32b7c6163f75bcde682cda07", size = 166996, upload-time = "2025-10-13T16:17:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/ffe750b5452eb66de788c34e7d21ec6d886abb4d7c43ad1dc88ceb3d998f/numexpr-2.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:fdd886f4b7dbaf167633ee396478f0d0aa58ea2f9e7ccc3c6431019623e8d68f", size = 160187, upload-time = "2025-10-13T16:17:11.974Z" }, -] - [[package]] name = "numpy" version = "2.4.1" @@ -2388,7 +2097,7 @@ wheels = [ [[package]] name = "open-notebook" -version = "1.6.2" +version = "1.7.0rc1" source = { editable = "." } dependencies = [ { name = "ai-prompter" }, @@ -2401,7 +2110,6 @@ dependencies = [ { name = "langchain-community" }, { name = "langchain-deepseek" }, { name = "langchain-google-genai" }, - { name = "langchain-google-vertexai" }, { name = "langchain-groq" }, { name = "langchain-mistralai" }, { name = "langchain-ollama" }, @@ -2453,7 +2161,6 @@ requires-dist = [ { name = "langchain-community", specifier = ">=0.4.1" }, { name = "langchain-deepseek", specifier = ">=1.0.0" }, { name = "langchain-google-genai", specifier = ">=4.1.2" }, - { name = "langchain-google-vertexai", specifier = ">=3.2.0" }, { name = "langchain-groq", specifier = ">=1.1.1" }, { name = "langchain-mistralai", specifier = ">=1.1.1" }, { name = "langchain-ollama", specifier = ">=1.0.1" }, @@ -2923,33 +2630,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, ] -[[package]] -name = "proto-plus" -version = "1.27.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "protobuf" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/89/9cbe2f4bba860e149108b683bc2efec21f14d5f7ed6e25562ad86acbc373/proto_plus-1.27.0.tar.gz", hash = "sha256:873af56dd0d7e91836aee871e5799e1c6f1bda86ac9a983e0bb9f0c266a568c4", size = 56158, upload-time = "2025-12-16T13:46:25.729Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/24/3b7a0818484df9c28172857af32c2397b6d8fcd99d9468bd4684f98ebf0a/proto_plus-1.27.0-py3-none-any.whl", hash = "sha256:1baa7f81cf0f8acb8bc1f6d085008ba4171eaf669629d1b6d1673b21ed1c0a82", size = 50205, upload-time = "2025-12-16T13:46:24.76Z" }, -] - -[[package]] -name = "protobuf" -version = "6.33.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/b8/cda15d9d46d03d4aa3a67cb6bffe05173440ccf86a9541afaf7ac59a1b6b/protobuf-6.33.4.tar.gz", hash = "sha256:dc2e61bca3b10470c1912d166fe0af67bfc20eb55971dcef8dfa48ce14f0ed91", size = 444346, upload-time = "2026-01-12T18:33:40.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/be/24ef9f3095bacdf95b458543334d0c4908ccdaee5130420bf064492c325f/protobuf-6.33.4-cp310-abi3-win32.whl", hash = "sha256:918966612c8232fc6c24c78e1cd89784307f5814ad7506c308ee3cf86662850d", size = 425612, upload-time = "2026-01-12T18:33:29.656Z" }, - { url = "https://files.pythonhosted.org/packages/31/ad/e5693e1974a28869e7cd244302911955c1cebc0161eb32dfa2b25b6e96f0/protobuf-6.33.4-cp310-abi3-win_amd64.whl", hash = "sha256:8f11ffae31ec67fc2554c2ef891dcb561dae9a2a3ed941f9e134c2db06657dbc", size = 436962, upload-time = "2026-01-12T18:33:31.345Z" }, - { url = "https://files.pythonhosted.org/packages/66/15/6ee23553b6bfd82670207ead921f4d8ef14c107e5e11443b04caeb5ab5ec/protobuf-6.33.4-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:2fe67f6c014c84f655ee06f6f66213f9254b3a8b6bda6cda0ccd4232c73c06f0", size = 427612, upload-time = "2026-01-12T18:33:32.646Z" }, - { url = "https://files.pythonhosted.org/packages/2b/48/d301907ce6d0db75f959ca74f44b475a9caa8fcba102d098d3c3dd0f2d3f/protobuf-6.33.4-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:757c978f82e74d75cba88eddec479df9b99a42b31193313b75e492c06a51764e", size = 324484, upload-time = "2026-01-12T18:33:33.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/1c/e53078d3f7fe710572ab2dcffd993e1e3b438ae71cfc031b71bae44fcb2d/protobuf-6.33.4-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c7c64f259c618f0bef7bee042075e390debbf9682334be2b67408ec7c1c09ee6", size = 339256, upload-time = "2026-01-12T18:33:35.231Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8e/971c0edd084914f7ee7c23aa70ba89e8903918adca179319ee94403701d5/protobuf-6.33.4-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:3df850c2f8db9934de4cf8f9152f8dc2558f49f298f37f90c517e8e5c84c30e9", size = 323311, upload-time = "2026-01-12T18:33:36.305Z" }, - { url = "https://files.pythonhosted.org/packages/75/b1/1dc83c2c661b4c62d56cc081706ee33a4fc2835bd90f965baa2663ef7676/protobuf-6.33.4-py3-none-any.whl", hash = "sha256:1fe3730068fcf2e595816a6c34fe66eeedd37d51d0400b72fabc848811fdc1bc", size = 170532, upload-time = "2026-01-12T18:33:39.199Z" }, -] - [[package]] name = "psutil" version = "7.2.1" @@ -3025,28 +2705,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" }, ] -[[package]] -name = "pyarrow" -version = "22.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/53/04a7fdc63e6056116c9ddc8b43bc28c12cdd181b85cbeadb79278475f3ae/pyarrow-22.0.0.tar.gz", hash = "sha256:3d600dc583260d845c7d8a6db540339dd883081925da2bd1c5cb808f720b3cd9", size = 1151151, upload-time = "2025-10-24T12:30:00.762Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/b7/18f611a8cdc43417f9394a3ccd3eace2f32183c08b9eddc3d17681819f37/pyarrow-22.0.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:3e294c5eadfb93d78b0763e859a0c16d4051fc1c5231ae8956d61cb0b5666f5a", size = 34272022, upload-time = "2025-10-24T10:04:28.973Z" }, - { url = "https://files.pythonhosted.org/packages/26/5c/f259e2526c67eb4b9e511741b19870a02363a47a35edbebc55c3178db22d/pyarrow-22.0.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:69763ab2445f632d90b504a815a2a033f74332997052b721002298ed6de40f2e", size = 35995834, upload-time = "2025-10-24T10:04:35.467Z" }, - { url = "https://files.pythonhosted.org/packages/50/8d/281f0f9b9376d4b7f146913b26fac0aa2829cd1ee7e997f53a27411bbb92/pyarrow-22.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b41f37cabfe2463232684de44bad753d6be08a7a072f6a83447eeaf0e4d2a215", size = 45030348, upload-time = "2025-10-24T10:04:43.366Z" }, - { url = "https://files.pythonhosted.org/packages/f5/e5/53c0a1c428f0976bf22f513d79c73000926cb00b9c138d8e02daf2102e18/pyarrow-22.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:35ad0f0378c9359b3f297299c3309778bb03b8612f987399a0333a560b43862d", size = 47699480, upload-time = "2025-10-24T10:04:51.486Z" }, - { url = "https://files.pythonhosted.org/packages/95/e1/9dbe4c465c3365959d183e6345d0a8d1dc5b02ca3f8db4760b3bc834cf25/pyarrow-22.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8382ad21458075c2e66a82a29d650f963ce51c7708c7c0ff313a8c206c4fd5e8", size = 48011148, upload-time = "2025-10-24T10:04:59.585Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/7caf5d21930061444c3cf4fa7535c82faf5263e22ce43af7c2759ceb5b8b/pyarrow-22.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1a812a5b727bc09c3d7ea072c4eebf657c2f7066155506ba31ebf4792f88f016", size = 50276964, upload-time = "2025-10-24T10:05:08.175Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f3/cec89bd99fa3abf826f14d4e53d3d11340ce6f6af4d14bdcd54cd83b6576/pyarrow-22.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:ec5d40dd494882704fb876c16fa7261a69791e784ae34e6b5992e977bd2e238c", size = 28106517, upload-time = "2025-10-24T10:05:14.314Z" }, - { url = "https://files.pythonhosted.org/packages/af/63/ba23862d69652f85b615ca14ad14f3bcfc5bf1b99ef3f0cd04ff93fdad5a/pyarrow-22.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:bea79263d55c24a32b0d79c00a1c58bb2ee5f0757ed95656b01c0fb310c5af3d", size = 34211578, upload-time = "2025-10-24T10:05:21.583Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d0/f9ad86fe809efd2bcc8be32032fa72e8b0d112b01ae56a053006376c5930/pyarrow-22.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:12fe549c9b10ac98c91cf791d2945e878875d95508e1a5d14091a7aaa66d9cf8", size = 35989906, upload-time = "2025-10-24T10:05:29.485Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a8/f910afcb14630e64d673f15904ec27dd31f1e009b77033c365c84e8c1e1d/pyarrow-22.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:334f900ff08ce0423407af97e6c26ad5d4e3b0763645559ece6fbf3747d6a8f5", size = 45021677, upload-time = "2025-10-24T10:05:38.274Z" }, - { url = "https://files.pythonhosted.org/packages/13/95/aec81f781c75cd10554dc17a25849c720d54feafb6f7847690478dcf5ef8/pyarrow-22.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:c6c791b09c57ed76a18b03f2631753a4960eefbbca80f846da8baefc6491fcfe", size = 47726315, upload-time = "2025-10-24T10:05:47.314Z" }, - { url = "https://files.pythonhosted.org/packages/bb/d4/74ac9f7a54cfde12ee42734ea25d5a3c9a45db78f9def949307a92720d37/pyarrow-22.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c3200cb41cdbc65156e5f8c908d739b0dfed57e890329413da2748d1a2cd1a4e", size = 47990906, upload-time = "2025-10-24T10:05:58.254Z" }, - { url = "https://files.pythonhosted.org/packages/2e/71/fedf2499bf7a95062eafc989ace56572f3343432570e1c54e6599d5b88da/pyarrow-22.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ac93252226cf288753d8b46280f4edf3433bf9508b6977f8dd8526b521a1bbb9", size = 50306783, upload-time = "2025-10-24T10:06:08.08Z" }, - { url = "https://files.pythonhosted.org/packages/68/ed/b202abd5a5b78f519722f3d29063dda03c114711093c1995a33b8e2e0f4b/pyarrow-22.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:44729980b6c50a5f2bfcc2668d36c569ce17f8b17bccaf470c4313dcbbf13c9d", size = 27972883, upload-time = "2025-10-24T10:06:14.204Z" }, -] - [[package]] name = "pyasn1" version = "0.6.2" From 827cf55d25e40317dcfd149247bea0fe9091456a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:34:37 -0300 Subject: [PATCH 02/27] chore(deps): bump pip from 25.3 to 26.0 (#532) Bumps [pip](https://github.com/pypa/pip) from 25.3 to 26.0. - [Changelog](https://github.com/pypa/pip/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/pip/compare/25.3...26.0) --- updated-dependencies: - dependency-name: pip dependency-version: '26.0' dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 2043b95..002f717 100644 --- a/uv.lock +++ b/uv.lock @@ -874,6 +874,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, + { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, @@ -881,6 +882,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -2494,11 +2496,11 @@ wheels = [ [[package]] name = "pip" -version = "25.3" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/c2/65686a7783a7c27a329706207147e82f23c41221ee9ae33128fc331670a0/pip-26.0.tar.gz", hash = "sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa", size = 1812654, upload-time = "2026-01-31T01:40:54.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, + { url = "https://files.pythonhosted.org/packages/69/00/5ac7aa77688ec4d34148b423d34dc0c9bc4febe0d872a9a1ad9860b2f6f1/pip-26.0-py3-none-any.whl", hash = "sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754", size = 1787564, upload-time = "2026-01-31T01:40:52.252Z" }, ] [[package]] From 0268a1e6a34ceccf462f7be0dd61a0f886f3f454 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:34:58 -0300 Subject: [PATCH 03/27] chore(deps): bump axios from 1.12.0 to 1.13.5 in /frontend (#553) Bumps [axios](https://github.com/axios/axios) from 1.12.0 to 1.13.5. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.12.0...v1.13.5) --- updated-dependencies: - dependency-name: axios dependency-version: 1.13.5 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- frontend/package-lock.json | 33 ++++++++++++++++++++------------- frontend/package.json | 2 +- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 21c8910..d5b09ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" }, diff --git a/frontend/package.json b/frontend/package.json index 3f35007..b0c99aa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", From 3cb8c73cf110161f7eed48dda2ece897cbe01e4e Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Tue, 10 Feb 2026 08:36:32 -0300 Subject: [PATCH 04/27] chore: bump version to 1.7.0 (#554) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a60b59a..cd3af7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-notebook" -version = "1.7.0-rc1" +version = "1.7.0" description = "An open source implementation of a research assistant, inspired by Google Notebook LM" authors = [ {name = "Luis Novo", email = "lfnovo@gmail.com"} From 97b7fc6e0d86e162670b84acefa30b3e821d1c4a Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Tue, 10 Feb 2026 08:48:25 -0300 Subject: [PATCH 05/27] docs: update CHANGELOG for v1.7.0 release (#555) * chore: bump version to 1.7.0 * docs: update CHANGELOG for v1.7.0 release --- CHANGELOG.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3512e2c..563bd3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.7.0-rc1] - 2026-02-07 +## [1.7.0] - 2026-02-10 ### Added - **Credential-Based Provider Management** (#477) @@ -34,10 +34,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 @@ -49,6 +64,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 From 98dadd151aabb0d34f561b2fda40a9b7519f92ec Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Tue, 10 Feb 2026 11:24:17 -0300 Subject: [PATCH 06/27] fix: turn the embedding field into optional (#557) * fix: turn the embedding field into optional * enable migration 13 - fixes: #556 --- open_notebook/database/async_migrate.py | 6 ++++++ open_notebook/database/migrations/13.surrealql | 3 +++ open_notebook/database/migrations/13_down.surrealql | 2 ++ 3 files changed, 11 insertions(+) create mode 100644 open_notebook/database/migrations/13.surrealql create mode 100644 open_notebook/database/migrations/13_down.surrealql diff --git a/open_notebook/database/async_migrate.py b/open_notebook/database/async_migrate.py index 2de354b..2908995 100644 --- a/open_notebook/database/async_migrate.py +++ b/open_notebook/database/async_migrate.py @@ -112,6 +112,9 @@ class AsyncMigrationManager: AsyncMigration.from_file( "open_notebook/database/migrations/12.surrealql" ), + AsyncMigration.from_file( + "open_notebook/database/migrations/13.surrealql" + ), ] self.down_migrations = [ AsyncMigration.from_file( @@ -150,6 +153,9 @@ class AsyncMigrationManager: AsyncMigration.from_file( "open_notebook/database/migrations/12_down.surrealql" ), + AsyncMigration.from_file( + "open_notebook/database/migrations/13_down.surrealql" + ), ] self.runner = AsyncMigrationRunner( up_migrations=self.up_migrations, diff --git a/open_notebook/database/migrations/13.surrealql b/open_notebook/database/migrations/13.surrealql new file mode 100644 index 0000000..fe35f00 --- /dev/null +++ b/open_notebook/database/migrations/13.surrealql @@ -0,0 +1,3 @@ + +DEFINE FIELD OVERWRITE embedding ON TABLE source_insight TYPE option>; +DEFINE FIELD OVERWRITE embedding ON TABLE note TYPE option>; diff --git a/open_notebook/database/migrations/13_down.surrealql b/open_notebook/database/migrations/13_down.surrealql new file mode 100644 index 0000000..529d528 --- /dev/null +++ b/open_notebook/database/migrations/13_down.surrealql @@ -0,0 +1,2 @@ +DEFINE FIELD OVERWRITE embedding ON TABLE source_insight TYPE array; +DEFINE FIELD OVERWRITE embedding ON TABLE note TYPE array; \ No newline at end of file From 877c303b028d8e738cf57aed8de8983bb055d830 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Thu, 12 Feb 2026 07:33:27 -0300 Subject: [PATCH 07/27] fix: update esperanto dep and increase transformation max_tokens (#568) * fix: increase transformation max_tokens from 5055 to 8192 Closes #565 * chore: update esperanto dep to fix api keys passing via config - fixes: #567 --- open_notebook/graphs/transformation.py | 2 +- pyproject.toml | 2 +- uv.lock | 12 +++++------- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/open_notebook/graphs/transformation.py b/open_notebook/graphs/transformation.py index 9660047..1dc1f56 100644 --- a/open_notebook/graphs/transformation.py +++ b/open_notebook/graphs/transformation.py @@ -41,7 +41,7 @@ async def run_transformation(state: dict, config: RunnableConfig) -> dict: str(payload), config.get("configurable", {}).get("model_id"), "transformation", - max_tokens=5055, + max_tokens=8192, ) response = await chain.ainvoke(payload) diff --git a/pyproject.toml b/pyproject.toml index cd3af7f..04afcee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "httpx[socks]>=0.27.0", "content-core>=1.14.1,<2", "ai-prompter>=0.3,<1", - "esperanto>=2.18,<3", + "esperanto>=2.19.1,<3", "surrealdb>=1.0.4", "podcast-creator>=0.9,<1", "surreal-commands>=1.3.1,<2", diff --git a/uv.lock b/uv.lock index 002f717..a5f0313 100644 --- a/uv.lock +++ b/uv.lock @@ -639,15 +639,15 @@ wheels = [ [[package]] name = "esperanto" -version = "2.18.0" +version = "2.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/f1/1b34dc08a293b6f00291611799fc04074e3c7cadf9578b26f2329c20b91d/esperanto-2.18.0.tar.gz", hash = "sha256:8743fdaeb810b0354ae127a8ca875947c4e57db4d0336543a862f2dd213735fd", size = 849402, upload-time = "2026-01-30T00:40:09.038Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/d1/1584254f18b81b0975ca24b5f2645b99b8a16a22f7e5f15e9a193a7bbc71/esperanto-2.19.1.tar.gz", hash = "sha256:13f5116e167cc433a26471ffd4a3334c66da33bff2638a9649f3e6a626886aea", size = 831868, upload-time = "2026-02-12T10:25:30Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/21/9b/163225ef2b723a8edf06003c82888a73b8ad3fbb97bc12aed62867f4381d/esperanto-2.18.0-py3-none-any.whl", hash = "sha256:dfcec77ed81a195aa222636f7b70024c73c4282f3bcad13edb000ad1f4224903", size = 201374, upload-time = "2026-01-30T00:40:10.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/a4/b5941ecb5f2a1357fbe3fa843fc76e2d978624695a0a0e2d991692907f46/esperanto-2.19.1-py3-none-any.whl", hash = "sha256:0902315ea53a066366dbc41f795aaf525b80f3b29607f5e1b3bd22d80ac22abf", size = 202082, upload-time = "2026-02-12T10:25:31.882Z" }, ] [[package]] @@ -874,7 +874,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" }, { url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" }, { url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" }, - { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" }, { url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" }, { url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" }, { url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" }, @@ -882,7 +881,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, - { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, @@ -2099,7 +2097,7 @@ wheels = [ [[package]] name = "open-notebook" -version = "1.7.0rc1" +version = "1.7.0" source = { editable = "." } dependencies = [ { name = "ai-prompter" }, @@ -2153,7 +2151,7 @@ dev = [ requires-dist = [ { name = "ai-prompter", specifier = ">=0.3,<1" }, { name = "content-core", specifier = ">=1.14.1,<2" }, - { name = "esperanto", specifier = ">=2.18,<3" }, + { name = "esperanto", specifier = ">=2.19.1,<3" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'dev'", specifier = ">=6.29.5" }, From 26d53497503b46c37975b50e5f3505770049f5f0 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 14 Feb 2026 18:09:07 -0300 Subject: [PATCH 08/27] fix: handle empty/whitespace source content without retry loop (#576) Source.vectorize() wrapped its own ValueError in DatabaseOperationError, bypassing the stop_on=[ValueError] retry guard in process_source_command. This caused up to 15 retries when processing files with no extractable text, blocking sync API requests indefinitely. - Re-raise ValueError directly in Source.vectorize() instead of wrapping - Add .strip() check to catch whitespace-only content - Skip vectorization gracefully in save_source() when content is empty - Add unit tests for vectorize error handling Fixes #560 --- open_notebook/domain/notebook.py | 4 +++- open_notebook/graphs/source.py | 9 ++++++-- tests/test_domain.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/open_notebook/domain/notebook.py b/open_notebook/domain/notebook.py index 05dc8f2..656255f 100644 --- a/open_notebook/domain/notebook.py +++ b/open_notebook/domain/notebook.py @@ -429,7 +429,7 @@ class Source(ObjectModel): logger.info(f"Submitting embed_source job for source {self.id}") try: - if not self.full_text: + if not self.full_text or not self.full_text.strip(): raise ValueError(f"Source {self.id} has no text to vectorize") # Submit the embed_source command @@ -447,6 +447,8 @@ class Source(ObjectModel): return command_id_str + except ValueError: + raise except Exception as e: logger.error( f"Failed to submit embed_source job for source {self.id}: {e}" diff --git a/open_notebook/graphs/source.py b/open_notebook/graphs/source.py index 02d1e75..dc55390 100644 --- a/open_notebook/graphs/source.py +++ b/open_notebook/graphs/source.py @@ -101,8 +101,13 @@ async def save_source(state: SourceState) -> dict: # No need to create them here to avoid duplicate edges if state["embed"]: - logger.debug("Embedding content for vector search") - await source.vectorize() + if source.full_text and source.full_text.strip(): + logger.debug("Embedding content for vector search") + await source.vectorize() + else: + logger.warning( + f"Source {source.id} has no text content to embed, skipping vectorization" + ) return {"source": source} diff --git a/tests/test_domain.py b/tests/test_domain.py index 7fe5167..917c163 100644 --- a/tests/test_domain.py +++ b/tests/test_domain.py @@ -202,6 +202,43 @@ class TestSourceDomain: mock_delete.assert_called_once() + @pytest.mark.asyncio + async def test_vectorize_raises_valueerror_when_no_text(self): + """Test that vectorize() raises ValueError (not DatabaseOperationError) for empty text.""" + source = Source(id="source:test_empty", title="Test", full_text=None) + with pytest.raises(ValueError, match="has no text to vectorize"): + await source.vectorize() + + @pytest.mark.asyncio + async def test_vectorize_raises_valueerror_when_empty_string(self): + """Test that vectorize() raises ValueError for empty string.""" + source = Source(id="source:test_empty_str", title="Test", full_text="") + with pytest.raises(ValueError, match="has no text to vectorize"): + await source.vectorize() + + @pytest.mark.asyncio + async def test_vectorize_raises_valueerror_when_whitespace_only(self): + """Test that vectorize() raises ValueError for whitespace-only text.""" + source = Source(id="source:test_ws", title="Test", full_text=" \n\t ") + with pytest.raises(ValueError, match="has no text to vectorize"): + await source.vectorize() + + @pytest.mark.asyncio + async def test_vectorize_submits_command_with_valid_text(self): + """Test that vectorize() submits embed_source command when text is valid.""" + source = Source(id="source:test_valid", title="Test", full_text="Real content") + with patch( + "open_notebook.domain.notebook.submit_command", return_value="command:123" + ) as mock_submit: + result = await source.vectorize() + mock_submit.assert_called_once_with( + "open_notebook", + "embed_source", + {"source_id": "source:test_valid"}, + ) + assert result == "command:123" + + # ============================================================================ # TEST SUITE 5: Note Domain # ============================================================================ From 1049b39449dda2707bcec22c93d1683436b99bd9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:10:27 -0300 Subject: [PATCH 09/27] chore(deps): bump cryptography from 46.0.3 to 46.0.5 (#563) Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.3 to 46.0.5. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.5) --- updated-dependencies: - dependency-name: cryptography dependency-version: 46.0.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 74 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/uv.lock b/uv.lock index a5f0313..603f2ff 100644 --- a/uv.lock +++ b/uv.lock @@ -444,49 +444,47 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.3" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, - { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, - { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, - { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, - { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, - { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, - { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, - { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, - { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, - { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, - { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, - { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, - { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, - { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, - { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, - { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, - { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, - { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, - { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, - { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, - { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, - { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, - { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, - { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, - { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] From db094da10a33e8fabe10392ec73ac4269a474460 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Feb 2026 18:10:43 -0300 Subject: [PATCH 10/27] chore(deps): bump langchain-core from 1.2.7 to 1.2.11 (#564) Bumps [langchain-core](https://github.com/langchain-ai/langchain) from 1.2.7 to 1.2.11. - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-core==1.2.7...langchain-core==1.2.11) --- updated-dependencies: - dependency-name: langchain-core dependency-version: 1.2.11 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/uv.lock b/uv.lock index 603f2ff..eac2270 100644 --- a/uv.lock +++ b/uv.lock @@ -1439,7 +1439,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.2.7" +version = "1.2.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -1451,9 +1451,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "uuid-utils" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" } +sdist = { url = "https://files.pythonhosted.org/packages/12/17/1943cedfc118e04b8128e4c3e1dbf0fa0ea58eefddbb6198cfd699d19f01/langchain_core-1.2.11.tar.gz", hash = "sha256:f164bb36602dd74a3a50c1334fca75309ad5ed95767acdfdbb9fa95ce28a1e01", size = 831211, upload-time = "2026-02-10T20:35:28.35Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/10/30/1f80e3fc674353cad975ed5294353d42512535d2094ef032c06454c2c873/langchain_core-1.2.11-py3-none-any.whl", hash = "sha256:ae11ceb8dda60d0b9d09e763116e592f1683327c17be5b715f350fd29aee65d3", size = 500062, upload-time = "2026-02-10T20:35:26.698Z" }, ] [[package]] From 7efac77503364566f5fa0c8d5291eb8401f9e74f Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Sat, 14 Feb 2026 23:11:23 +0200 Subject: [PATCH 11/27] feat: expose embed command_id in note API responses (#545) * feat: expose embed command_id in note API responses Note.save() already returns the command_id from the embed_note background job, but the API routes discarded it. This surfaces the command_id in NoteResponse for both POST and PUT endpoints, enabling callers to poll GET /api/commands/jobs/{command_id} to know when embedding has completed. * Add tests for note API command_id response --- api/models.py | 1 + api/routers/notes.py | 6 ++- tests/test_notes_api.py | 115 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 tests/test_notes_api.py diff --git a/api/models.py b/api/models.py index b7c5ac8..7ea8967 100644 --- a/api/models.py +++ b/api/models.py @@ -193,6 +193,7 @@ class NoteResponse(BaseModel): note_type: Optional[str] created: str updated: str + command_id: Optional[str] = None # Embedding API models diff --git a/api/routers/notes.py b/api/routers/notes.py index d07cd34..4935057 100644 --- a/api/routers/notes.py +++ b/api/routers/notes.py @@ -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=command_id, ) 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=command_id, ) except HTTPException: raise diff --git a/tests/test_notes_api.py b/tests/test_notes_api.py new file mode 100644 index 0000000..eb11721 --- /dev/null +++ b/tests/test_notes_api.py @@ -0,0 +1,115 @@ +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + """Create test client after environment variables have been cleared by conftest.""" + from api.main import app + + return TestClient(app) + + +class TestNoteCreation: + """Test suite for Note API endpoints.""" + + @patch("api.routers.notes.Note") + def test_create_note_returns_command_id(self, mock_note_cls, client): + """Test that creating a note returns the embed command_id.""" + mock_note = AsyncMock() + mock_note.id = "note:abc123" + mock_note.title = "Test Note" + mock_note.content = "Some content" + mock_note.note_type = "human" + mock_note.created = "2026-01-01T00:00:00Z" + mock_note.updated = "2026-01-01T00:00:00Z" + mock_note.save.return_value = "command:embed123" + mock_note.add_to_notebook = AsyncMock() + mock_note_cls.return_value = mock_note + + response = client.post( + "/api/notes", + json={"content": "Some content", "note_type": "human"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["command_id"] == "command:embed123" + assert data["id"] == "note:abc123" + + @patch("api.routers.notes.Note") + def test_create_note_command_id_none_when_no_content_embedding( + self, mock_note_cls, client + ): + """Test that command_id is None when save returns None (no embedding).""" + mock_note = AsyncMock() + mock_note.id = "note:abc456" + mock_note.title = "Empty Note" + mock_note.content = "Some content" + mock_note.note_type = "human" + mock_note.created = "2026-01-01T00:00:00Z" + mock_note.updated = "2026-01-01T00:00:00Z" + mock_note.save.return_value = None + mock_note.add_to_notebook = AsyncMock() + mock_note_cls.return_value = mock_note + + response = client.post( + "/api/notes", + json={"content": "Some content", "note_type": "human"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["command_id"] is None + + +class TestNoteUpdate: + """Test suite for Note update endpoint.""" + + @patch("api.routers.notes.Note") + def test_update_note_returns_command_id(self, mock_note_cls, client): + """Test that updating a note returns the embed command_id.""" + mock_note = AsyncMock() + mock_note.id = "note:abc123" + mock_note.title = "Test Note" + mock_note.content = "Original content" + mock_note.note_type = "human" + mock_note.created = "2026-01-01T00:00:00Z" + mock_note.updated = "2026-01-01T00:00:00Z" + mock_note.save.return_value = "command:embed789" + mock_note_cls.get = AsyncMock(return_value=mock_note) + + response = client.put( + "/api/notes/note:abc123", + json={"content": "Updated content"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["command_id"] == "command:embed789" + + @patch("api.routers.notes.Note") + def test_update_note_command_id_none_when_no_embedding( + self, mock_note_cls, client + ): + """Test that command_id is None on update when no embedding is triggered.""" + mock_note = AsyncMock() + mock_note.id = "note:abc123" + mock_note.title = "Test Note" + mock_note.content = "Some content" + mock_note.note_type = "human" + mock_note.created = "2026-01-01T00:00:00Z" + mock_note.updated = "2026-01-01T00:00:00Z" + mock_note.save.return_value = None + mock_note_cls.get = AsyncMock(return_value=mock_note) + + response = client.put( + "/api/notes/note:abc123", + json={"title": "Updated Title"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["command_id"] is None From 9811c58d1509360cfe12c1e8e3e295701e22e6d7 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 14 Feb 2026 18:38:32 -0300 Subject: [PATCH 12/27] docs: fix docker container names in local setup guides (#577) Docker Compose v2 derives container names from the directory name using dashes. Since the docs instruct users to create an `open-notebook-local` folder, the correct container name is `open-notebook-local-ollama-1`, not `open_notebook-ollama-1`. Fixes #575 --- docs/0-START-HERE/quick-start-local.md | 12 ++++++------ docs/1-INSTALLATION/docker-compose.md | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/0-START-HERE/quick-start-local.md b/docs/0-START-HERE/quick-start-local.md index 646c60c..5abd336 100644 --- a/docs/0-START-HERE/quick-start-local.md +++ b/docs/0-START-HERE/quick-start-local.md @@ -96,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). @@ -224,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 @@ -236,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 ``` --- diff --git a/docs/1-INSTALLATION/docker-compose.md b/docs/1-INSTALLATION/docker-compose.md index 93e4764..3c9215d 100644 --- a/docs/1-INSTALLATION/docker-compose.md +++ b/docs/1-INSTALLATION/docker-compose.md @@ -174,7 +174,7 @@ volumes: 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: From 9b507f111cb30ddad348df5325a9e4c318f9ac58 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 14 Feb 2026 19:12:49 -0300 Subject: [PATCH 13/27] fix: update esperanto to fix ElevenLabs TTS credential passthrough (#578) Esperanto's AIFactory.create_text_to_speech() did not accept a config dict like the other factory methods, so credentials configured via the UI were not passed through. Fixed upstream in esperanto 2.9.2. Refs #571 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 04afcee..553780c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "httpx[socks]>=0.27.0", "content-core>=1.14.1,<2", "ai-prompter>=0.3,<1", - "esperanto>=2.19.1,<3", + "esperanto>=2.19.2,<3", "surrealdb>=1.0.4", "podcast-creator>=0.9,<1", "surreal-commands>=1.3.1,<2", diff --git a/uv.lock b/uv.lock index eac2270..d269800 100644 --- a/uv.lock +++ b/uv.lock @@ -637,15 +637,15 @@ wheels = [ [[package]] name = "esperanto" -version = "2.19.1" +version = "2.19.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/4f/d1/1584254f18b81b0975ca24b5f2645b99b8a16a22f7e5f15e9a193a7bbc71/esperanto-2.19.1.tar.gz", hash = "sha256:13f5116e167cc433a26471ffd4a3334c66da33bff2638a9649f3e6a626886aea", size = 831868, upload-time = "2026-02-12T10:25:30Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/50/cb258aa994d190d327b43cfc760b7d4db7714c1be57158233643606fb3d2/esperanto-2.19.2.tar.gz", hash = "sha256:cfe34893c31619dd65502787d54ab8182445abf9827cbff8bbbe13314201c8c1", size = 832654, upload-time = "2026-02-14T22:05:29.5Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/12/a4/b5941ecb5f2a1357fbe3fa843fc76e2d978624695a0a0e2d991692907f46/esperanto-2.19.1-py3-none-any.whl", hash = "sha256:0902315ea53a066366dbc41f795aaf525b80f3b29607f5e1b3bd22d80ac22abf", size = 202082, upload-time = "2026-02-12T10:25:31.882Z" }, + { url = "https://files.pythonhosted.org/packages/45/cc/807d7c26d775cb512f2fa372613d7bd1939748d66d48873d1d5a2d7b4187/esperanto-2.19.2-py3-none-any.whl", hash = "sha256:c16ffe084dd39f417d9660e4cc1fd0134b680d6d762118fa0007b66c406f6f98", size = 202161, upload-time = "2026-02-14T22:05:28.206Z" }, ] [[package]] @@ -2149,7 +2149,7 @@ dev = [ requires-dist = [ { name = "ai-prompter", specifier = ">=0.3,<1" }, { name = "content-core", specifier = ">=1.14.1,<2" }, - { name = "esperanto", specifier = ">=2.19.1,<3" }, + { name = "esperanto", specifier = ">=2.19.2,<3" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'dev'", specifier = ">=6.29.5" }, From fb21f5e777337990ee700e56e36d4dd222983205 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 14 Feb 2026 20:14:08 -0300 Subject: [PATCH 14/27] feat: add CI test workflow and improve i18n validation (#580) - Add GitHub Actions workflow to run backend (pytest) and frontend (vitest) tests on PRs and pushes to main - Replace hardcoded locale parity tests with dynamic discovery from the resources registry, so new languages are tested automatically - Add unused key detection test that scans source files for i18n key references, with optional chaining normalization - Remove 149 genuinely unused translation keys from all 7 locale files - Add missing keys to it-IT (7) and ru-RU (5) with English fallback --- .github/workflows/test.yml | 59 ++++ frontend/src/lib/locales/en-US/index.ts | 241 +++------------ frontend/src/lib/locales/index.test.ts | 104 ++++--- frontend/src/lib/locales/it-IT/index.ts | 160 +--------- frontend/src/lib/locales/ja-JP/index.ts | 241 +++------------ frontend/src/lib/locales/pt-BR/index.ts | 155 +--------- frontend/src/lib/locales/ru-RU/index.ts | 157 +--------- frontend/src/lib/locales/zh-CN/index.ts | 371 +++++++---------------- frontend/src/lib/locales/zh-TW/index.ts | 373 +++++++----------------- 9 files changed, 444 insertions(+), 1417 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c6d1f23 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/frontend/src/lib/locales/en-US/index.ts b/frontend/src/lib/locales/en-US/index.ts index 0c3c36e..e02bc17 100644 --- a/frontend/src/lib/locales/en-US/index.ts +++ b/frontend/src/lib/locales/en-US/index.ts @@ -36,22 +36,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 +66,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 +78,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 +95,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 +103,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 +116,6 @@ export const enUS = { url: "URL", errorDetails: "Error Details", editTransformation: "Edit Transformation", - comingSoon: "Coming soon", retry: "Try Again", traditionalChinese: "繁體中文", portuguese: "Português", @@ -165,7 +153,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 +193,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 +212,6 @@ export const enUS = { nav: "Navigation", language: "Toggle language", theme: "Theme", - search: "Search", ask: "Ask", }, notebooks: { @@ -248,12 +233,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 +259,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 +299,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 +336,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 +377,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 +408,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 +416,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 +473,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 +697,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 +733,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,105 +779,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", @@ -936,16 +840,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", @@ -959,7 +856,6 @@ export const enUS = { changeModelOnly: "Change Model Only", changeAndRebuild: "Change & Go to Rebuild", autoAssign: "Auto-assign Defaults", - autoAssignDesc: "Automatically assign the best available model for each slot", autoAssigning: "Assigning...", autoAssignSuccess: "{count} default models automatically assigned", autoAssignNoModels: "No models available to assign. Please sync models first.", @@ -967,25 +863,16 @@ export const enUS = { testModel: "Test Model", testModelSuccess: "Model Test Passed", testModelFailed: "Model Test Failed", - testingModel: "Testing model...", searchOrAddModel: "Search or type a model name...", - addCustomModel: 'Add "{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.", - loadFailed: "Failed to load API keys status", 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", - sourceDatabase: "Database", - sourceEnvironment: "Environment", - enterApiKey: "Enter your API key", - enterBaseUrl: "Enter the base URL", - saveSuccess: "API key saved successfully", - deleteSuccess: "API key deleted successfully", - fromEnvironmentHint: "This key is set via environment variable. Save a new key to override it in the database.", 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", @@ -993,67 +880,29 @@ export const enUS = { migrationSuccess: "{count} API key(s) migrated successfully", migrationErrors: "{count} key(s) failed to migrate", migrationNothingToMigrate: "All keys are already in the database", - serviceType: "Service Type", - serviceLlm: "Language Model (LLM)", - serviceEmbedding: "Embedding", - serviceStt: "Speech to Text (STT)", - serviceTts: "Text to Speech (TTS)", - serviceEndpoints: "Service Endpoints (optional)", - azureEndpointsHint: "Configure different endpoints for each service type if needed.", - endpointPlaceholder: "https://your-resource.openai.azure.com/", - openaiCompatibleHint: "Configure an OpenAI-compatible API endpoint. Each service type can have its own configuration.", - baseUrlPlaceholder: "https://api.example.com/v1", learnMore: "Learn how to configure API keys →", testConnection: "Test Connection", - testing: "Testing...", testSuccess: "Connection successful", testFailed: "Connection test failed", syncModels: "Sync Models", - syncing: "Syncing...", syncSuccess: "Discovered {discovered} models, added {new} new", syncNoNew: "Discovered {count} models, all already registered", syncFailed: "Failed to sync models", - syncAllModels: "Sync All Providers", - syncAllSuccess: "Discovered {discovered} models across all providers, added {new} new", - modelsConfigured: "{count} models", - noModelsConfigured: "No models", - viewModels: "View Models", - supportedTypes: "Supported types", - typeLanguage: "Language", - typeEmbedding: "Embedding", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "API Endpoint", getApiKey: "Get API Key", vertexProject: "GCP Project ID", vertexLocation: "Region", vertexCredentials: "Service Account JSON Path", - vertexCredentialsHint: "Path to your Google Cloud service account JSON file inside the container.", - - // Multi-config translations - configsCount: "{count} configs", - configuredMultiple: "Configured", addConfig: "Add Configuration", editConfig: "Edit Configuration", deleteConfig: "Delete Configuration", - setAsDefault: "Set as Default", - defaultBadge: "Default", - defaultDescription: "Default configuration for this provider", configName: "Configuration Name", configNameHint: "A descriptive name for this configuration (e.g., 'Production', 'Development')", baseUrl: "Base URL", - baseUrlHint: "Default: {url}", baseUrlOverrideHint: "Only change this if you need to override the provider's default API endpoint.", - ollamaApiKeyHint: "Only required for Ollama Cloud. Leave empty for local Ollama.", - noConfigs: "No configurations yet", - noConfigsHint: "Add a configuration to start using this provider", deleteConfigConfirm: "Are you sure you want to delete '{name}'? This cannot be undone.", - setDefaultConfirm: "Set '{name}' as the default configuration?", configSaveSuccess: "Configuration saved successfully", configUpdateSuccess: "Configuration updated successfully", configDeleteSuccess: "Configuration deleted successfully", - configSetDefaultSuccess: "Default configuration updated", - apiKeyHint: "Enter your API key for this configuration", apiKeyEditHint: "Leave blank to keep the existing API key", }, setupBanner: { diff --git a/frontend/src/lib/locales/index.test.ts b/frontend/src/lib/locales/index.test.ts index 731710f..11d760d 100644 --- a/frontend/src/lib/locales/index.test.ts +++ b/frontend/src/lib/locales/index.test.ts @@ -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, 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, prefix + el + '.')] - } - return [...res, prefix + el] - }, []) - } +const getKeys = (obj: Record, 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, 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) - 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, + ) }) diff --git a/frontend/src/lib/locales/it-IT/index.ts b/frontend/src/lib/locales/it-IT/index.ts index beaa42f..4dfad9b 100644 --- a/frontend/src/lib/locales/it-IT/index.ts +++ b/frontend/src/lib/locales/it-IT/index.ts @@ -23,7 +23,7 @@ export const itIT = { english: "English", chinese: "简体中文", japanese: "日本語", - italian: "Italiano", + russian: "Русский", source: "Fonte", notebook: "Quaderno", podcast: "Podcast", @@ -36,22 +36,16 @@ export const itIT = { warning: "Attenzione", error: "Errore", success: "Successo", - sessions: "Sessioni", model: "Modello", - send: "Invia", back: "Indietro", next: "Avanti", done: "Fatto", processing: "Elaborazione...", creating: "Creazione...", - tokenCount: "Token", - charCount: "Caratteri", linked: "Collegato", - added: "Aggiunto il {date}", adding: "Aggiunta in corso...", addSelected: "Aggiungi selezionati", customModel: "Modello personalizzato", - messages: "Messaggi", failed: "fallito", current: "Corrente", save: "Salva", @@ -72,7 +66,6 @@ export const itIT = { unknown: "Sconosciuto", notes: "Note", chat: "Chat", - details: "Dettagli", deleteForever: "Elimina definitivamente", connectionError: "Errore di connessione", unableToConnect: "Impossibile connettersi al server API", @@ -85,7 +78,6 @@ export const itIT = { checkConsoleLogs: "Controlla la console del browser per log dettagliati (cerca i messaggi 🔧 [Config])", yes: "Sì", no: "No", - simple: "Semplice", saving: "Salvataggio...", description: "Descrizione", saveToNote: "Salva come nota", @@ -103,7 +95,6 @@ export const itIT = { nameRequired: "Il nome è obbligatorio", modelConfiguration: "Configurazione modello", resetToDefault: "Ripristina predefinito", - notFound: "Non trovato", reasoning: "Ragionamento", searchTerms: "Termini di ricerca", strategy: "Strategia", @@ -112,14 +103,12 @@ export const itIT = { notebookLabel: "Quaderno: {name}", itemNotFound: "Questo {type} non è stato trovato", accessibility: { - navigation: "Navigazione", transformationViews: "Viste trasformazioni", searchKB: "Chiedi o cerca nella tua base di conoscenza", enterQuestion: "Inserisci la tua domanda per interrogare la base di conoscenza", enterSearch: "Inserisci la query di ricerca", searchKBBtn: "Cerca nella base di conoscenza", podcastViews: "Viste podcast", - chatSessions: "Sessioni chat", ytVideo: "Video YouTube", askResponse: "Risposta alla domanda", searchNotebooks: "Cerca quaderni", @@ -127,7 +116,6 @@ export const itIT = { url: "URL", errorDetails: "Dettagli errore", editTransformation: "Modifica trasformazione", - comingSoon: "Prossimamente", retry: "Riprova", traditionalChinese: "繁體中文", portuguese: "Português", @@ -165,7 +153,6 @@ export const itIT = { failedToSendMessage: "Impossibile inviare il messaggio", unauthorized: "Accesso non autorizzato, controlla la password", invalidPassword: "Password non valida", - missingAuth: "Autenticazione mancante", embeddingModelRequired: "Questa funzionalità richiede un modello di embedding. Configurane uno nella sezione Modelli.", strategyModelNotFound: "Modello strategia non trovato", answerModelNotFound: "Modello risposta non trovato", @@ -206,7 +193,6 @@ export const itIT = { passwordPlaceholder: "Password", signingIn: "Accesso in corso...", signIn: "Accedi", - unhandledError: "Errore non gestito durante l'accesso", connectErrorHint: "Impossibile connettersi al server. Verifica che l'API sia in esecuzione.", }, navigation: { @@ -226,7 +212,6 @@ export const itIT = { nav: "Navigazione", language: "Cambia lingua", theme: "Tema", - search: "Cerca", ask: "Chiedi", }, notebooks: { @@ -248,12 +233,8 @@ export const itIT = { keepExclusiveSourcesLabel: "Scollega e mantieni", activeNotebooks: "Quaderni attivi", archivedNotebooks: "Quaderni archiviati", - emptyDescription: "Inizia creando il tuo primo quaderno per organizzare la tua ricerca.", - noActiveNotebooks: "Nessun quaderno attivo", - noArchivedNotebooks: "Nessun quaderno archiviato", notFound: "Quaderno non trovato", notFoundDesc: "Il quaderno richiesto non esiste.", - noDescription: "Nessuna descrizione...", updated: "Aggiornato", namePlaceholder: "Nome quaderno", addDescription: "Aggiungi descrizione...", @@ -278,10 +259,7 @@ export const itIT = { add: "Aggiungi fonte", addNew: "Aggiungi nuova fonte", addExisting: "Aggiungi fonte esistente", - empty: "Ancora nessuna fonte", - emptyDesc: "Aggiungi la tua prima fonte per iniziare a costruire la tua base di conoscenza.", delete: "Elimina Fonte", - deleteMsg: "Sei sicuro di voler eliminare questa fonte? Questa azione non può essere annullata.", statusPreparing: "In preparazione", statusQueued: "In coda", statusProcessing: "In elaborazione", @@ -321,7 +299,6 @@ export const itIT = { sourceRequeued: "Fonte rimessa in coda", sourceRequeuedDesc: "La fonte è stata rimessa in coda per l'elaborazione.", failedToRetry: "Nuovo tentativo fallito", - failedToRetryDesc: "Impossibile ritentare l'elaborazione della fonte. Riprova.", sourcesAddedToNotebook: "{count} fonte/i aggiunte al quaderno", failedToAddSourcesToNotebook: "Impossibile aggiungere le fonti al quaderno", partialAddSuccess: "{success} fonte/i aggiunte, {failed} fallite", @@ -358,7 +335,7 @@ export const itIT = { viewInsight: "Visualizza approfondimento", deleteInsight: "Elimina approfondimento", deleteInsightConfirm: "Sei sicuro di voler eliminare questo approfondimento? Questa azione non può essere annullata.", - deleteNoteConfirm: "Sei sicuro di voler eliminare questa nota? Questa azione non può essere annullata.", + insightGenerationStarted: "Generazione dell'approfondimento avviata. Apparirà a breve.", editNote: "Modifica nota", createNote: "Crea nota", addTitle: "Aggiungi un titolo...", @@ -366,9 +343,7 @@ export const itIT = { writeNotePlaceholder: "Scrivi il contenuto della tua nota qui...", saveNote: "Salva nota", createNoteBtn: "Crea nota", - noNotesYet: "Ancora nessuna nota", createFirstNote: "Crea la tua prima nota per catturare intuizioni e osservazioni.", - deleteNote: "Elimina nota", urlLabel: "URL *", fileLabel: "File *", textContentLabel: "Contenuto testo *", @@ -402,7 +377,6 @@ export const itIT = { retryProcessing: "Riprova elaborazione", deleteSource: "Elimina fonte", retry: "Riprova", - progress: "Progresso", addExistingTitle: "Aggiungi fonti esistenti", addExistingDesc: "Seleziona fonti esistenti da tutti i tuoi quaderni per aggiungerle a quello corrente.", searchPlaceholder: "Cerca fonti per nome o URL...", @@ -434,8 +408,6 @@ export const itIT = { batchFailed: "Impossibile creare tutte le {count} fonti", batchPartial: "{success} riuscite, {failed} fallite", submittingSource: "Invio fonte per l'elaborazione...", - contentRequired: "Fornisci il contenuto richiesto per il tipo di fonte selezionato", - titleRequiredForText: "Il titolo è obbligatorio per le fonti testuali", processingBatchSources: "Elaborazione di {count} fonti. Potrebbe richiedere qualche istante.", processingSource: "La tua fonte è in elaborazione. Potrebbe richiedere qualche istante.", maxFilesAllowed: "Massimo {count} file consentiti per batch", @@ -444,16 +416,10 @@ export const itIT = { sessions: "Sessioni", sessionTitlePlaceholder: "Digita un titolo qui...", noSessions: "Ancora nessuna sessione chat", - startChatting: "Inizia a chattare sulle tue fonti.", deleteSession: "Elimina sessione", deleteSessionDesc: "Sei sicuro di voler eliminare questa sessione chat? Questa azione non può essere annullata.", sendPlaceholder: "Chiedi qualsiasi cosa sulle tue fonti...", - newChat: "Nuova chat", sessionsTitle: "Sessioni chat", - clearhistory: "Cancella cronologia", - renameSession: "Rinomina sessione", - noSourcesLinked: "Nessuna fonte collegata", - thinking: "L'IA sta pensando...", chatWith: "Chatta con {name}", startConversation: "Inizia una conversazione su questo {type}", askQuestions: "Fai domande per capire meglio il contenuto", @@ -507,8 +473,6 @@ export const itIT = { saveSuccess: "Salvato con successo nel quaderno", saveError: "Impossibile salvare nel quaderno", selectNotebook: "Seleziona quaderno", - createNewNotebook: "Crea nuovo quaderno", - cancel: "Annulla", searchAndAsk: "Cerca e chiedi", searchResultsFor: "Risultati di ricerca per \"{query}\"", askAbout: "Chiedi riguardo \"{query}\"", @@ -733,8 +697,6 @@ export const itIT = { speakerCountMin: "È richiesto almeno uno speaker", speakerCountMax: "Puoi configurare fino a 4 speaker", delete: "Elimina", - unknown: "Sconosciuto", - deleteSuccess: "Podcast eliminato con successo", failedToDelete: "Impossibile eliminare il podcast", }, settings: { @@ -771,13 +733,8 @@ export const itIT = { title: "Strumenti avanzati", desc: "Strumenti e utilità avanzate per utenti esperti", systemInfo: "Informazioni sistema", - systemInfoDesc: "Visualizza lo stato dei componenti di sistema sottostanti", rebuildEmbeddings: "Ricostruisci indicizzazioni", rebuildEmbeddingsDesc: "Ricostruisci l'indice di ricerca vettoriale per tutte le fonti", - rebuildWarning: "Questa azione può richiedere molto tempo a seconda del numero di fonti. Cancellerà gli indici vettoriali esistenti e rigenererà le indicizzazioni per tutto.", - startRebuild: "Avvia ricostruzione", - rebuilding: "Ricostruzione...", - rebuildSuccess: "Ricostruzione indicizzazioni avviata con successo", currentVersion: "Versione corrente", latestVersion: "Ultima versione", status: "Stato", @@ -822,10 +779,8 @@ export const itIT = { defaultPrompt: "Prompt trasformazione predefinito", defaultPromptDesc: "Questo verrà aggiunto a tutti i tuoi prompt di trasformazione", defaultPromptPlaceholder: "Inserisci le tue istruzioni di trasformazione predefinite...", - saveDefault: "Salva predefinito", listTitle: "Trasformazioni personalizzate", createNew: "Crea Nuova", - testInPlayground: "Testa nel playground", inputLabel: "Testo di input", inputPlaceholder: "Inserisci del testo da trasformare...", outputLabel: "Output", @@ -844,83 +799,33 @@ export const itIT = { deleteSuccess: "Trasformazione eliminata con successo", noTransformations: "Ancora nessuna trasformazione", createOne: "Crea una trasformazione per iniziare", - deleteDesc: "L'eliminazione di questa trasformazione non può essere annullata.", selectModel: "Seleziona un modello", deleteConfirm: "Sei sicuro di voler eliminare questa trasformazione?", model: "Modello", systemPrompt: "Prompt di sistema", - type: "Tipo", - extraction: "Estrazione", - summary: "Riepilogo", - custom: "Personalizzato", - saveChanges: "Salva modifiche", overrideModelDesc: "Sovrascrivi il modello predefinito per questa sessione chat. Lascia vuoto per usare il default di sistema.", sessionUseReplacement: "Questa sessione userà {name} invece del modello predefinito.", systemDefault: "Predefinito di sistema", }, models: { - title: "Gestione modelli", - desc: "Configura i modelli IA per diversi scopi in Open Notebook", - failedToLoad: "Impossibile caricare i dati dei modelli", - language: "Modelli linguistici", embedding: "Modelli di embedding", tts: "Text to Speech (TTS)", stt: "Speech to Text (STT)", - providers: "Provider", - defaultModels: "Modelli predefiniti", - status: "Stato", - notConfigured: "Non configurato", - active: "Attivo", - inactive: "Inattivo", - configure: "Configura", - saveChanges: "Salva modifiche", - addModel: "Aggiungi modello", - modelName: "Nome modello", provider: "Provider", apiKey: "Chiave API", - baseUrl: "URL Base", - capabilities: "Capacità", - enabled: "Abilitato", - disabled: "Disabilitato", - deleteConfirm: "Sei sicuro di voler eliminare questo modello?", deleteSuccess: "Modello eliminato con successo", saveSuccess: "Modello salvato con successo", - providerStatus: "Stato provider", - connectionOk: "Connessione OK", - connectionFailed: "Connessione fallita", - changeEmbeddingWarning: "Cambiare il modello di embedding predefinito influenzerà le nuove fonti. Le fonti esistenti potrebbero dover essere re-indicizzate.", - changeEmbeddingTitle: "Cambiare il modello di embedding predefinito?", - aiProviders: "Provider IA", - providerConfigDesc: "Configura i provider tramite variabili d'ambiente per abilitare i loro modelli.", - configuredCount: "{count} di {total} configurati", noModels: "Nessun modello", - learnMore: "Scopri come configurare i provider →", - seeLess: "Mostra meno", - seeAll: "Mostra tutti i {count} provider", - language_models: "Modelli linguistici", - embedding_models: "Modelli di embedding", - text_to_speech: "Text to Speech (TTS)", - speech_to_text: "Speech to Text (STT)", - languageDesc: "Chat, trasformazioni e generazione testo", - embeddingDesc: "Ricerca semantica e embedding vettoriali", - ttsDesc: "Genera audio da testo", - sttDesc: "Trascrivi audio in testo", - all: "Tutti", - noModelsConfigured: "Nessun modello configurato", - noProviderModelsConfigured: "Nessun modello {provider} configurato", - showMore: "Mostra altri {count}", discoverModels: "Scopri Modelli", noModelsFound: "Nessun modello trovato per questo provider", modelType: "Tipo di Modello", modelTypeHint: "Seleziona il tipo per i modelli che vuoi aggiungere. Se hai bisogno di tipi diversi, aggiungili in lotti separati.", deleteModel: "Elimina modello", - deleteModelDesc: "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.", defaultAssignments: "Assegnazioni modelli predefiniti", defaultAssignmentsDesc: "Configura quali modelli usare per diversi scopi in Open Notebook", missingRequiredModels: "Modelli richiesti mancanti: {models}. Open Notebook potrebbe non funzionare correttamente senza questi.", selectModelPlaceholder: "Seleziona un modello", requiredModelPlaceholder: "⚠️ Richiesto - Seleziona un modello", - whichModelToChoose: "Quale modello dovrei scegliere? →", chatModelLabel: "Modello chat", chatModelDesc: "Usato per le conversazioni chat", transformationModelLabel: "Modello trasformazione", @@ -935,16 +840,9 @@ export const itIT = { ttsModelDesc: "Usato per la generazione podcast", sttModelLabel: "Modello Speech-to-Text", sttModelDesc: "Usato per la trascrizione audio", - addSpecificModel: "Aggiungi modello {type}", - addSpecificModelDesc: "Configura un nuovo modello {type} dai provider disponibili.", - noProvidersForType: "Nessun provider disponibile per modelli {type}", selectProviderPlaceholder: "Seleziona un provider", providerRequired: "Il provider è obbligatorio", - modelNameRequired: "Il nome del modello è obbligatorio", modelRequired: "Il modello è obbligatorio", - adding: "Aggiunta...", - azureHint: "Per Azure, usa il nome del deployment come nome del modello", - enterModelName: "Inserisci nome modello", embeddingChangeTitle: "Cambio modello di embedding", embeddingChangeConfirm: "Stai per cambiare il modello di embedding da {from} a {to}.", rebuildRequired: "Importante: ricostruzione richiesta", @@ -957,28 +855,24 @@ export const itIT = { proceedToRebuildPrompt: "Vuoi procedere alla pagina avanzate per avviare la ricostruzione ora?", changeModelOnly: "Cambia solo modello", changeAndRebuild: "Cambia e vai a ricostruzione", + autoAssign: "Assegnazione automatica predefiniti", + autoAssigning: "Assegnazione in corso...", + autoAssignSuccess: "{count} modelli predefiniti assegnati automaticamente", + autoAssignNoModels: "Nessun modello disponibile da assegnare. Sincronizza prima i modelli.", + autoAssignAlreadySet: "Tutti i modelli predefiniti sono già configurati", testModel: "Testa Modello", testModelSuccess: "Test del Modello Superato", testModelFailed: "Test del Modello Fallito", - testingModel: "Test del modello in corso...", searchOrAddModel: "Cerca o digita un nome modello...", - addCustomModel: 'Aggiungi "{name}"', + addCustomModel: "Aggiungi \"{name}\"", }, apiKeys: { title: "Configura la tua IA con le tue chiavi API", description: "Salva le chiavi API in modo sicuro nel database per abilitare i provider IA in Open Notebook.", - loadFailed: "Impossibile caricare lo stato delle chiavi API", encryptionRequired: "Chiave di crittografia non configurata", encryptionRequiredDescription: "Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY su una stringa segreta qualsiasi per abilitare il salvataggio delle chiavi API nel database.", configured: "Configurato", notConfigured: "Non configurato", - sourceDatabase: "Database", - sourceEnvironment: "Variabile d'ambiente", - enterApiKey: "Inserisci la tua chiave API", - enterBaseUrl: "Inserisci l'URL base", - saveSuccess: "Chiave API salvata con successo", - deleteSuccess: "Chiave API eliminata con successo", - fromEnvironmentHint: "Questa chiave è impostata tramite variabile d'ambiente. Salva una nuova chiave per sovrascriverla nel database.", migrationAvailable: "Variabili d'ambiente rilevate", migrationDescription: "{count} chiave/i API configurata/e tramite variabili d'ambiente. Puoi migrarle nel database per una gestione più semplice.", migrateToDatabase: "Migra nel database", @@ -986,67 +880,29 @@ export const itIT = { migrationSuccess: "{count} chiave/i API migrata/e con successo", migrationErrors: "{count} chiave/i non migrata/e", migrationNothingToMigrate: "Tutte le chiavi sono già nel database", - serviceType: "Tipo di servizio", - serviceLlm: "Modello linguistico (LLM)", - serviceEmbedding: "Embedding", - serviceStt: "Riconoscimento vocale (STT)", - serviceTts: "Sintesi vocale (TTS)", - serviceEndpoints: "Endpoint dei servizi (opzionale)", - azureEndpointsHint: "Se necessario, configura endpoint diversi per ogni tipo di servizio.", - endpointPlaceholder: "https://your-resource.openai.azure.com/", - openaiCompatibleHint: "Configura un endpoint API compatibile con OpenAI. Ogni tipo di servizio può avere la propria configurazione.", - baseUrlPlaceholder: "https://api.example.com/v1", learnMore: "Scopri come configurare le chiavi API →", testConnection: "Testa connessione", - testing: "Test in corso...", testSuccess: "Connessione riuscita", testFailed: "Test di connessione fallito", syncModels: "Sincronizza modelli", - syncing: "Sincronizzazione...", syncSuccess: "Trovati {discovered} modelli, aggiunti {new} nuovi", syncNoNew: "Trovati {count} modelli, tutti già registrati", syncFailed: "Sincronizzazione modelli fallita", - syncAllModels: "Sincronizza tutti i provider", - syncAllSuccess: "Trovati {discovered} modelli da tutti i provider, aggiunti {new} nuovi", - modelsConfigured: "{count} modelli", - noModelsConfigured: "Nessun modello", - viewModels: "Visualizza modelli", - supportedTypes: "Tipi supportati", - typeLanguage: "Linguistico", - typeEmbedding: "Embedding", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "Endpoint API", getApiKey: "Ottieni chiave API", vertexProject: "ID progetto GCP", vertexLocation: "Regione", vertexCredentials: "Percorso JSON account di servizio", - vertexCredentialsHint: "Percorso del file JSON dell'account di servizio Google Cloud all'interno del container.", - - // Traduzioni multi-configurazione - configsCount: "{count} configurazioni", - configuredMultiple: "Configurato", addConfig: "Aggiungi configurazione", editConfig: "Modifica configurazione", deleteConfig: "Elimina configurazione", - setAsDefault: "Imposta come predefinito", - defaultBadge: "Predefinito", - defaultDescription: "Configurazione predefinita per questo provider", configName: "Nome configurazione", configNameHint: "Un nome descrittivo per questa configurazione (es. 'Produzione', 'Sviluppo')", baseUrl: "URL base", - baseUrlHint: "Predefinito: {url}", baseUrlOverrideHint: "Modifica solo se devi sovrascrivere l'endpoint API predefinito del provider.", - ollamaApiKeyHint: "Necessaria solo per Ollama Cloud. Lascia vuoto per Ollama locale.", - noConfigs: "Nessuna configurazione presente", - noConfigsHint: "Aggiungi una configurazione per iniziare a usare questo provider", deleteConfigConfirm: "Sei sicuro di voler eliminare '{name}'? Questa azione non può essere annullata.", - setDefaultConfirm: "Impostare '{name}' come configurazione predefinita?", configSaveSuccess: "Configurazione salvata con successo", configUpdateSuccess: "Configurazione aggiornata con successo", configDeleteSuccess: "Configurazione eliminata con successo", - configSetDefaultSuccess: "Configurazione predefinita aggiornata", - apiKeyHint: "Inserisci la chiave API per questa configurazione", apiKeyEditHint: "Lascia vuoto per mantenere la chiave API esistente", }, setupBanner: { diff --git a/frontend/src/lib/locales/ja-JP/index.ts b/frontend/src/lib/locales/ja-JP/index.ts index 90aee8b..daf6cdf 100644 --- a/frontend/src/lib/locales/ja-JP/index.ts +++ b/frontend/src/lib/locales/ja-JP/index.ts @@ -36,22 +36,16 @@ export const jaJP = { warning: "警告", error: "エラー", success: "成功", - sessions: "セッション", model: "モデル", - send: "送信", back: "戻る", next: "次へ", done: "完了", processing: "処理中...", creating: "作成中...", - tokenCount: "トークン数", - charCount: "文字数", linked: "リンク済み", - added: "{date}に追加", adding: "追加中...", addSelected: "選択項目を追加", customModel: "カスタムモデル", - messages: "メッセージ", failed: "失敗", current: "現在", save: "保存", @@ -72,7 +66,6 @@ export const jaJP = { unknown: "不明", notes: "ノート", chat: "チャット", - details: "詳細", deleteForever: "完全に削除", connectionError: "接続エラー", unableToConnect: "APIサーバーに接続できません", @@ -85,7 +78,6 @@ export const jaJP = { checkConsoleLogs: "ブラウザコンソールで詳細ログを確認してください(🔧 [Config] メッセージを探してください)", yes: "はい", no: "いいえ", - simple: "シンプル", saving: "保存中...", description: "説明", saveToNote: "ノートに保存", @@ -103,7 +95,6 @@ export const jaJP = { nameRequired: "名前は必須です", modelConfiguration: "モデル設定", resetToDefault: "デフォルトに戻す", - notFound: "見つかりません", reasoning: "推論", searchTerms: "検索ワード", strategy: "戦略", @@ -112,14 +103,12 @@ export const jaJP = { notebookLabel: "ノートブック: {name}", itemNotFound: "この{type}は見つかりませんでした", accessibility: { - navigation: "ナビゲーション", transformationViews: "トランスフォーメーション表示", searchKB: "ナレッジベースに質問・検索", enterQuestion: "ナレッジベースへの質問を入力", enterSearch: "検索クエリを入力", searchKBBtn: "ナレッジベースを検索", podcastViews: "ポッドキャスト表示", - chatSessions: "チャットセッション", ytVideo: "YouTube動画", askResponse: "質問への回答", searchNotebooks: "ノートブックを検索", @@ -127,7 +116,6 @@ export const jaJP = { url: "URL", errorDetails: "エラー詳細", editTransformation: "トランスフォーメーションを編集", - comingSoon: "近日公開", retry: "再試行", traditionalChinese: "繁體中文", portuguese: "Português", @@ -165,7 +153,6 @@ export const jaJP = { failedToSendMessage: "メッセージの送信に失敗しました", unauthorized: "認証エラー。パスワードを確認してください", invalidPassword: "パスワードが無効です", - missingAuth: "認証情報がありません", embeddingModelRequired: "この機能にはEmbeddingモデルが必要です。モデルセクションで設定してください。", strategyModelNotFound: "戦略モデルが見つかりません", answerModelNotFound: "回答モデルが見つかりません", @@ -206,7 +193,6 @@ export const jaJP = { passwordPlaceholder: "パスワード", signingIn: "サインイン中...", signIn: "サインイン", - unhandledError: "ログイン中に予期しないエラーが発生しました", connectErrorHint: "サーバーに接続できません。APIが起動しているか確認してください。", }, navigation: { @@ -226,7 +212,6 @@ export const jaJP = { nav: "ナビゲーション", language: "言語を切り替え", theme: "テーマ", - search: "検索", ask: "質問", }, notebooks: { @@ -248,12 +233,8 @@ export const jaJP = { keepExclusiveSourcesLabel: "リンク解除して保持", activeNotebooks: "アクティブなノートブック", archivedNotebooks: "アーカイブ済みノートブック", - emptyDescription: "最初のノートブックを作成してリサーチを整理しましょう。", - noActiveNotebooks: "アクティブなノートブックがありません", - noArchivedNotebooks: "アーカイブ済みノートブックがありません", notFound: "ノートブックが見つかりません", notFoundDesc: "指定されたノートブックは存在しません。", - noDescription: "説明なし...", updated: "更新日時", namePlaceholder: "ノートブック名", addDescription: "説明を追加...", @@ -278,10 +259,7 @@ export const jaJP = { add: "ソースを追加", addNew: "新規ソースを追加", addExisting: "既存ソースを追加", - empty: "ソースがまだありません", - emptyDesc: "最初のソースを追加してナレッジベースの構築を始めましょう。", delete: "ソースを削除", - deleteMsg: "このソースを削除しますか?この操作は元に戻せません。", statusPreparing: "準備中", statusQueued: "キュー待ち", statusProcessing: "処理中", @@ -321,7 +299,6 @@ export const jaJP = { sourceRequeued: "ソースの再処理をキューに追加", sourceRequeuedDesc: "ソースを再処理キューに追加しました。", failedToRetry: "再試行に失敗", - failedToRetryDesc: "ソースの再処理に失敗しました。もう一度お試しください。", sourcesAddedToNotebook: "{count}件のソースをノートブックに追加しました", failedToAddSourcesToNotebook: "ノートブックへのソース追加に失敗しました", partialAddSuccess: "{success}件追加成功、{failed}件失敗", @@ -359,34 +336,31 @@ export const jaJP = { deleteInsight: "インサイトを削除", deleteInsightConfirm: "このインサイトを削除しますか?この操作は元に戻せません。", insightGenerationStarted: "インサイトの生成が開始されました。まもなく表示されます。", - deleteNoteConfirm: 'このノートを削除しますか?この操作は元に戻せません。', - editNote: 'ノートを編集', - createNote: 'ノートを作成', - addTitle: 'タイトルを追加...', - untitledNote: '無題のノート', - writeNotePlaceholder: 'ノートの内容をここに入力...', - saveNote: 'ノートを保存', - createNoteBtn: 'ノートを作成', - noNotesYet: "ノートがまだありません", + editNote: "ノートを編集", + createNote: "ノートを作成", + addTitle: "タイトルを追加...", + untitledNote: "無題のノート", + writeNotePlaceholder: "ノートの内容をここに入力...", + saveNote: "ノートを保存", + createNoteBtn: "ノートを作成", createFirstNote: "最初のノートを作成してインサイトや気づきを記録しましょう。", - deleteNote: "ノートを削除", - urlLabel: 'URL *', - fileLabel: 'ファイル *', - textContentLabel: 'テキストコンテンツ *', - enterUrlsPlaceholder: 'URLを1行ずつ入力\nhttps://example.com/article1\nhttps://example.com/article2', - batchUrlHint: '複数のURLを貼り付けて一括インポート(1行に1つ)', - invalidUrlsDetected: '無効なURLが検出されました:', - lineLabel: '{line}行目', - fixInvalidUrls: '無効なURLを修正または削除してください', - selectMultipleFilesHint: '複数ファイルを選択して一括インポート。対応形式:ドキュメント(PDF、DOC、DOCX、PPT、XLS、EPUB、TXT、MD)、メディア(MP4、MP3、WAV、M4A)、画像(JPG、PNG)、アーカイブ(ZIP)', - selectedFiles: '選択されたファイル:', - textPlaceholder: 'コンテンツを貼り付けまたは入力...', - htmlDetected: 'HTMLコンテンツが検出されました。処理後にMarkdownに変換されます。', - titlePlaceholder: 'ソースにわかりやすいタイトルを付けてください', - batchTitlesAuto: 'タイトルは各ソースごとに自動生成されます。', - batchCommonSettings: '同じノートブックとトランスフォーメーションがすべてのアイテムに適用されます。', - urlsCount: '{count}件のURL', - filesCount: '{count}件のファイル', + urlLabel: "URL *", + fileLabel: "ファイル *", + textContentLabel: "テキストコンテンツ *", + enterUrlsPlaceholder: "URLを1行ずつ入力\nhttps://example.com/article1\nhttps://example.com/article2", + batchUrlHint: "複数のURLを貼り付けて一括インポート(1行に1つ)", + invalidUrlsDetected: "無効なURLが検出されました:", + lineLabel: "{line}行目", + fixInvalidUrls: "無効なURLを修正または削除してください", + selectMultipleFilesHint: "複数ファイルを選択して一括インポート。対応形式:ドキュメント(PDF、DOC、DOCX、PPT、XLS、EPUB、TXT、MD)、メディア(MP4、MP3、WAV、M4A)、画像(JPG、PNG)、アーカイブ(ZIP)", + selectedFiles: "選択されたファイル:", + textPlaceholder: "コンテンツを貼り付けまたは入力...", + htmlDetected: "HTMLコンテンツが検出されました。処理後にMarkdownに変換されます。", + titlePlaceholder: "ソースにわかりやすいタイトルを付けてください", + batchTitlesAuto: "タイトルは各ソースごとに自動生成されます。", + batchCommonSettings: "同じノートブックとトランスフォーメーションがすべてのアイテムに適用されます。", + urlsCount: "{count}件のURL", + filesCount: "{count}件のファイル", addSource: "ソースを追加", notEmbeddedAlert: "コンテンツが未Embedding", notEmbeddedDesc: "このコンテンツはベクトル検索用にEmbeddingされていません。Embeddingを行うと高度な検索機能やコンテンツの発見性が向上します。", @@ -403,7 +377,6 @@ export const jaJP = { retryProcessing: "処理を再試行", deleteSource: "ソースを削除", retry: "再試行", - progress: "進捗", addExistingTitle: "既存ソースを追加", addExistingDesc: "すべてのノートブックから既存のソースを選択して現在のノートブックに追加します。", searchPlaceholder: "名前またはURLでソースを検索...", @@ -435,8 +408,6 @@ export const jaJP = { batchFailed: "{count}件すべてのソース作成に失敗しました", batchPartial: "{success}件成功、{failed}件失敗", submittingSource: "ソースを処理に送信中...", - contentRequired: "選択したソースタイプに必要なコンテンツを入力してください", - titleRequiredForText: "テキストソースにはタイトルが必要です", processingBatchSources: "{count}件のソースを処理中。しばらくお待ちください。", processingSource: "ソースを処理中です。しばらくお待ちください。", maxFilesAllowed: "一括処理は最大{count}件までです", @@ -445,26 +416,20 @@ export const jaJP = { sessions: "セッション", sessionTitlePlaceholder: "タイトルを入力...", noSessions: "チャットセッションがまだありません", - startChatting: "ソースについてチャットを始めましょう。", deleteSession: "セッションを削除", deleteSessionDesc: "このチャットセッションを削除しますか?この操作は元に戻せません。", sendPlaceholder: "ソースについて何でも質問してください...", - newChat: "新規チャット", sessionsTitle: "チャットセッション", - clearhistory: "履歴をクリア", - renameSession: "セッション名を変更", - noSourcesLinked: "リンクされたソースがありません", - thinking: "AIが考え中...", chatWith: "{name}とチャット", startConversation: "この{type}について会話を始めましょう", askQuestions: "コンテンツをより深く理解するために質問してください", pressToSend: "{key}を押して送信", model: "モデル", - createToStart: 'セッションを作成して開始', - chatWithNotebook: 'ノートブックとチャット', - unableToLoadChat: 'チャットを読み込めません', - noDescription: '説明なし', - startByCreating: '最初のノートブックを作成してリサーチを整理しましょう。', + createToStart: "セッションを作成して開始", + chatWithNotebook: "ノートブックとチャット", + unableToLoadChat: "チャットを読み込めません", + noDescription: "説明なし", + startByCreating: "最初のノートブックを作成してリサーチを整理しましょう。", messagesCount: "{count}件のメッセージ", sessionCreated: "チャットセッションを作成しました", sessionUpdated: "セッションを更新しました", @@ -508,8 +473,6 @@ export const jaJP = { saveSuccess: "ノートブックに保存しました", saveError: "ノートブックへの保存に失敗しました", selectNotebook: "ノートブックを選択", - createNewNotebook: "新規ノートブックを作成", - cancel: "キャンセル", searchAndAsk: "検索と質問", searchResultsFor: "「{query}」の検索結果", askAbout: "「{query}」について質問", @@ -734,8 +697,6 @@ export const jaJP = { speakerCountMin: "最低1人のスピーカーが必要です", speakerCountMax: "最大4人まで設定できます", delete: "削除", - unknown: "不明", - deleteSuccess: "ポッドキャストを削除しました", failedToDelete: "ポッドキャストの削除に失敗しました", }, settings: { @@ -772,13 +733,8 @@ export const jaJP = { title: "詳細ツール", desc: "パワーユーザー向けの詳細ツールとユーティリティ", systemInfo: "システム情報", - systemInfoDesc: "基盤システムコンポーネントのステータスを表示", rebuildEmbeddings: "Embeddingを再構築", rebuildEmbeddingsDesc: "すべてのソースのベクトル検索インデックスを再構築", - rebuildWarning: "このアクションはソース数によっては非常に時間がかかる場合があります。既存のベクトルインデックスをクリアし、すべてのEmbeddingを再生成します。", - startRebuild: "再構築を開始", - rebuilding: "再構築中...", - rebuildSuccess: "Embedding再構築を開始しました", currentVersion: "現在のバージョン", latestVersion: "最新バージョン", status: "ステータス", @@ -823,105 +779,53 @@ export const jaJP = { defaultPrompt: "デフォルトトランスフォーメーションプロンプト", defaultPromptDesc: "これはすべてのトランスフォーメーションプロンプトに追加されます", defaultPromptPlaceholder: "デフォルトのトランスフォーメーション指示を入力...", - saveDefault: "デフォルトを保存", listTitle: "カスタムトランスフォーメーション", createNew: "新規作成", - testInPlayground: "プレイグラウンドでテスト", inputLabel: "入力テキスト", - inputPlaceholder: '変換するテキストを入力...', - outputLabel: '出力', - runTest: 'トランスフォーメーションを実行', - running: '実行中...', - selectToStart: 'トランスフォーメーションを選択して開始', - name: '名前', - namePlaceholder: '一意の識別子、例: key_topics', - titlePlaceholder: '表示タイトル、空欄の場合は名前を使用', - promptPlaceholder: 'このトランスフォーメーションを実行するプロンプトを書いてください...', - descriptionPlaceholder: 'このトランスフォーメーションの機能を説明してください。', - suggestDefault: '新しいソースでデフォルトで提案', - promptHint: 'プロンプトはソースコンテンツを念頭に置いて書いてください。モデルに要約、インサイトの抽出、テーブルなどの構造化出力の生成を依頼できます。', - createSuccess: 'トランスフォーメーションを作成しました', - updateSuccess: 'トランスフォーメーションを更新しました', - deleteSuccess: 'トランスフォーメーションを削除しました', + inputPlaceholder: "変換するテキストを入力...", + outputLabel: "出力", + runTest: "トランスフォーメーションを実行", + running: "実行中...", + selectToStart: "トランスフォーメーションを選択して開始", + name: "名前", + namePlaceholder: "一意の識別子、例: key_topics", + titlePlaceholder: "表示タイトル、空欄の場合は名前を使用", + promptPlaceholder: "このトランスフォーメーションを実行するプロンプトを書いてください...", + descriptionPlaceholder: "このトランスフォーメーションの機能を説明してください。", + suggestDefault: "新しいソースでデフォルトで提案", + promptHint: "プロンプトはソースコンテンツを念頭に置いて書いてください。モデルに要約、インサイトの抽出、テーブルなどの構造化出力の生成を依頼できます。", + createSuccess: "トランスフォーメーションを作成しました", + updateSuccess: "トランスフォーメーションを更新しました", + deleteSuccess: "トランスフォーメーションを削除しました", noTransformations: "トランスフォーメーションがまだありません", createOne: "開始するにはトランスフォーメーションを作成してください", - deleteDesc: "このトランスフォーメーションを削除すると元に戻せません。", selectModel: "モデルを選択", deleteConfirm: "このトランスフォーメーションを削除しますか?", model: "モデル", systemPrompt: "システムプロンプト", - type: "タイプ", - extraction: "抽出", - summary: "要約", - custom: "カスタム", - saveChanges: "変更を保存", overrideModelDesc: "このチャットセッションのデフォルトモデルを上書きします。空欄の場合はシステムデフォルトを使用します。", sessionUseReplacement: "このセッションはデフォルトモデルの代わりに{name}を使用します。", systemDefault: "システムデフォルト", }, models: { - title: "モデル管理", - desc: "Open Notebook全体で異なる目的に使用するAIモデルを設定", - failedToLoad: "モデルデータの読み込みに失敗しました", - language: "言語モデル", embedding: "Embeddingモデル", tts: "音声合成(TTS)", stt: "音声認識(STT)", - providers: "プロバイダー", - defaultModels: "デフォルトモデル", - status: "ステータス", - notConfigured: "未設定", - active: "アクティブ", - inactive: "非アクティブ", - configure: "設定", - saveChanges: "変更を保存", - addModel: "モデルを追加", - modelName: "モデル名", provider: "プロバイダー", apiKey: "APIキー", - baseUrl: "ベースURL", - capabilities: "機能", - enabled: "有効", - disabled: "無効", - deleteConfirm: "このモデルを削除しますか?", deleteSuccess: "モデルを削除しました", saveSuccess: "モデルを保存しました", - providerStatus: "プロバイダーステータス", - connectionOk: "接続OK", - connectionFailed: "接続失敗", - changeEmbeddingWarning: "デフォルトのEmbeddingモデルを変更すると新しいソースに影響します。既存のソースは再インデックスが必要になる場合があります。", - changeEmbeddingTitle: "デフォルトEmbeddingモデルを変更しますか?", - aiProviders: "AIプロバイダー", - providerConfigDesc: "環境変数を通じてプロバイダーを設定し、モデルを有効にしてください。", - configuredCount: "{total}件中{count}件設定済み", noModels: "モデルなし", - learnMore: "プロバイダーの設定方法を見る →", - seeLess: "折りたたむ", - seeAll: "{count}件すべてのプロバイダーを表示", - language_models: "言語モデル", - embedding_models: "Embeddingモデル", - text_to_speech: "音声合成(TTS)", - speech_to_text: "音声認識(STT)", - languageDesc: "チャット、トランスフォーメーション、テキスト生成", - embeddingDesc: "セマンティック検索とベクトルEmbedding", - ttsDesc: "テキストから音声を生成", - sttDesc: "音声をテキストに書き起こし", - all: "すべて", - noModelsConfigured: "モデルが設定されていません", - noProviderModelsConfigured: "{provider}モデルが設定されていません", - showMore: "さらに{count}件表示", discoverModels: "モデルを検出", noModelsFound: "このプロバイダーからモデルが見つかりません", modelType: "モデルタイプ", modelTypeHint: "追加するモデルのタイプを選択してください。異なるタイプが必要な場合は、別々のバッチで追加してください。", deleteModel: "モデルを削除", - deleteModelDesc: "「{name}」を削除しますか?この操作は元に戻せません。", defaultAssignments: "デフォルトモデル割り当て", defaultAssignmentsDesc: "Open Notebook全体で異なる目的に使用するモデルを設定", missingRequiredModels: "必須モデルがありません: {models}。これらがないとOpen Notebookが正しく機能しない可能性があります。", selectModelPlaceholder: "モデルを選択", requiredModelPlaceholder: "⚠️ 必須 - モデルを選択", - whichModelToChoose: "どのモデルを選ぶべき? →", chatModelLabel: "チャットモデル", chatModelDesc: "チャット会話に使用", transformationModelLabel: "トランスフォーメーションモデル", @@ -936,16 +840,9 @@ export const jaJP = { ttsModelDesc: "ポッドキャスト生成に使用", sttModelLabel: "音声認識モデル", sttModelDesc: "音声の書き起こしに使用", - addSpecificModel: "{type}を追加", - addSpecificModelDesc: "利用可能なプロバイダーから新しい{type}を設定します。", - noProvidersForType: "{type}用のプロバイダーがありません", selectProviderPlaceholder: "プロバイダーを選択", providerRequired: "プロバイダーは必須です", - modelNameRequired: "モデル名は必須です", modelRequired: "モデルは必須です", - adding: "追加中...", - azureHint: "Azureの場合、デプロイメント名をモデル名として使用してください", - enterModelName: "モデル名を入力", embeddingChangeTitle: "Embeddingモデルの変更", embeddingChangeConfirm: "Embeddingモデルを{from}から{to}に変更しようとしています。", rebuildRequired: "重要:再構築が必要", @@ -959,7 +856,6 @@ export const jaJP = { changeModelOnly: "モデルのみ変更", changeAndRebuild: "変更して再構築へ", autoAssign: "デフォルトを自動割り当て", - autoAssignDesc: "各スロットに最適なモデルを自動的に割り当てます", autoAssigning: "割り当て中...", autoAssignSuccess: "{count}件のデフォルトモデルを自動的に割り当てました", autoAssignNoModels: "割り当て可能なモデルがありません。先にモデルを同期してください。", @@ -967,25 +863,16 @@ export const jaJP = { testModel: "モデルをテスト", testModelSuccess: "モデルテスト成功", testModelFailed: "モデルテスト失敗", - testingModel: "モデルをテスト中...", searchOrAddModel: "検索またはモデル名を入力...", - addCustomModel: '"{name}" を追加', + addCustomModel: "\"{name}\" を追加", }, apiKeys: { title: "独自のAPIキーでAIを設定", description: "APIキーをデータベースに安全に保存し、Open NotebookでAIプロバイダーを有効にします。", - loadFailed: "APIキーのステータスの読み込みに失敗しました", encryptionRequired: "暗号化キーが設定されていません", encryptionRequiredDescription: "OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数に任意の秘密文字列を設定して、データベースへのAPIキーの保存を有効にしてください。", configured: "設定済み", notConfigured: "未設定", - sourceDatabase: "データベース", - sourceEnvironment: "環境変数", - enterApiKey: "APIキーを入力してください", - enterBaseUrl: "ベースURLを入力してください", - saveSuccess: "APIキーを保存しました", - deleteSuccess: "APIキーを削除しました", - fromEnvironmentHint: "このキーは環境変数で設定されています。新しいキーを保存するとデータベースで上書きされます。", migrationAvailable: "環境変数を検出", migrationDescription: "{count}個のAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行できます。", migrateToDatabase: "データベースに移行", @@ -993,67 +880,29 @@ export const jaJP = { migrationSuccess: "{count}個のAPIキーを移行しました", migrationErrors: "{count}個のキーの移行に失敗しました", migrationNothingToMigrate: "すべてのキーはすでにデータベースにあります", - serviceType: "サービスタイプ", - serviceLlm: "言語モデル(LLM)", - serviceEmbedding: "Embedding", - serviceStt: "音声認識(STT)", - serviceTts: "音声合成(TTS)", - serviceEndpoints: "サービスエンドポイント(任意)", - azureEndpointsHint: "必要に応じて、各サービスタイプに異なるエンドポイントを設定します。", - endpointPlaceholder: "https://your-resource.openai.azure.com/", - openaiCompatibleHint: "OpenAI互換のAPIエンドポイントを設定します。各サービスタイプに独自の設定が可能です。", - baseUrlPlaceholder: "https://api.example.com/v1", learnMore: "APIキーの設定方法を確認 →", testConnection: "接続テスト", - testing: "テスト中...", testSuccess: "接続成功", testFailed: "接続テストに失敗", syncModels: "モデル同期", - syncing: "同期中...", syncSuccess: "{discovered} モデルを発見、{new} 個を新規追加", syncNoNew: "{count} モデルを発見、すべて登録済み", syncFailed: "モデルの同期に失敗", - syncAllModels: "全プロバイダーを同期", - syncAllSuccess: "全プロバイダーで {discovered} モデルを発見、{new} 個を新規追加", - modelsConfigured: "{count} モデル", - noModelsConfigured: "モデルなし", - viewModels: "モデルを表示", - supportedTypes: "対応タイプ", - typeLanguage: "言語", - typeEmbedding: "埋め込み", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "APIエンドポイント", getApiKey: "APIキーを取得", vertexProject: "GCPプロジェクトID", vertexLocation: "リージョン", vertexCredentials: "サービスアカウントJSONパス", - vertexCredentialsHint: "コンテナ内のGoogle Cloudサービスアカウント JSON ファイルへのパス。", - - // Multi-config translations - configsCount: "{count} 設定", - configuredMultiple: "設定済み", addConfig: "設定を追加", editConfig: "設定を編集", deleteConfig: "設定を削除", - setAsDefault: "デフォルトに設定", - defaultBadge: "デフォルト", - defaultDescription: "このプロバイダーのデフォルト設定", configName: "設定名", configNameHint: "この設定の説明的な名前(例:本番環境、開発環境)", baseUrl: "ベースURL", - baseUrlHint: "デフォルト:{url}", baseUrlOverrideHint: "プロバイダーのデフォルト API エンドポイントを上書きする場合のみ変更してください。", - ollamaApiKeyHint: "Ollama Cloud でのみ必要です。ローカル Ollama の場合は空のままにしてください。", - noConfigs: "設定がありません", - noConfigsHint: "このプロバイダーの使用を開始するには設定を追加してください", deleteConfigConfirm: "「{name}」を削除してもよろしいですか?この操作は元に戻せません。", - setDefaultConfirm: "「{name}」をデフォルト設定にしますか?", configSaveSuccess: "設定が正常に保存されました", configUpdateSuccess: "設定が正常に変更されました", configDeleteSuccess: "設定が正常に削除されました", - configSetDefaultSuccess: "デフォルト設定が更新されました", - apiKeyHint: "この設定のAPIキーを入力してください", apiKeyEditHint: "既存のAPIキーを維持するには空白のままにしてください", }, setupBanner: { diff --git a/frontend/src/lib/locales/pt-BR/index.ts b/frontend/src/lib/locales/pt-BR/index.ts index 6b700cc..1b657b8 100644 --- a/frontend/src/lib/locales/pt-BR/index.ts +++ b/frontend/src/lib/locales/pt-BR/index.ts @@ -36,22 +36,16 @@ export const ptBR = { warning: "Aviso", error: "Erro", success: "Sucesso", - sessions: "Sessões", model: "Modelo", - send: "Enviar", back: "Voltar", next: "Próximo", done: "Concluído", processing: "Processando...", creating: "Criando...", - tokenCount: "Tokens", - charCount: "Caracteres", linked: "Vinculado", - added: "Adicionado em {date}", adding: "Adicionando...", addSelected: "Adicionar Selecionados", customModel: "Modelo Personalizado", - messages: "Mensagens", failed: "falhou", current: "Atual", save: "Salvar", @@ -72,7 +66,6 @@ export const ptBR = { unknown: "Desconhecido", notes: "Notas", chat: "Chat", - details: "Detalhes", deleteForever: "Excluir Permanentemente", connectionError: "Erro de Conexão", unableToConnect: "Não foi possível conectar ao servidor da API", @@ -85,7 +78,6 @@ export const ptBR = { checkConsoleLogs: "Verifique o console do navegador para logs detalhados (procure por mensagens 🔧 [Config])", yes: "Sim", no: "Não", - simple: "Simples", saving: "Salvando...", description: "Descrição", saveToNote: "Salvar em nota", @@ -103,7 +95,6 @@ export const ptBR = { nameRequired: "Nome é obrigatório", modelConfiguration: "Configuração do Modelo", resetToDefault: "Restaurar Padrão", - notFound: "Não encontrado", reasoning: "Raciocínio", searchTerms: "Termos de Busca", strategy: "Estratégia", @@ -112,14 +103,12 @@ export const ptBR = { notebookLabel: "Caderno: {name}", itemNotFound: "Este {type} não foi encontrado", accessibility: { - navigation: "Navegação", transformationViews: "Visualizações de transformação", searchKB: "Perguntar ou buscar na base de conhecimento", enterQuestion: "Digite sua pergunta para a base de conhecimento", enterSearch: "Digite sua busca", searchKBBtn: "Buscar na base de conhecimento", podcastViews: "Visualizações de podcast", - chatSessions: "Sessões de chat", ytVideo: "Vídeo do YouTube", askResponse: "Resposta da Consulta", searchNotebooks: "Buscar cadernos", @@ -127,9 +116,9 @@ export const ptBR = { url: "URL", errorDetails: "Detalhes do Erro", editTransformation: "Editar Transformação", - comingSoon: "Em breve", retry: "Tentar Novamente", traditionalChinese: "繁體中文", + portuguese: "Português", completed: "concluído", saveSuccess: "Salvo com sucesso", contextModes: { @@ -139,7 +128,6 @@ export const ptBR = { clickToCycle: "Clique para alternar", }, clickToEdit: "Clique para editar", - portuguese: "Português", }, apiErrors: { notebookNotFound: "Caderno não encontrado", @@ -165,7 +153,6 @@ export const ptBR = { failedToSendMessage: "Falha ao enviar mensagem", unauthorized: "Acesso não autorizado, verifique sua senha", invalidPassword: "Senha inválida", - missingAuth: "Autenticação ausente", embeddingModelRequired: "Este recurso requer um modelo de embedding. Configure um na seção Modelos.", strategyModelNotFound: "Modelo de estratégia não encontrado", answerModelNotFound: "Modelo de resposta não encontrado", @@ -206,7 +193,6 @@ export const ptBR = { passwordPlaceholder: "Senha", signingIn: "Entrando...", signIn: "Entrar", - unhandledError: "Erro não tratado durante login", connectErrorHint: "Não foi possível conectar ao servidor. Verifique se a API está rodando.", }, navigation: { @@ -226,7 +212,6 @@ export const ptBR = { nav: "Navegação", language: "Alternar idioma", theme: "Tema", - search: "Buscar", ask: "Perguntar", }, notebooks: { @@ -248,12 +233,8 @@ export const ptBR = { keepExclusiveSourcesLabel: "Desvincular e manter", activeNotebooks: "Cadernos Ativos", archivedNotebooks: "Cadernos Arquivados", - emptyDescription: "Comece criando seu primeiro caderno para organizar sua pesquisa.", - noActiveNotebooks: "Nenhum caderno ativo", - noArchivedNotebooks: "Nenhum caderno arquivado", notFound: "Caderno não encontrado", notFoundDesc: "O caderno solicitado não existe.", - noDescription: "Sem descrição...", updated: "Atualizado", namePlaceholder: "Nome do caderno", addDescription: "Adicionar descrição...", @@ -278,10 +259,7 @@ export const ptBR = { add: "Adicionar Fonte", addNew: "Adicionar Nova Fonte", addExisting: "Adicionar Fonte Existente", - empty: "Nenhuma fonte ainda", - emptyDesc: "Adicione sua primeira fonte para começar a construir sua base de conhecimento.", delete: "Excluir Fonte", - deleteMsg: "Tem certeza que deseja excluir esta fonte? Esta ação não pode ser desfeita.", statusPreparing: "Preparando", statusQueued: "Na Fila", statusProcessing: "Processando", @@ -321,7 +299,6 @@ export const ptBR = { sourceRequeued: "Fonte Reenfileirada", sourceRequeuedDesc: "A fonte foi reenfileirada para processamento.", failedToRetry: "Falha ao Tentar Novamente", - failedToRetryDesc: "Falha ao tentar processar fonte novamente. Por favor, tente de novo.", sourcesAddedToNotebook: "{count} fonte(s) adicionada(s) ao caderno", failedToAddSourcesToNotebook: "Falha ao adicionar fontes ao caderno", partialAddSuccess: "{success} fonte(s) adicionada(s), {failed} falhou(aram)", @@ -359,7 +336,6 @@ export const ptBR = { deleteInsight: "Excluir Insight", deleteInsightConfirm: "Tem certeza que deseja excluir este insight? Esta ação não pode ser desfeita.", insightGenerationStarted: "Geração de insight iniciada. Aparecerá em breve.", - deleteNoteConfirm: "Tem certeza que deseja excluir esta nota? Esta ação não pode ser desfeita.", editNote: "Editar nota", createNote: "Criar nota", addTitle: "Adicionar título...", @@ -367,9 +343,7 @@ export const ptBR = { writeNotePlaceholder: "Escreva o conteúdo da sua nota aqui...", saveNote: "Salvar Nota", createNoteBtn: "Criar Nota", - noNotesYet: "Nenhuma nota ainda", createFirstNote: "Crie sua primeira nota para capturar insights e observações.", - deleteNote: "Excluir Nota", urlLabel: "URL(s) *", fileLabel: "Arquivo(s) *", textContentLabel: "Conteúdo de Texto *", @@ -403,7 +377,6 @@ export const ptBR = { retryProcessing: "Tentar Processamento Novamente", deleteSource: "Excluir Fonte", retry: "Tentar Novamente", - progress: "Progresso", addExistingTitle: "Adicionar Fontes Existentes", addExistingDesc: "Selecione fontes existentes de todos os seus cadernos para adicionar ao atual.", searchPlaceholder: "Buscar fontes por nome ou URL...", @@ -435,8 +408,6 @@ export const ptBR = { batchFailed: "Falha ao criar todas as {count} fontes", batchPartial: "{success} sucesso, {failed} falhou(aram)", submittingSource: "Enviando fonte para processamento...", - contentRequired: "Por favor, forneça o conteúdo necessário para o tipo de fonte selecionado", - titleRequiredForText: "Título é obrigatório para fontes de texto", processingBatchSources: "Processando {count} fontes. Isso pode levar alguns momentos.", processingSource: "Sua fonte está sendo processada. Isso pode levar alguns momentos.", maxFilesAllowed: "Máximo de {count} arquivos permitidos por lote", @@ -445,16 +416,10 @@ export const ptBR = { sessions: "Sessões", sessionTitlePlaceholder: "Digite um título aqui...", noSessions: "Nenhuma sessão de chat ainda", - startChatting: "Comece a conversar sobre suas fontes.", deleteSession: "Excluir Sessão", deleteSessionDesc: "Tem certeza que deseja excluir esta sessão de chat? Esta ação não pode ser desfeita.", sendPlaceholder: "Pergunte qualquer coisa sobre suas fontes...", - newChat: "Novo Chat", sessionsTitle: "Sessões de Chat", - clearhistory: "Limpar Histórico", - renameSession: "Renomear Sessão", - noSourcesLinked: "Nenhuma fonte vinculada", - thinking: "IA está pensando...", chatWith: "Conversar com {name}", startConversation: "Inicie uma conversa sobre este {type}", askQuestions: "Faça perguntas para entender melhor o conteúdo", @@ -508,8 +473,6 @@ export const ptBR = { saveSuccess: "Salvo no caderno com sucesso", saveError: "Falha ao salvar no caderno", selectNotebook: "Selecionar Caderno", - createNewNotebook: "Criar Novo Caderno", - cancel: "Cancelar", searchAndAsk: "Buscar e Perguntar", searchResultsFor: "Resultados da busca para \"{query}\"", askAbout: "Perguntar sobre \"{query}\"", @@ -734,8 +697,6 @@ export const ptBR = { speakerCountMin: "Pelo menos um locutor é necessário", speakerCountMax: "Você pode configurar até 4 locutores", delete: "Excluir", - unknown: "Desconhecido", - deleteSuccess: "Podcast excluído com sucesso", failedToDelete: "Falha ao excluir podcast", }, settings: { @@ -772,13 +733,8 @@ export const ptBR = { title: "Ferramentas Avançadas", desc: "Ferramentas e utilitários avançados para usuários avançados", systemInfo: "Informações do Sistema", - systemInfoDesc: "Visualize o status dos componentes subjacentes do sistema", rebuildEmbeddings: "Reconstruir Embeddings", rebuildEmbeddingsDesc: "Reconstruir índice de busca vetorial para todas as fontes", - rebuildWarning: "Esta ação pode ser muito demorada dependendo do número de fontes que você tem. Ela limpará os índices vetoriais existentes e regerará embeddings para tudo.", - startRebuild: "Iniciar Reconstrução", - rebuilding: "Reconstruindo...", - rebuildSuccess: "Reconstrução de embedding iniciada com sucesso", currentVersion: "Versão Atual", latestVersion: "Última Versão", status: "Status", @@ -823,10 +779,8 @@ export const ptBR = { defaultPrompt: "Prompt de Transformação Padrão", defaultPromptDesc: "Isso será adicionado a todos os seus prompts de transformação", defaultPromptPlaceholder: "Digite suas instruções padrão de transformação...", - saveDefault: "Salvar Padrão", listTitle: "Transformações Personalizadas", createNew: "Criar Nova", - testInPlayground: "Testar no Playground", inputLabel: "Texto de Entrada", inputPlaceholder: "Digite algum texto para transformar...", outputLabel: "Saída", @@ -845,83 +799,33 @@ export const ptBR = { deleteSuccess: "Transformação excluída com sucesso", noTransformations: "Nenhuma transformação ainda", createOne: "Crie uma transformação para começar", - deleteDesc: "Excluir esta transformação não pode ser desfeito.", selectModel: "Selecione um modelo", deleteConfirm: "Tem certeza que deseja excluir esta transformação?", model: "Modelo", systemPrompt: "Prompt do Sistema", - type: "Tipo", - extraction: "Extração", - summary: "Resumo", - custom: "Personalizado", - saveChanges: "Salvar Alterações", overrideModelDesc: "Substitua o modelo padrão para esta sessão de chat. Deixe vazio para usar o padrão do sistema.", sessionUseReplacement: "Esta sessão usará {name} em vez do modelo padrão.", systemDefault: "Padrão do Sistema", }, models: { - title: "Gerenciamento de Modelos", - desc: "Configure modelos de IA para diferentes propósitos no Open Notebook", - failedToLoad: "Falha ao carregar dados dos modelos", - language: "Modelos de Linguagem", embedding: "Modelos de Embedding", tts: "Text to Speech (TTS)", stt: "Speech to Text (STT)", - providers: "Provedores", - defaultModels: "Modelos Padrão", - status: "Status", - notConfigured: "Não configurado", - active: "Ativo", - inactive: "Inativo", - configure: "Configurar", - saveChanges: "Salvar Alterações", - addModel: "Adicionar Modelo", - modelName: "Nome do Modelo", provider: "Provedor", apiKey: "Chave da API", - baseUrl: "URL Base", - capabilities: "Capacidades", - enabled: "Habilitado", - disabled: "Desabilitado", - deleteConfirm: "Tem certeza que deseja excluir este modelo?", deleteSuccess: "Modelo excluído com sucesso", saveSuccess: "Modelo salvo com sucesso", - providerStatus: "Status do Provedor", - connectionOk: "Conexão OK", - connectionFailed: "Conexão falhou", - changeEmbeddingWarning: "Alterar o modelo de embedding padrão afetará novas fontes. Fontes existentes podem precisar ser reindexadas.", - changeEmbeddingTitle: "Alterar Modelo de Embedding Padrão?", - aiProviders: "Provedores de IA", - providerConfigDesc: "Configure provedores através de variáveis de ambiente para habilitar seus modelos.", - configuredCount: "{count} de {total} configurados", noModels: "Sem modelos", - learnMore: "Saiba como configurar provedores →", - seeLess: "Ver menos", - seeAll: "Ver todos os {count} provedores", - language_models: "Modelos de Linguagem", - embedding_models: "Modelos de Embedding", - text_to_speech: "Text to Speech (TTS)", - speech_to_text: "Speech to Text (STT)", - languageDesc: "Chat, transformações e geração de texto", - embeddingDesc: "Busca semântica e embeddings vetoriais", - ttsDesc: "Gerar áudio a partir de texto", - sttDesc: "Transcrever áudio para texto", - all: "Todos", - noModelsConfigured: "Nenhum modelo configurado", - noProviderModelsConfigured: "Nenhum modelo {provider} configurado", - showMore: "Mostrar mais {count}", discoverModels: "Descobrir Modelos", noModelsFound: "Nenhum modelo encontrado para este provedor", modelType: "Tipo do Modelo", modelTypeHint: "Selecione o tipo para os modelos que deseja adicionar. Se precisar de tipos diferentes, adicione em lotes separados.", deleteModel: "Excluir Modelo", - deleteModelDesc: "Tem certeza que deseja excluir \"{name}\"? Esta ação não pode ser desfeita.", defaultAssignments: "Atribuições de Modelo Padrão", defaultAssignmentsDesc: "Configure quais modelos usar para diferentes propósitos no Open Notebook", missingRequiredModels: "Modelos obrigatórios ausentes: {models}. O Open Notebook pode não funcionar corretamente sem eles.", selectModelPlaceholder: "Selecione um modelo", requiredModelPlaceholder: "⚠️ Obrigatório - Selecione um modelo", - whichModelToChoose: "Qual modelo devo escolher? →", chatModelLabel: "Modelo de Chat", chatModelDesc: "Usado para conversas de chat", transformationModelLabel: "Modelo de Transformação", @@ -936,16 +840,9 @@ export const ptBR = { ttsModelDesc: "Usado para geração de podcast", sttModelLabel: "Modelo Speech-to-Text", sttModelDesc: "Usado para transcrição de áudio", - addSpecificModel: "Adicionar Modelo de {type}", - addSpecificModelDesc: "Configure um novo modelo de {type} dos provedores disponíveis.", - noProvidersForType: "Nenhum provedor disponível para modelos de {type}", selectProviderPlaceholder: "Selecione um provedor", providerRequired: "Provedor é obrigatório", - modelNameRequired: "Nome do modelo é obrigatório", modelRequired: "Modelo é obrigatório", - adding: "Adicionando...", - azureHint: "Para Azure, use o nome do deployment como nome do modelo", - enterModelName: "Digite o nome do modelo", embeddingChangeTitle: "Alteração de Modelo de Embedding", embeddingChangeConfirm: "Você está prestes a alterar seu modelo de embedding de {from} para {to}.", rebuildRequired: "Importante: Reconstrução Necessária", @@ -959,7 +856,6 @@ export const ptBR = { changeModelOnly: "Apenas Alterar Modelo", changeAndRebuild: "Alterar e Ir para Reconstrução", autoAssign: "Atribuir Automaticamente", - autoAssignDesc: "Atribuir automaticamente o melhor modelo disponível para cada slot", autoAssigning: "Atribuindo...", autoAssignSuccess: "{count} modelos padrão atribuídos automaticamente", autoAssignNoModels: "Nenhum modelo disponível para atribuir. Por favor, sincronize os modelos primeiro.", @@ -967,25 +863,16 @@ export const ptBR = { testModel: "Testar Modelo", testModelSuccess: "Teste do Modelo Passou", testModelFailed: "Teste do Modelo Falhou", - testingModel: "Testando modelo...", searchOrAddModel: "Pesquisar ou digitar nome do modelo...", - addCustomModel: 'Adicionar "{name}"', + addCustomModel: "Adicionar \"{name}\"", }, apiKeys: { title: "Configure sua IA com suas próprias chaves de API", description: "Armazene chaves de API com segurança no banco de dados para habilitar provedores de IA no Open Notebook.", - loadFailed: "Falha ao carregar status das chaves de API", encryptionRequired: "Chave de criptografia não configurada", encryptionRequiredDescription: "Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY com qualquer string secreta para armazenar chaves de API no banco de dados.", configured: "Configurado", notConfigured: "Não configurado", - sourceDatabase: "Banco de dados", - sourceEnvironment: "Ambiente", - enterApiKey: "Digite sua chave de API", - enterBaseUrl: "Digite a URL base", - saveSuccess: "Chave de API salva com sucesso", - deleteSuccess: "Chave de API excluída com sucesso", - fromEnvironmentHint: "Esta chave é definida via variável de ambiente. Salve uma nova chave para sobrescrevê-la no banco de dados.", migrationAvailable: "Variáveis de Ambiente Detectadas", migrationDescription: "{count} chave(s) de API estão configuradas via variáveis de ambiente e podem ser migradas para o banco de dados para facilitar o gerenciamento.", migrateToDatabase: "Migrar para Banco de Dados", @@ -993,67 +880,29 @@ export const ptBR = { migrationSuccess: "{count} chave(s) de API migrada(s) com sucesso", migrationErrors: "{count} chave(s) falhou ao migrar", migrationNothingToMigrate: "Todas as chaves já estão no banco de dados", - serviceType: "Tipo de Serviço", - serviceLlm: "Modelo de Linguagem (LLM)", - serviceEmbedding: "Embedding", - serviceStt: "Speech to Text (STT)", - serviceTts: "Text to Speech (TTS)", - serviceEndpoints: "Endpoints de Serviço (opcional)", - azureEndpointsHint: "Configure endpoints diferentes para cada tipo de serviço se necessário.", - endpointPlaceholder: "https://seu-recurso.openai.azure.com/", - openaiCompatibleHint: "Configure um endpoint de API compatível com OpenAI. Cada tipo de serviço pode ter sua própria configuração.", - baseUrlPlaceholder: "https://api.exemplo.com/v1", learnMore: "Saiba como configurar chaves de API →", testConnection: "Testar Conexão", - testing: "Testando...", testSuccess: "Conexão bem-sucedida", testFailed: "Falha no teste de conexão", syncModels: "Sincronizar Modelos", - syncing: "Sincronizando...", syncSuccess: "Descobertos {discovered} modelos, {new} novos adicionados", syncNoNew: "Descobertos {count} modelos, todos já registrados", syncFailed: "Falha ao sincronizar modelos", - syncAllModels: "Sincronizar Todos os Provedores", - syncAllSuccess: "Descobertos {discovered} modelos em todos os provedores, {new} novos adicionados", - modelsConfigured: "{count} modelos", - noModelsConfigured: "Sem modelos", - viewModels: "Ver Modelos", - supportedTypes: "Tipos suportados", - typeLanguage: "Linguagem", - typeEmbedding: "Embedding", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "Endpoint da API", getApiKey: "Obter Chave de API", vertexProject: "ID do Projeto GCP", vertexLocation: "Região", vertexCredentials: "Caminho do JSON da Conta de Serviço", - vertexCredentialsHint: "Caminho para o arquivo JSON da conta de serviço do Google Cloud dentro do contêiner.", - - // Multi-config translations - configsCount: "{count} configurações", - configuredMultiple: "Configurado", addConfig: "Adicionar Configuração", editConfig: "Editar Configuração", deleteConfig: "Excluir Configuração", - setAsDefault: "Definir como Padrão", - defaultBadge: "Padrão", - defaultDescription: "Configuração padrão para este provedor", configName: "Nome da Configuração", configNameHint: "Um nome descritivo para esta configuração (ex.: 'Produção', 'Desenvolvimento')", baseUrl: "URL Base", - baseUrlHint: "Padrão: {url}", baseUrlOverrideHint: "Altere apenas se precisar sobrescrever o endpoint padrão do provedor.", - ollamaApiKeyHint: "Necessária apenas para Ollama Cloud. Deixe vazio para Ollama local.", - noConfigs: "Sem configurações ainda", - noConfigsHint: "Adicione uma configuração para começar a usar este provedor", deleteConfigConfirm: "Tem certeza de que deseja excluir '{name}'? Esta ação não pode ser desfeita.", - setDefaultConfirm: "Definir '{name}' como a configuração padrão?", configSaveSuccess: "Configuração salva com sucesso", configUpdateSuccess: "Configuração atualizada com sucesso", configDeleteSuccess: "Configuração excluída com sucesso", - configSetDefaultSuccess: "Configuração padrão atualizada", - apiKeyHint: "Digite sua chave de API para esta configuração", apiKeyEditHint: "Deixe em branco para manter a chave de API existente", }, setupBanner: { diff --git a/frontend/src/lib/locales/ru-RU/index.ts b/frontend/src/lib/locales/ru-RU/index.ts index 9cf1e1a..7410b81 100644 --- a/frontend/src/lib/locales/ru-RU/index.ts +++ b/frontend/src/lib/locales/ru-RU/index.ts @@ -36,22 +36,16 @@ export const ruRU = { warning: "Предупреждение", error: "Ошибка", success: "Успешно", - sessions: "Сессии", model: "Модель", - send: "Отправить", back: "Назад", next: "Далее", done: "Готово", processing: "Обработка...", creating: "Создание...", - tokenCount: "Токены", - charCount: "Символы", linked: "Связано", - added: "Добавлено {date}", adding: "Добавление...", addSelected: "Добавить выбранное", customModel: "Своя модель", - messages: "Сообщения", failed: "не удалось", current: "Текущий", save: "Сохранить", @@ -72,7 +66,6 @@ export const ruRU = { unknown: "Неизвестно", notes: "Заметки", chat: "Чат", - details: "Подробности", deleteForever: "Удалить навсегда", connectionError: "Ошибка подключения", unableToConnect: "Не удаётся подключиться к API-серверу", @@ -85,7 +78,6 @@ export const ruRU = { checkConsoleLogs: "Проверьте консоль браузера для подробных логов (ищите сообщения 🔧 [Config])", yes: "Да", no: "Нет", - simple: "Простой", saving: "Сохранение...", description: "Описание", saveToNote: "Сохранить в заметку", @@ -103,7 +95,6 @@ export const ruRU = { nameRequired: "Название обязательно", modelConfiguration: "Настройка модели", resetToDefault: "Сбросить по умолчанию", - notFound: "Не найдено", reasoning: "Рассуждение", searchTerms: "Поисковые запросы", strategy: "Стратегия", @@ -112,14 +103,12 @@ export const ruRU = { notebookLabel: "Блокнот: {name}", itemNotFound: "Этот {type} не найден", accessibility: { - navigation: "Навигация", transformationViews: "Представления трансформаций", searchKB: "Спросить или найти в базе знаний", enterQuestion: "Введите вопрос для базы знаний", enterSearch: "Введите поисковый запрос", searchKBBtn: "Поиск по базе знаний", podcastViews: "Представления подкастов", - chatSessions: "Сессии чата", ytVideo: "Видео YouTube", askResponse: "Ответ на запрос", searchNotebooks: "Поиск блокнотов", @@ -127,7 +116,6 @@ export const ruRU = { url: "URL", errorDetails: "Детали ошибки", editTransformation: "Редактировать трансформацию", - comingSoon: "Скоро будет", retry: "Повторить", traditionalChinese: "繁體中文", portuguese: "Português", @@ -165,7 +153,6 @@ export const ruRU = { failedToSendMessage: "Не удалось отправить сообщение", unauthorized: "Неавторизованный доступ, проверьте пароль", invalidPassword: "Неверный пароль", - missingAuth: "Отсутствует авторизация", embeddingModelRequired: "Для этой функции требуется модель эмбеддингов. Настройте её в разделе «Модели».", strategyModelNotFound: "Модель стратегии не найдена", answerModelNotFound: "Модель ответов не найдена", @@ -206,7 +193,6 @@ export const ruRU = { passwordPlaceholder: "Пароль", signingIn: "Вход...", signIn: "Войти", - unhandledError: "Необработанная ошибка при входе", connectErrorHint: "Не удаётся подключиться к серверу. Проверьте, запущен ли API.", }, navigation: { @@ -226,7 +212,6 @@ export const ruRU = { nav: "Навигация", language: "Переключить язык", theme: "Тема", - search: "Поиск", ask: "Запрос", }, notebooks: { @@ -248,12 +233,8 @@ export const ruRU = { keepExclusiveSourcesLabel: "Отвязать и сохранить", activeNotebooks: "Активные блокноты", archivedNotebooks: "Архивные блокноты", - emptyDescription: "Начните с создания первого блокнота для организации исследований.", - noActiveNotebooks: "Нет активных блокнотов", - noArchivedNotebooks: "Нет архивных блокнотов", notFound: "Блокнот не найден", notFoundDesc: "Запрошенный блокнот не существует.", - noDescription: "Без описания...", updated: "Обновлено", namePlaceholder: "Название блокнота", addDescription: "Добавить описание...", @@ -278,10 +259,7 @@ export const ruRU = { add: "Добавить источник", addNew: "Добавить новый источник", addExisting: "Добавить существующий источник", - empty: "Пока нет источников", - emptyDesc: "Добавьте первый источник, чтобы начать создание базы знаний.", delete: "Удалить источник", - deleteMsg: "Вы уверены, что хотите удалить этот источник? Это действие нельзя отменить.", statusPreparing: "Подготовка", statusQueued: "В очереди", statusProcessing: "Обработка", @@ -321,7 +299,6 @@ export const ruRU = { sourceRequeued: "Повторная обработка в очереди", sourceRequeuedDesc: "Источник поставлен в очередь на повторную обработку.", failedToRetry: "Повтор не удался", - failedToRetryDesc: "Не удалось повторить обработку источника. Попробуйте ещё раз.", sourcesAddedToNotebook: "Добавлено источников в блокнот: {count}", failedToAddSourcesToNotebook: "Не удалось добавить источники в блокнот", partialAddSuccess: "Добавлено: {success}, не удалось: {failed}", @@ -359,7 +336,6 @@ export const ruRU = { deleteInsight: "Удалить инсайт", deleteInsightConfirm: "Вы уверены, что хотите удалить этот инсайт? Это действие нельзя отменить.", insightGenerationStarted: "Генерация инсайта запущена. Скоро он появится.", - deleteNoteConfirm: "Вы уверены, что хотите удалить эту заметку? Это действие нельзя отменить.", editNote: "Редактировать заметку", createNote: "Создать заметку", addTitle: "Добавьте название...", @@ -367,9 +343,7 @@ export const ruRU = { writeNotePlaceholder: "Напишите содержимое заметки здесь...", saveNote: "Сохранить заметку", createNoteBtn: "Создать заметку", - noNotesYet: "Пока нет заметок", createFirstNote: "Создайте первую заметку для записи идей и наблюдений.", - deleteNote: "Удалить заметку", urlLabel: "URL(ы) *", fileLabel: "Файл(ы) *", textContentLabel: "Текстовое содержимое *", @@ -403,7 +377,6 @@ export const ruRU = { retryProcessing: "Повторить обработку", deleteSource: "Удалить источник", retry: "Повторить", - progress: "Прогресс", addExistingTitle: "Добавить существующие источники", addExistingDesc: "Выберите существующие источники из всех блокнотов для добавления в текущий.", searchPlaceholder: "Поиск источников по названию или URL...", @@ -435,8 +408,6 @@ export const ruRU = { batchFailed: "Не удалось создать все источники: {count}", batchPartial: "Успешно: {success}, не удалось: {failed}", submittingSource: "Отправка источника на обработку...", - contentRequired: "Пожалуйста, предоставьте необходимое содержимое для выбранного типа источника", - titleRequiredForText: "Для текстовых источников требуется название", processingBatchSources: "Обработка источников: {count}. Это может занять некоторое время.", processingSource: "Источник обрабатывается. Это может занять некоторое время.", maxFilesAllowed: "Максимальное количество файлов в пакете: {count}", @@ -445,16 +416,10 @@ export const ruRU = { sessions: "Сессии", sessionTitlePlaceholder: "Введите название...", noSessions: "Пока нет сессий чата", - startChatting: "Начните общение о ваших источниках.", deleteSession: "Удалить сессию", deleteSessionDesc: "Вы уверены, что хотите удалить эту сессию чата? Это действие нельзя отменить.", sendPlaceholder: "Задайте вопрос о ваших источниках...", - newChat: "Новый чат", sessionsTitle: "Сессии чата", - clearhistory: "Очистить историю", - renameSession: "Переименовать сессию", - noSourcesLinked: "Нет связанных источников", - thinking: "ИИ размышляет...", chatWith: "Чат с {name}", startConversation: "Начните разговор об этом {type}", askQuestions: "Задавайте вопросы, чтобы лучше понять содержимое", @@ -508,8 +473,6 @@ export const ruRU = { saveSuccess: "Успешно сохранено в блокнот", saveError: "Не удалось сохранить в блокнот", selectNotebook: "Выберите блокнот", - createNewNotebook: "Создать новый блокнот", - cancel: "Отмена", searchAndAsk: "Поиск и запрос", searchResultsFor: "Результаты поиска для «{query}»", askAbout: "Спросить о «{query}»", @@ -734,8 +697,6 @@ export const ruRU = { speakerCountMin: "Требуется минимум один говорящий", speakerCountMax: "Можно настроить до 4 говорящих", delete: "Удалить", - unknown: "Неизвестно", - deleteSuccess: "Подкаст успешно удалён", failedToDelete: "Не удалось удалить подкаст", }, settings: { @@ -772,13 +733,8 @@ export const ruRU = { title: "Дополнительные инструменты", desc: "Расширенные инструменты и утилиты для опытных пользователей", systemInfo: "Информация о системе", - systemInfoDesc: "Просмотр состояния системных компонентов", rebuildEmbeddings: "Пересоздать эмбеддинги", rebuildEmbeddingsDesc: "Пересоздать индекс векторного поиска для всех источников", - rebuildWarning: "Это действие может занять много времени в зависимости от количества источников. Существующие векторные индексы будут очищены и эмбеддинги будут созданы заново.", - startRebuild: "Начать пересоздание", - rebuilding: "Пересоздание...", - rebuildSuccess: "Пересоздание эмбеддингов успешно запущено", currentVersion: "Текущая версия", latestVersion: "Последняя версия", status: "Статус", @@ -823,10 +779,8 @@ export const ruRU = { defaultPrompt: "Промпт трансформации по умолчанию", defaultPromptDesc: "Этот текст будет добавлен ко всем промптам трансформаций", defaultPromptPlaceholder: "Введите инструкции трансформации по умолчанию...", - saveDefault: "Сохранить по умолчанию", listTitle: "Пользовательские трансформации", createNew: "Создать новую", - testInPlayground: "Тестировать в песочнице", inputLabel: "Входной текст", inputPlaceholder: "Введите текст для трансформации...", outputLabel: "Результат", @@ -845,83 +799,33 @@ export const ruRU = { deleteSuccess: "Трансформация успешно удалена", noTransformations: "Пока нет трансформаций", createOne: "Создайте трансформацию для начала", - deleteDesc: "Удаление трансформации нельзя отменить.", selectModel: "Выберите модель", deleteConfirm: "Вы уверены, что хотите удалить эту трансформацию?", model: "Модель", systemPrompt: "Системный промпт", - type: "Тип", - extraction: "Извлечение", - summary: "Резюме", - custom: "Пользовательский", - saveChanges: "Сохранить изменения", overrideModelDesc: "Переопределить модель по умолчанию для этой сессии чата. Оставьте пустым для использования системной модели.", sessionUseReplacement: "Эта сессия будет использовать {name} вместо модели по умолчанию.", systemDefault: "Системная по умолчанию", }, models: { - title: "Управление моделями", - desc: "Настройте ИИ-модели для различных задач в Open Notebook", - failedToLoad: "Не удалось загрузить данные моделей", - language: "Языковые модели", embedding: "Модели эмбеддинга", tts: "Озвучивание (TTS)", stt: "Распознавание речи (STT)", - providers: "Провайдеры", - defaultModels: "Модели по умолчанию", - status: "Статус", - notConfigured: "Не настроено", - active: "Активно", - inactive: "Неактивно", - configure: "Настроить", - saveChanges: "Сохранить изменения", - addModel: "Добавить модель", - modelName: "Название модели", provider: "Провайдер", apiKey: "API-ключ", - baseUrl: "Базовый URL", - capabilities: "Возможности", - enabled: "Включено", - disabled: "Отключено", - deleteConfirm: "Вы уверены, что хотите удалить эту модель?", deleteSuccess: "Модель успешно удалена", saveSuccess: "Модель успешно сохранена", - providerStatus: "Статус провайдера", - connectionOk: "Подключение OK", - connectionFailed: "Ошибка подключения", - changeEmbeddingWarning: "Изменение модели эмбеддинга по умолчанию повлияет на новые источники. Существующие источники могут потребовать переиндексации.", - changeEmbeddingTitle: "Изменить модель эмбеддинга по умолчанию?", - aiProviders: "ИИ-провайдеры", - providerConfigDesc: "Настройте провайдеров через переменные окружения для включения их моделей.", - configuredCount: "Настроено {count} из {total}", noModels: "Нет моделей", - learnMore: "Узнать, как настроить провайдеров →", - seeLess: "Свернуть", - seeAll: "Показать все {count} провайдеров", - language_models: "Языковые модели", - embedding_models: "Модели эмбеддинга", - text_to_speech: "Озвучивание (TTS)", - speech_to_text: "Распознавание речи (STT)", - languageDesc: "Чат, трансформации и генерация текста", - embeddingDesc: "Семантический поиск и векторные эмбеддинги", - ttsDesc: "Генерация аудио из текста", - sttDesc: "Транскрибация аудио в текст", - all: "Все", - noModelsConfigured: "Модели не настроены", - noProviderModelsConfigured: "Модели {provider} не настроены", - showMore: "Показать ещё {count}", discoverModels: "Обнаружение моделей", noModelsFound: "Модели от этого провайдера не найдены", modelType: "Тип модели", modelTypeHint: "Выберите тип для добавляемых моделей. Если нужны разные типы, добавляйте их отдельными партиями.", deleteModel: "Удалить модель", - deleteModelDesc: "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.", defaultAssignments: "Назначение моделей по умолчанию", defaultAssignmentsDesc: "Настройте, какие модели использовать для различных задач в Open Notebook", missingRequiredModels: "Отсутствуют необходимые модели: {models}. Open Notebook может работать некорректно без них.", selectModelPlaceholder: "Выберите модель", requiredModelPlaceholder: "⚠️ Обязательно — выберите модель", - whichModelToChoose: "Какую модель выбрать? →", chatModelLabel: "Модель чата", chatModelDesc: "Используется для чат-разговоров", transformationModelLabel: "Модель трансформаций", @@ -936,16 +840,9 @@ export const ruRU = { ttsModelDesc: "Используется для генерации подкастов", sttModelLabel: "Модель распознавания речи", sttModelDesc: "Используется для транскрибации аудио", - addSpecificModel: "Добавить модель {type}", - addSpecificModelDesc: "Настройте новую модель {type} от доступных провайдеров.", - noProvidersForType: "Нет доступных провайдеров для моделей {type}", selectProviderPlaceholder: "Выберите провайдера", providerRequired: "Требуется провайдер", - modelNameRequired: "Требуется название модели", modelRequired: "Требуется модель", - adding: "Добавление...", - azureHint: "Для Azure используйте название деплоймента как название модели", - enterModelName: "Введите название модели", embeddingChangeTitle: "Изменение модели эмбеддинга", embeddingChangeConfirm: "Вы собираетесь изменить модель эмбеддинга с {from} на {to}.", rebuildRequired: "Важно: Требуется пересоздание", @@ -958,28 +855,24 @@ export const ruRU = { proceedToRebuildPrompt: "Хотите перейти на страницу «Дополнительно», чтобы начать пересоздание сейчас?", changeModelOnly: "Только изменить модель", changeAndRebuild: "Изменить и перейти к пересозданию", + autoAssign: "Автоназначение по умолчанию", + autoAssigning: "Назначение...", + autoAssignSuccess: "{count} моделей по умолчанию автоматически назначено", + autoAssignNoModels: "Нет доступных моделей для назначения. Сначала синхронизируйте модели.", + autoAssignAlreadySet: "Все модели по умолчанию уже настроены", testModel: "Тестировать модель", testModelSuccess: "Тест модели пройден", testModelFailed: "Тест модели не пройден", - testingModel: "Тестирование модели...", searchOrAddModel: "Поиск или введите имя модели...", - addCustomModel: 'Добавить "{name}"', + addCustomModel: "Добавить \"{name}\"", }, apiKeys: { title: "Настройте ИИ с помощью собственных API-ключей", description: "Храните API-ключи в базе данных для безопасного подключения провайдеров ИИ в Open Notebook.", - loadFailed: "Не удалось загрузить статус API-ключей", encryptionRequired: "Ключ шифрования не настроен", encryptionRequiredDescription: "Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY в любую секретную строку для хранения API-ключей в базе данных.", configured: "Настроено", notConfigured: "Не настроено", - sourceDatabase: "База данных", - sourceEnvironment: "Переменная окружения", - enterApiKey: "Введите ваш API-ключ", - enterBaseUrl: "Введите базовый URL", - saveSuccess: "API-ключ успешно сохранён", - deleteSuccess: "API-ключ успешно удалён", - fromEnvironmentHint: "Этот ключ задан через переменную окружения. Сохраните новый ключ, чтобы переопределить его в базе данных.", migrationAvailable: "Обнаружены переменные окружения", migrationDescription: "{count} API-ключ(ей) настроено через переменные окружения и может быть перенесено в базу данных для удобного управления.", migrateToDatabase: "Перенести в базу данных", @@ -987,67 +880,29 @@ export const ruRU = { migrationSuccess: "{count} API-ключ(ей) успешно перенесено", migrationErrors: "{count} ключ(ей) не удалось перенести", migrationNothingToMigrate: "Все ключи уже находятся в базе данных", - serviceType: "Тип сервиса", - serviceLlm: "Языковая модель (LLM)", - serviceEmbedding: "Эмбеддинг", - serviceStt: "Распознавание речи (STT)", - serviceTts: "Синтез речи (TTS)", - serviceEndpoints: "Эндпоинты сервисов (необязательно)", - azureEndpointsHint: "При необходимости настройте отдельные эндпоинты для каждого типа сервиса.", - endpointPlaceholder: "https://your-resource.openai.azure.com/", - openaiCompatibleHint: "Настройте совместимый с OpenAI API-эндпоинт. Каждый тип сервиса может иметь собственную конфигурацию.", - baseUrlPlaceholder: "https://api.example.com/v1", learnMore: "Узнайте, как настроить API-ключи →", testConnection: "Проверить подключение", - testing: "Проверка...", testSuccess: "Подключение успешно", testFailed: "Проверка подключения не удалась", syncModels: "Синхронизировать модели", - syncing: "Синхронизация...", syncSuccess: "Обнаружено {discovered} моделей, добавлено {new} новых", syncNoNew: "Обнаружено {count} моделей, все уже зарегистрированы", syncFailed: "Не удалось синхронизировать модели", - syncAllModels: "Синхронизировать всех провайдеров", - syncAllSuccess: "Обнаружено {discovered} моделей у всех провайдеров, добавлено {new} новых", - modelsConfigured: "{count} моделей", - noModelsConfigured: "Нет моделей", - viewModels: "Посмотреть модели", - supportedTypes: "Поддерживаемые типы", - typeLanguage: "Языковая", - typeEmbedding: "Эмбеддинг", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "API-эндпоинт", getApiKey: "Получить API-ключ", vertexProject: "ID проекта GCP", vertexLocation: "Регион", vertexCredentials: "Путь к JSON сервисного аккаунта", - vertexCredentialsHint: "Путь к JSON-файлу сервисного аккаунта Google Cloud внутри контейнера.", - - // Мультиконфигурация - configsCount: "{count} конфигураций", - configuredMultiple: "Настроено", addConfig: "Добавить конфигурацию", editConfig: "Редактировать конфигурацию", deleteConfig: "Удалить конфигурацию", - setAsDefault: "Установить по умолчанию", - defaultBadge: "По умолчанию", - defaultDescription: "Конфигурация по умолчанию для этого провайдера", configName: "Название конфигурации", configNameHint: "Описательное название для этой конфигурации (например, «Продакшн», «Разработка»)", baseUrl: "Базовый URL", - baseUrlHint: "По умолчанию: {url}", baseUrlOverrideHint: "Изменяйте только если нужно переопределить стандартную конечную точку API провайдера.", - ollamaApiKeyHint: "Требуется только для Ollama Cloud. Оставьте пустым для локальной Ollama.", - noConfigs: "Конфигурации ещё не созданы", - noConfigsHint: "Добавьте конфигурацию, чтобы начать использовать этого провайдера", deleteConfigConfirm: "Вы уверены, что хотите удалить «{name}»? Это действие необратимо.", - setDefaultConfirm: "Установить «{name}» как конфигурацию по умолчанию?", configSaveSuccess: "Конфигурация успешно сохранена", configUpdateSuccess: "Конфигурация успешно обновлена", configDeleteSuccess: "Конфигурация успешно удалена", - configSetDefaultSuccess: "Конфигурация по умолчанию обновлена", - apiKeyHint: "Введите API-ключ для этой конфигурации", apiKeyEditHint: "Оставьте пустым, чтобы сохранить текущий API-ключ", }, setupBanner: { diff --git a/frontend/src/lib/locales/zh-CN/index.ts b/frontend/src/lib/locales/zh-CN/index.ts index ea0c5a7..8fbb2fe 100644 --- a/frontend/src/lib/locales/zh-CN/index.ts +++ b/frontend/src/lib/locales/zh-CN/index.ts @@ -1,16 +1,11 @@ export const zhCN = { common: { search: "搜索...", - chat: '聊天', - notes: '笔记', create: "新建", new: "新建", cancel: "取消", - save: "保存", - nameRequired: "这是必填项", delete: "删除", edit: "编辑", - actions: "快捷操作", theme: "主题", signOut: "退出登录", noMatches: "未找到匹配项", @@ -41,33 +36,27 @@ export const zhCN = { warning: "警告", error: "操作失败", success: "操作成功", - modelConfiguration: "模型配置", - resetToDefault: "重置为默认", - sessions: "会话", model: "模型", - send: "发送", back: "返回", next: "下一步", done: "完成", processing: "处理中...", creating: "创建中...", - tokenCount: "Token", - charCount: "字符", linked: "已关联", - added: "已于 {date} 添加", adding: "正在添加...", addSelected: "添加所选", customModel: "自定义模型", - messages: "消息", failed: "失败", current: "当前", - writeNote: '撰写笔记', + save: "保存", + writeNote: "撰写笔记", batchMode: "批量模式", optional: "可选", type: "类型", title: "标题", created: "创建于 {time}", updated: "更新于 {time}", + actions: "快捷操作", noResults: "未找到结果", references: "引用", refreshPage: "请重试刷新页面", @@ -75,7 +64,8 @@ export const zhCN = { aiGenerated: "AI 生成", human: "人类", unknown: "未知", - details: "详情", + notes: "笔记", + chat: "聊天", deleteForever: "永久删除", connectionError: "连接错误", unableToConnect: "无法连接到 API 服务器", @@ -88,7 +78,6 @@ export const zhCN = { checkConsoleLogs: "请检查浏览器控制台获取详细日志(搜索 🔧 [Config] 消息)", yes: "是", no: "否", - simple: "简单", saving: "正在保存...", description: "描述", saveToNote: "保存到笔记", @@ -103,7 +92,9 @@ export const zhCN = { saveChanges: "保存更改", name: "名称", default: "默认", - notFound: "未找到", + nameRequired: "这是必填项", + modelConfiguration: "模型配置", + resetToDefault: "重置为默认", reasoning: "推理过程", searchTerms: "搜索词", strategy: "策略", @@ -112,22 +103,19 @@ export const zhCN = { notebookLabel: "笔记本: {name}", itemNotFound: "未找到该 {type}", accessibility: { - navigation: "导航", transformationViews: "转换视图", searchKB: "向知识库提问或搜索", - searchNotebooks: "搜索笔记本", enterQuestion: "输入您的问题以询问知识库", enterSearch: "输入搜索词", searchKBBtn: "搜索知识库", podcastViews: "播客视图", - chatSessions: "对话 Session", ytVideo: "YouTube 视频", askResponse: "提问回答", + searchNotebooks: "搜索笔记本", }, url: "URL", errorDetails: "错误详情", editTransformation: "编辑转换规则", - comingSoon: "敬请期待", retry: "重试", traditionalChinese: "繁体中文", portuguese: "葡萄牙语", @@ -155,17 +143,16 @@ export const zhCN = { invalidSortOrder: "排序方向必须是 'asc' 或 'desc'", accessDenied: "文件访问被拒绝", fileNotFoundOnServer: "服务器上找不到该文件", + searchFailed: "搜索失败", + askFailed: "提问失败", + pleaseEnterQuestion: "请输入问题", + pleaseConfigureModels: "请配置所有必选模型", failedToCreateSession: "创建对话失败", failedToUpdateSession: "更新会话失败", failedToDeleteSession: "删除会话失败", failedToSendMessage: "发送消息失败", - pleaseEnterQuestion: "请输入问题", - pleaseConfigureModels: "请配置所有必选模型", - askFailed: "提问失败", - searchFailed: "搜索失败", unauthorized: "无权访问,请检查您的密码", invalidPassword: "密码错误", - missingAuth: "缺少身份验证信息", embeddingModelRequired: "此功能需要嵌入模型。请在模型设置中配置一个。", strategyModelNotFound: "未找到策略模型", answerModelNotFound: "未找到回答模型", @@ -206,7 +193,6 @@ export const zhCN = { passwordPlaceholder: "密码", signingIn: "正在登录...", signIn: "登录", - unhandledError: "登录过程中出现未处理的错误", connectErrorHint: "无法连接到服务器。请检查 API 是否正在运行。", }, navigation: { @@ -217,8 +203,6 @@ export const zhCN = { sources: "来源", notebooks: "笔记本", askAndSearch: "询问与搜索", - search: "搜索", - ask: "提问", podcasts: "播客", models: "模型", transformations: "转换", @@ -228,6 +212,7 @@ export const zhCN = { nav: "导航", language: "切换语言", theme: "主题", + ask: "提问", }, notebooks: { title: "笔记本", @@ -248,12 +233,8 @@ export const zhCN = { keepExclusiveSourcesLabel: "取消关联并保留", activeNotebooks: "活动的笔记本", archivedNotebooks: "归档的笔记本", - emptyDescription: "从创建您的第一个笔记本开始,组织您的研究。", - noActiveNotebooks: "没有活动的笔记本", - noArchivedNotebooks: "没有归档的笔记本", notFound: "未找到笔记本", notFoundDesc: "请求的笔记本不存在。", - noDescription: "暂无描述...", updated: "已更新", namePlaceholder: "笔记本名称", addDescription: "添加描述...", @@ -278,12 +259,7 @@ export const zhCN = { add: "添加来源", addNew: "添加新来源", addExisting: "添加现有来源", - allSourcesDescShort: "在此查看所有来源。", - cannotSaveNoteNoNotebook: "无法保存笔记:缺少笔记本 ID", - empty: "暂无来源", - emptyDesc: "添加您的第一个来源,开始构建您的知识库。", delete: "删除来源", - deleteMsg: "确定要删除此来源吗?此操作无法撤销。", statusPreparing: "正在准备", statusQueued: "已排队", statusProcessing: "正在处理", @@ -301,6 +277,11 @@ export const zhCN = { yes: "是", no: "否", loadingMore: "正在加载更多...", + noSourcesYet: "暂无来源", + allSourcesDescShort: "在此查看所有来源。", + cannotSaveNoteNoNotebook: "无法保存笔记:缺少笔记本 ID", + createFirstSource: "添加您的第一个来源开始构建知识库。", + deleteSourceConfirm: "确定要删除此来源吗?", deleteConfirm: "确定要删除吗?", deleteConfirmWithTitle: "确定要删除 \"{title}\" 吗?", deleteSuccess: "来源删除成功。注意:要从存储中删除文件,必须在设置页面中启用“删除文件”选项。", @@ -318,16 +299,12 @@ export const zhCN = { sourceRequeued: "来源重试已加入队列", sourceRequeuedDesc: "来源已重新加入处理队列。", failedToRetry: "重试失败", - failedToRetryDesc: "重试来源处理失败。请重试。", sourcesAddedToNotebook: "{count} 个来源已添加到笔记本", failedToAddSourcesToNotebook: "添加来源到笔记本失败", partialAddSuccess: "{success} 个来源已添加,{failed} 个失败", sourceRemovedFromNotebook: "来源已成功从笔记本中移除", failedToRemoveSourceFromNotebook: "从笔记本中移除来源失败", removeConfirm: "确定要从此笔记本移除吗?", - noSourcesYet: "暂无来源", - createFirstSource: "添加您的第一个来源开始构建知识库。", - deleteSourceConfirm: "确定要删除此来源吗?", checking: "正在检查...", untitledSource: "未命名来源", maxItems: "最多 {count} 个", @@ -356,25 +333,50 @@ export const zhCN = { noInsightsYet: "暂无见解", createFirstInsight: "使用上方的转换规则创建您的第一个见解", viewInsight: "查看见解", + deleteInsight: "删除见解", + deleteInsightConfirm: "确定要删除此见解吗?此操作无法撤销。", + insightGenerationStarted: "见解生成已开始,稍后将显示。", + editNote: "编辑笔记", + createNote: "创建笔记", + addTitle: "添加标题...", + untitledNote: "无标题笔记", + writeNotePlaceholder: "在此处编写您的笔记内容...", + saveNote: "保存笔记", + createNoteBtn: "创建笔记", + createFirstNote: "创建您的第一条笔记,记录见解与观察。", + urlLabel: "URL(s) *", + fileLabel: "文件(s) *", + textContentLabel: "文本内容 *", + enterUrlsPlaceholder: "每行输入一个 URL\nhttps://example.com/article1\nhttps://example.com/article2", + batchUrlHint: "粘贴多个 URL(每行一个)进行批量导入", + invalidUrlsDetected: "检测到无效的 URL:", + lineLabel: "第 {line} 行", + fixInvalidUrls: "请修正或移除无效的 URL 以继续", + selectMultipleFilesHint: "选择多个文件进行批量导入。支持:文档 (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD),媒体 (MP4, MP3, WAV, M4A),图片 (JPG, PNG),归档 (ZIP)", + selectedFiles: "已选择文件:", + textPlaceholder: "在此处粘贴或输入您的内容...", + htmlDetected: "检测到 HTML 内容。处理后将转换为 Markdown。", + titlePlaceholder: "为您的来源起一个描述性的标题", + batchTitlesAuto: "将为每个来源自动生成标题。", + batchCommonSettings: "同样的笔记本和转换将应用于所有项目。", + urlsCount: "{count} 个 URL", + filesCount: "{count} 个文件", + addSource: "添加来源", + notEmbeddedAlert: "内容未嵌入向量", + notEmbeddedDesc: "此内容尚未为了向量搜索进行嵌入。嵌入可以启用高级搜索功能并更好地发现内容。", + openOnYoutube: "在 YouTube 上打开", + urlCopied: "URL 已复制到剪贴板", viewSource: "查看来源", noInsightSelected: "未选择见解", sourceInsight: "来源见解", manageNotebooks: "管理所属笔记本", manageNotebooksDesc: "管理包含此来源的笔记本", noNotebooksAvailable: "暂无可用笔记本", - deleteInsight: "删除见解", - deleteInsightConfirm: "确定要删除此见解吗?此操作无法撤销。", - insightGenerationStarted: "见解生成已开始,稍后将显示。", - notEmbeddedAlert: "内容未嵌入向量", - notEmbeddedDesc: "此内容尚未为了向量搜索进行嵌入。嵌入可以启用高级搜索功能并更好地发现内容。", - openOnYoutube: "在 YouTube 上打开", - urlCopied: "URL 已复制到剪贴板", loadFailed: "加载来源详情失败", removeFromNotebook: "从笔记本移除", retryProcessing: "重试处理", deleteSource: "删除来源", retry: "重试", - progress: "进度", addExistingTitle: "添加现有来源", addExistingDesc: "从您的所有笔记本中选择已有的来源添加到当前笔记本。", searchPlaceholder: "通过名称或 URL 搜索来源...", @@ -382,35 +384,6 @@ export const zhCN = { showingFirst100: "仅显示前 100 个来源。请使用搜索功能查找特定来源。", selectedCount: "已选择 {count} 个来源", added: "已添加于 {date}", - noNotesYet: '暂无笔记', - createFirstNote: '创建您的第一条笔记,记录见解与观察。', - deleteNote: '删除笔记', - deleteNoteConfirm: '您确定要删除此笔记吗?此操作无法撤销。', - editNote: '编辑笔记', - createNote: '创建笔记', - addTitle: '添加标题...', - untitledNote: '无标题笔记', - writeNotePlaceholder: '在此处编写您的笔记内容...', - saveNote: '保存笔记', - createNoteBtn: '创建笔记', - urlLabel: 'URL(s) *', - fileLabel: '文件(s) *', - textContentLabel: '文本内容 *', - enterUrlsPlaceholder: '每行输入一个 URL\nhttps://example.com/article1\nhttps://example.com/article2', - batchUrlHint: '粘贴多个 URL(每行一个)进行批量导入', - invalidUrlsDetected: '检测到无效的 URL:', - lineLabel: '第 {line} 行', - fixInvalidUrls: '请修正或移除无效的 URL 以继续', - selectMultipleFilesHint: '选择多个文件进行批量导入。支持:文档 (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD),媒体 (MP4, MP3, WAV, M4A),图片 (JPG, PNG),归档 (ZIP)', - selectedFiles: '已选择文件:', - textPlaceholder: '在此处粘贴或输入您的内容...', - htmlDetected: '检测到 HTML 内容。处理后将转换为 Markdown。', - titlePlaceholder: '为您的来源起一个描述性的标题', - batchTitlesAuto: '将为每个来源自动生成标题。', - batchCommonSettings: '同样的笔记本和转换将应用于所有项目。', - urlsCount: '{count} 个 URL', - filesCount: '{count} 个文件', - addSource: "添加来源", addUrl: "添加 URL", uploadFile: "上传文件", enterText: "输入文本", @@ -435,8 +408,6 @@ export const zhCN = { batchFailed: "全部 {count} 个来源创建失败", batchPartial: "{success} 个成功,{failed} 个失败", submittingSource: "正在提交来源进行处理...", - contentRequired: "请提供所选来源类型所需的内容", - titleRequiredForText: "文本来源需要提供标题", processingBatchSources: "正在处理 {count} 个来源,请稍候...", processingSource: "正在处理您的来源,请稍候...", maxFilesAllowed: "每批最多允许 {count} 个文件", @@ -445,26 +416,20 @@ export const zhCN = { sessions: "会话", sessionTitlePlaceholder: "在此输入标题...", noSessions: "暂无会话", - startChatting: "开始针对您的来源进行聊天。", deleteSession: "删除会话", deleteSessionDesc: "确定要删除此聊天会话吗?此操作无法撤销。", sendPlaceholder: "向您的来源提问...", - newChat: "新建对话", sessionsTitle: "对话列表", - clearhistory: "清空历史", - renameSession: "重命名会话", - noSourcesLinked: "未关联来源", - thinking: "AI 正在思考...", chatWith: "与{name}对话", startConversation: "开始针对{type}进行对话", askQuestions: "提出问题以更好地理解内容", pressToSend: "按 {key} 发送", model: "模型", - createToStart: '创建一个会话以开始。', - chatWithNotebook: '与笔记本对话', - unableToLoadChat: '无法加载聊天', - noDescription: '暂无描述', - startByCreating: '从创建您的第一个笔记本开始,组织您的研究。', + createToStart: "创建一个会话以开始。", + chatWithNotebook: "与笔记本对话", + unableToLoadChat: "无法加载聊天", + noDescription: "暂无描述", + startByCreating: "从创建您的第一个笔记本开始,组织您的研究。", messagesCount: "{count} 条消息", sessionCreated: "聊天会话已创建", sessionUpdated: "会话已更新", @@ -508,8 +473,10 @@ export const zhCN = { saveSuccess: "成功保存到笔记本", saveError: "保存到笔记本失败", selectNotebook: "选择笔记本", - createNewNotebook: "创建新笔记本", - cancel: "取消", + searchAndAsk: "搜索与提问", + searchResultsFor: "搜索 “{query}”", + askAbout: "提问关于 “{query}”", + orSearchKb: "或搜索您的知识库", saving: "保存中...", advancedModelTitle: "高级模型选择", advancedModelDesc: "为提问过程的每个阶段选择特定的模型", @@ -520,10 +487,6 @@ export const zhCN = { selectAnswerPlaceholder: "选择回答模型", selectFinalPlaceholder: "选择最终回答模型", saveChanges: "保存更改", - searchAndAsk: "搜索与提问", - searchResultsFor: "搜索 “{query}”", - askAbout: "提问关于 “{query}”", - orSearchKb: "或搜索您的知识库", processingQuestion: "正在处理您的问题...", }, podcasts: { @@ -637,8 +600,41 @@ export const zhCN = { createProfile: "创建简介", createSpeakerFirst: "在添加单集简介之前,请先创建一个发言人简介。", noEpisodeProfiles: "暂无单集简介。创建一个以启动播客生成。", + speakerCreated: "发言人配置已创建", + speakerCreatedDesc: "发言人配置已准备就绪。", + failedToCreateSpeaker: "创建发言人配置失败", + speakerUpdated: "发言人配置已更新", + speakerUpdatedDesc: "更改已成功保存。", + failedToUpdateSpeaker: "更新发言人配置失败", + speakerDeleted: "发言人配置已删除", + speakerDeletedDesc: "配置已成功移除。", + failedToDeleteSpeaker: "删除发言人配置失败", + speakerDuplicated: "发言人配置已复制", + speakerDuplicatedDesc: "已创建配置副本。", + failedToDuplicateSpeaker: "复制发言人配置失败", + generationStarted: "播客启动生成", + generationStartedDesc: "剧集 \"{name}\" 正在创建中。", + failedToStartGeneration: "启动播客生成失败", + tryAgainMoment: "请稍后再试。", deleteProfileTitle: "删除简介?", deleteProfileDesc: "这将移除 “{name}”。现有单集将保留其数据,但新单集将不再使用此配置。", + profileCreated: "剧集配置已创建", + profileCreatedDesc: "新的剧集配置已准备就绪。", + failedToCreateProfile: "创建剧集配置失败", + profileUpdated: "剧集配置已更新", + profileUpdatedDesc: "更改已成功保存。", + failedToUpdateProfile: "更新剧集配置失败", + profileDeleted: "剧集配置已删除", + profileDeletedDesc: "配置已成功移除。", + failedToDeleteProfile: "删除剧集配置失败", + failedToDeleteProfileDesc: "请确保配置未在使用中并重试。", + profileDuplicated: "剧集配置已复制", + profileDuplicatedDesc: "已创建配置副本。", + failedToDuplicateProfile: "复制剧集配置失败", + episodeDeleted: "剧集已删除", + episodeDeletedDesc: "播客剧集已成功移除。", + failedToDeleteEpisode: "删除剧集失败", + failedToDeleteSpeakerDesc: "请确保配置未在使用中并重试。", outlineModel: "大纲模型", transcriptModel: "脚本模型", segments: "分段数量", @@ -701,42 +697,7 @@ export const zhCN = { speakerCountMin: "至少需要一个发言人", speakerCountMax: "最多只能配置 4 个发言人", delete: "删除", - unknown: "未知", - deleteSuccess: "播客删除成功", failedToDelete: "删除播客失败", - episodeDeleted: "剧集已删除", - episodeDeletedDesc: "播客剧集已成功移除。", - failedToDeleteEpisode: "删除剧集失败", - profileCreated: "剧集配置已创建", - profileCreatedDesc: "新的剧集配置已准备就绪。", - failedToCreateProfile: "创建剧集配置失败", - profileUpdated: "剧集配置已更新", - profileUpdatedDesc: "更改已成功保存。", - failedToUpdateProfile: "更新剧集配置失败", - profileDeleted: "剧集配置已删除", - profileDeletedDesc: "配置已成功移除。", - failedToDeleteProfile: "删除剧集配置失败", - failedToDeleteProfileDesc: "请确保配置未在使用中并重试。", - profileDuplicated: "剧集配置已复制", - profileDuplicatedDesc: "已创建配置副本。", - failedToDuplicateProfile: "复制剧集配置失败", - speakerCreated: "发言人配置已创建", - speakerCreatedDesc: "发言人配置已准备就绪。", - failedToCreateSpeaker: "创建发言人配置失败", - speakerUpdated: "发言人配置已更新", - speakerUpdatedDesc: "更改已成功保存。", - failedToUpdateSpeaker: "更新发言人配置失败", - speakerDeleted: "发言人配置已删除", - speakerDeletedDesc: "配置已成功移除。", - failedToDeleteSpeaker: "删除发言人配置失败", - failedToDeleteSpeakerDesc: "请确保配置未在使用中并重试。", - speakerDuplicated: "发言人配置已复制", - speakerDuplicatedDesc: "已创建配置副本。", - failedToDuplicateSpeaker: "复制发言人配置失败", - generationStarted: "播客启动生成", - generationStartedDesc: "剧集 \"{name}\" 正在创建中。", - failedToStartGeneration: "启动播客生成失败", - tryAgainMoment: "请稍后再试。", }, settings: { contentProcessing: "内容处理", @@ -772,13 +733,8 @@ export const zhCN = { title: "高级工具", desc: "面向进阶用户的调试和实用工具", systemInfo: "系统信息", - systemInfoDesc: "查看底层系统组件的状态", rebuildEmbeddings: "重建索引", rebuildEmbeddingsDesc: "为所有来源重建向量索引", - rebuildWarning: "此操作可能非常耗时,具体取决于您的来源数量。它将清除现有的向量索引并重新为所有内容生成嵌入。", - startRebuild: "开始重建", - rebuilding: "正在重建...", - rebuildSuccess: "索引重建已成功启动", currentVersion: "当前版本", latestVersion: "最新版本", status: "状态", @@ -823,105 +779,53 @@ export const zhCN = { defaultPrompt: "默认全局提示词", defaultPromptDesc: "该提示词将被添加到您所有的转换提示词中", defaultPromptPlaceholder: "输入您的默认转换指令...", - saveDefault: "保存默认设置", listTitle: "自定义转换", createNew: "新建转换", - testInPlayground: "在实验室测试", inputLabel: "输入文本", - inputPlaceholder: '请输入要转换的文本...', - outputLabel: '输出', - runTest: '运行转换', - running: '运行中...', - selectToStart: '选择一个转换规则开始', - name: '名称', - namePlaceholder: '唯一标识符,例如 key_topics', - titlePlaceholder: '显示名称,默认为名称', - promptPlaceholder: '编写驱动此转换的提示词...', - descriptionPlaceholder: '描述此转换的作用。', - suggestDefault: '新来源默认建议', - promptHint: '提示词应根据源内容编写。您可以要求模型总结、提取见解或生成表格等结构化输出。', - createSuccess: '转换规则创建成功', - updateSuccess: '转换规则更新成功', - deleteSuccess: '转换规则删除成功', + inputPlaceholder: "请输入要转换的文本...", + outputLabel: "输出", + runTest: "运行转换", + running: "运行中...", + selectToStart: "选择一个转换规则开始", + name: "名称", + namePlaceholder: "唯一标识符,例如 key_topics", + titlePlaceholder: "显示名称,默认为名称", + promptPlaceholder: "编写驱动此转换的提示词...", + descriptionPlaceholder: "描述此转换的作用。", + suggestDefault: "新来源默认建议", + promptHint: "提示词应根据源内容编写。您可以要求模型总结、提取见解或生成表格等结构化输出。", + createSuccess: "转换规则创建成功", + updateSuccess: "转换规则更新成功", + deleteSuccess: "转换规则删除成功", noTransformations: "暂无转换规则", createOne: "创建一个转换规则以开始", - deleteDesc: "删除此转换无法撤销。", selectModel: "选择模型", deleteConfirm: "确定要删除此转换规则吗?", model: "模型", systemPrompt: "系统提示词", - type: "类型", - extraction: "提取", - summary: "摘要", - custom: "自定义", - saveChanges: "保存更改", overrideModelDesc: "为此聊天会话覆盖默认模型。留空则使用系统默认。", sessionUseReplacement: "此会话将使用 {name} 而不是默认模型。", systemDefault: "系统默认", }, models: { - title: "模型管理", - desc: "配置用于 Open Notebook 不同用途的 AI 模型", - failedToLoad: "加载模型数据失败", - language: "语言模型", embedding: "嵌入模型", tts: "文字转语音", stt: "语音转文字", - providers: "服务商", - defaultModels: "默认模型", - status: "状态", - notConfigured: "未配置", - active: "活动", - inactive: "不活动", - configure: "配置", - saveChanges: "保存更改", - addModel: "添加模型", - modelName: "模型名称", provider: "服务商", apiKey: "API 密钥", - baseUrl: "基础 URL", - capabilities: "功能", - enabled: "已启用", - disabled: "已禁用", - deleteConfirm: "确定要删除此模型吗?", deleteSuccess: "模型删除成功", saveSuccess: "模型保存成功", - providerStatus: "服务商状态", - connectionOk: "连接正常", - connectionFailed: "连接失败", - changeEmbeddingWarning: "更改默认嵌入模型将影响新的来源。现有来源可能需要重建索引。", - changeEmbeddingTitle: "更改默认嵌入模型?", - aiProviders: "AI 服务商", - providerConfigDesc: "通过环境变量配置服务商以启用其模型。", - configuredCount: "已配置 {count} / {total}", noModels: "暂无模型", - learnMore: "了解如何配置服务商 →", - seeLess: "收起", - seeAll: "查看全部 {count} 个服务商", - language_models: "语言模型", - embedding_models: "嵌入模型", - text_to_speech: "文字转语音", - speech_to_text: "语音转文字", - languageDesc: "聊天、内容转换和文本生成", - embeddingDesc: "语义搜索和向量嵌入", - ttsDesc: "根据文本生成音频", - sttDesc: "将语音转录为文本", - all: "全部", - noModelsConfigured: "未配置模型", - noProviderModelsConfigured: "未配置 {provider} 模型", - showMore: "显示更多 ({count})", discoverModels: "发现模型", noModelsFound: "未从此提供商找到模型", modelType: "模型类型", modelTypeHint: "选择要添加的模型类型。如果需要不同类型,请分批添加。", deleteModel: "删除模型", - deleteModelDesc: "确定要删除模型 “{name}” 吗?该操作无法撤销。", defaultAssignments: "默认模型分配", defaultAssignmentsDesc: "配置用于 Open Notebook 不同用途的默认模型", missingRequiredModels: "缺少必需的模型:{models}。如果没有这些模型,Open Notebook 可能无法正常运行。", selectModelPlaceholder: "选择一个模型", requiredModelPlaceholder: "⚠️ 必需 - 请选择一个模型", - whichModelToChoose: "我该选择哪个模型?→", chatModelLabel: "聊天模型", chatModelDesc: "用于聊天对话", transformationModelLabel: "转换模型", @@ -936,16 +840,9 @@ export const zhCN = { ttsModelDesc: "用于生成播客", sttModelLabel: "语音转文字模型", sttModelDesc: "用于音频转录", - addSpecificModel: "添加 {type} 模型", - addSpecificModelDesc: "从可用服务商配置一个新的 {type} 模型。", - noProvidersForType: "暂无可用于 {type} 模型的服务商", selectProviderPlaceholder: "选择服务商", providerRequired: "服务商是必填项", - modelNameRequired: "模型名称是必填项", modelRequired: "模型是必填项", - adding: "正在添加...", - azureHint: "对于 Azure,请使用部署名称作为模型名称", - enterModelName: "输入模型名称", embeddingChangeTitle: "嵌入模型变更", embeddingChangeConfirm: "您即将将嵌入模型从 {from} 更改为 {to}。", rebuildRequired: "重要提示:需要重建索引", @@ -959,7 +856,6 @@ export const zhCN = { changeModelOnly: "仅更改模型", changeAndRebuild: "更改并前往重建", autoAssign: "自动分配默认值", - autoAssignDesc: "为每个槽位自动分配最佳可用模型", autoAssigning: "正在分配...", autoAssignSuccess: "已自动分配 {count} 个默认模型", autoAssignNoModels: "没有可分配的模型。请先同步模型。", @@ -967,25 +863,16 @@ export const zhCN = { testModel: "测试模型", testModelSuccess: "模型测试通过", testModelFailed: "模型测试失败", - testingModel: "正在测试模型...", searchOrAddModel: "搜索或输入模型名称...", - addCustomModel: '添加 "{name}"', + addCustomModel: "添加 \"{name}\"", }, apiKeys: { title: "使用您自己的 API 密钥配置 AI", description: "将 API 密钥安全地存储在数据库中,以在 Open Notebook 中启用 AI 服务商。", - loadFailed: "加载 API 密钥状态失败", encryptionRequired: "未配置加密密钥", encryptionRequiredDescription: "请将 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量设置为任意密钥字符串,以启用将 API 密钥存储到数据库。", configured: "已配置", notConfigured: "未配置", - sourceDatabase: "数据库", - sourceEnvironment: "环境变量", - enterApiKey: "输入您的 API 密钥", - enterBaseUrl: "输入基础 URL", - saveSuccess: "API 密钥保存成功", - deleteSuccess: "API 密钥删除成功", - fromEnvironmentHint: "此密钥通过环境变量设置。保存新密钥将在数据库中覆盖它。", migrationAvailable: "检测到环境变量", migrationDescription: "{count} 个 API 密钥通过环境变量配置,可以迁移到数据库以便于管理。", migrateToDatabase: "迁移到数据库", @@ -993,67 +880,29 @@ export const zhCN = { migrationSuccess: "{count} 个 API 密钥迁移成功", migrationErrors: "{count} 个密钥迁移失败", migrationNothingToMigrate: "所有密钥已在数据库中", - serviceType: "服务类型", - serviceLlm: "语言模型 (LLM)", - serviceEmbedding: "嵌入", - serviceStt: "语音转文字 (STT)", - serviceTts: "文字转语音 (TTS)", - serviceEndpoints: "服务端点(可选)", - azureEndpointsHint: "如有需要,为每种服务类型配置不同的端点。", - endpointPlaceholder: "https://your-resource.openai.azure.com/", - openaiCompatibleHint: "配置 OpenAI 兼容的 API 端点。每种服务类型可以有自己的配置。", - baseUrlPlaceholder: "https://api.example.com/v1", learnMore: "了解如何配置 API 密钥 →", testConnection: "测试连接", - testing: "测试中...", testSuccess: "连接成功", testFailed: "连接测试失败", syncModels: "同步模型", - syncing: "同步中...", syncSuccess: "发现 {discovered} 个模型,新增 {new} 个", syncNoNew: "发现 {count} 个模型,全部已注册", syncFailed: "同步模型失败", - syncAllModels: "同步所有提供商", - syncAllSuccess: "在所有提供商中发现 {discovered} 个模型,新增 {new} 个", - modelsConfigured: "{count} 个模型", - noModelsConfigured: "无模型", - viewModels: "查看模型", - supportedTypes: "支持的类型", - typeLanguage: "语言", - typeEmbedding: "嵌入", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "API 端点", getApiKey: "获取 API 密钥", vertexProject: "GCP 项目 ID", vertexLocation: "区域", vertexCredentials: "服务账户 JSON 路径", - vertexCredentialsHint: "容器内 Google Cloud 服务账户 JSON 文件的路径。", - - // Multi-config translations - configsCount: "{count} 个配置", - configuredMultiple: "已配置", addConfig: "添加配置", editConfig: "编辑配置", deleteConfig: "删除配置", - setAsDefault: "设为默认", - defaultBadge: "默认", - defaultDescription: "此提供商的默认配置", configName: "配置名称", configNameHint: "此配置的描述性名称(例如:'生产环境'、'开发环境')", baseUrl: "基础 URL", - baseUrlHint: "默认:{url}", baseUrlOverrideHint: "仅在需要覆盖提供商默认 API 端点时更改此项。", - ollamaApiKeyHint: "仅 Ollama Cloud 需要 API 密钥。本地 Ollama 请留空。", - noConfigs: "暂无配置", - noConfigsHint: "添加配置以开始使用此提供商", deleteConfigConfirm: "确定要删除 '{name}' 吗?此操作无法撤销。", - setDefaultConfirm: "将 '{name}' 设为默认配置?", configSaveSuccess: "配置保存成功", configUpdateSuccess: "配置更新成功", configDeleteSuccess: "配置删除成功", - configSetDefaultSuccess: "默认配置已更新", - apiKeyHint: "输入此配置的 API 密钥", apiKeyEditHint: "留空以保留现有 API 密钥", }, setupBanner: { diff --git a/frontend/src/lib/locales/zh-TW/index.ts b/frontend/src/lib/locales/zh-TW/index.ts index 3d7c128..6e25ba4 100644 --- a/frontend/src/lib/locales/zh-TW/index.ts +++ b/frontend/src/lib/locales/zh-TW/index.ts @@ -1,29 +1,24 @@ export const zhTW = { common: { search: "搜尋...", - chat: '聊天', - notes: '筆記', create: "新增", new: "新建", cancel: "取消", - save: "儲存", delete: "刪除", edit: "編輯", - actions: "快捷操作", theme: "主題", signOut: "登出", + noMatches: "沒有找到匹配項", + tryDifferentSearch: "請嘗試使用不同的關鍵詞搜尋。", + light: "亮色", + dark: "暗色", + system: "系統", loading: "載入中...", note: "筆記", insight: "洞察", newSource: "新增來源", newNotebook: "新增筆記本", newPodcast: "新增播客", - nameRequired: "此為必填項", - noMatches: "沒有找到匹配項", - tryDifferentSearch: "請嘗試使用不同的關鍵詞搜尋。", - light: "亮色", - dark: "暗色", - system: "系統", language: "語言", english: "English", chinese: "簡體中文", @@ -41,31 +36,27 @@ export const zhTW = { warning: "警告", error: "錯誤", success: "成功", - sessions: "對話", model: "模型", - send: "發送", back: "返回", next: "下一步", done: "完成", processing: "處理中...", creating: "正在新增...", - tokenCount: "Token", - charCount: "字元", linked: "已連結", - added: "已於 {date} 新增", adding: "正在新增...", addSelected: "新增所選", customModel: "自訂模型", - messages: "訊息", failed: "失敗", current: "目前", - writeNote: '撰寫筆記', + save: "儲存", + writeNote: "撰寫筆記", batchMode: "批次模式", optional: "可選", type: "類型", title: "標題", created: "建立於 {time}", updated: "更新於 {time}", + actions: "快捷操作", noResults: "未找到結果", references: "引用", refreshPage: "請嘗試重新整理頁面", @@ -73,7 +64,8 @@ export const zhTW = { aiGenerated: "AI 生成", human: "人類", unknown: "未知", - details: "詳情", + notes: "筆記", + chat: "聊天", deleteForever: "永久刪除", connectionError: "連線錯誤", unableToConnect: "無法連線至 API 伺服器", @@ -86,7 +78,6 @@ export const zhTW = { checkConsoleLogs: "請檢查瀏覽器主控台以獲取詳細日誌(搜尋 🔧 [Config] 訊息)", yes: "是", no: "否", - simple: "簡單", saving: "正在儲存...", description: "描述", saveToNote: "儲存到筆記", @@ -101,9 +92,9 @@ export const zhTW = { saveChanges: "儲存更改", name: "名稱", default: "預設", + nameRequired: "此為必填項", modelConfiguration: "模型設定", resetToDefault: "重置為預設", - notFound: "未找到", reasoning: "推理過程", searchTerms: "搜尋詞", strategy: "策略", @@ -112,22 +103,19 @@ export const zhTW = { notebookLabel: "筆記本: {name}", itemNotFound: "未找到該 {type}", accessibility: { - navigation: "導覽", transformationViews: "轉換視圖", searchKB: "向知識庫提問或搜尋", - searchNotebooks: "搜尋筆記本", enterQuestion: "輸入您的問題以詢問知識庫", enterSearch: "輸入搜尋詞", searchKBBtn: "搜尋知識庫", podcastViews: "播客視圖", - chatSessions: "對話 Session", ytVideo: "YouTube 影片", askResponse: "提問回答", + searchNotebooks: "搜尋筆記本", }, url: "URL", errorDetails: "錯誤詳情", editTransformation: "編輯轉換規則", - comingSoon: "敬請期待", retry: "重試", traditionalChinese: "繁體中文", portuguese: "葡萄牙語", @@ -155,17 +143,16 @@ export const zhTW = { invalidSortOrder: "排序方向必須是 'asc' 或 'desc'", accessDenied: "檔案存取被拒絕", fileNotFoundOnServer: "伺服器上找不到該檔案", + searchFailed: "搜尋失敗", + askFailed: "提問失敗", + pleaseEnterQuestion: "請輸入問題", + pleaseConfigureModels: "請設定所有必選模型", failedToCreateSession: "新增對話失敗", failedToUpdateSession: "更新對話失敗", failedToDeleteSession: "刪除對話失敗", failedToSendMessage: "發送訊息失敗", - pleaseEnterQuestion: "請輸入問題", - pleaseConfigureModels: "請設定所有必選模型", - askFailed: "提問失敗", - searchFailed: "搜尋失敗", unauthorized: "無權存取,請檢查您的密碼", invalidPassword: "密碼錯誤", - missingAuth: "缺少身分驗證資訊", embeddingModelRequired: "此功能需要嵌入模型。請在模型設定中設定一個。", strategyModelNotFound: "未找到策略模型", answerModelNotFound: "未找到回答模型", @@ -206,7 +193,6 @@ export const zhTW = { passwordPlaceholder: "密碼", signingIn: "正在登入...", signIn: "登入", - unhandledError: "登入過程中出現未處理的錯誤", connectErrorHint: "無法連線至伺服器。請檢查 API 是否正在運行。", }, navigation: { @@ -217,8 +203,6 @@ export const zhTW = { sources: "來源", notebooks: "筆記本", askAndSearch: "詢問與搜尋", - search: "搜尋", - ask: "提問", podcasts: "播客", models: "模型", transformations: "轉換", @@ -228,6 +212,7 @@ export const zhTW = { nav: "導覽", language: "切換語言", theme: "主題", + ask: "提問", }, notebooks: { title: "筆記本", @@ -248,12 +233,8 @@ export const zhTW = { keepExclusiveSourcesLabel: "取消關聯並保留", activeNotebooks: "活動中的筆記本", archivedNotebooks: "封存的筆記本", - emptyDescription: "從新增您的第一個筆記本開始,組織您的研究。", - noActiveNotebooks: "沒有活動中的筆記本", - noArchivedNotebooks: "沒有封存的筆記本", notFound: "未找到筆記本", notFoundDesc: "請求的筆記本不存在。", - noDescription: "暫無描述...", updated: "已更新", namePlaceholder: "筆記本名稱", addDescription: "新增描述...", @@ -278,12 +259,7 @@ export const zhTW = { add: "新增來源", addNew: "新增新來源", addExisting: "新增現有來源", - allSourcesDescShort: "在此檢視所有來源。", - cannotSaveNoteNoNotebook: "無法儲存筆記:缺少筆記本 ID", - empty: "暫無來源", - emptyDesc: "新增您的第一個來源,開始構建您的知識庫。", delete: "刪除來源", - deleteMsg: "確定要刪除此來源嗎?此操作無法撤銷。", statusPreparing: "正在準備", statusQueued: "已排隊", statusProcessing: "正在處理", @@ -301,6 +277,11 @@ export const zhTW = { yes: "是", no: "否", loadingMore: "正在載入更多...", + noSourcesYet: "暫無來源", + allSourcesDescShort: "在此檢視所有來源。", + cannotSaveNoteNoNotebook: "無法儲存筆記:缺少筆記本 ID", + createFirstSource: "新增您的第一個來源開始構建知識庫。", + deleteSourceConfirm: "確定要刪除此來源嗎?", deleteConfirm: "確定要刪除嗎?", deleteConfirmWithTitle: "確定要刪除 \"{title}\" 嗎?", deleteSuccess: "來源刪除成功。注意:要從儲存中刪除檔案,必須在設定頁面中啟用「刪除檔案」選項。", @@ -318,16 +299,12 @@ export const zhTW = { sourceRequeued: "來源重試已加入隊列", sourceRequeuedDesc: "來源已重新加入處理隊列。", failedToRetry: "重試失敗", - failedToRetryDesc: "重試來源處理失敗。請重試。", sourcesAddedToNotebook: "{count} 個來源已新增到筆記本", failedToAddSourcesToNotebook: "新增來源到筆記本失敗", partialAddSuccess: "{success} 個來源已新增,{failed} 個失敗", sourceRemovedFromNotebook: "來源已成功從筆記本中移除", failedToRemoveSourceFromNotebook: "從筆記本中移除來源失敗", removeConfirm: "確定要從此筆記本移除嗎?", - noSourcesYet: "暫無來源", - createFirstSource: "新增您的第一個來源開始構建知識庫。", - deleteSourceConfirm: "確定要刪除此來源嗎?", checking: "正在檢查...", untitledSource: "未命名來源", maxItems: "最多 {count} 個", @@ -356,25 +333,50 @@ export const zhTW = { noInsightsYet: "暫無見解", createFirstInsight: "使用上方的轉換規則新增您的第一個見解", viewInsight: "查看見解", + deleteInsight: "刪除見解", + deleteInsightConfirm: "確定要刪除此見解嗎?此操作無法撤銷。", + insightGenerationStarted: "見解生成已開始,稍後將顯示。", + editNote: "編輯筆記", + createNote: "新增筆記", + addTitle: "新增標題...", + untitledNote: "無標題筆記", + writeNotePlaceholder: "在此處編寫您的筆記內容...", + saveNote: "儲存筆記", + createNoteBtn: "新增筆記", + createFirstNote: "新增您的第一條筆記,記錄見解與觀察。", + urlLabel: "URL(s) *", + fileLabel: "檔案(s) *", + textContentLabel: "文字內容 *", + enterUrlsPlaceholder: "每行輸入一個 URL\nhttps://example.com/article1\nhttps://example.com/article2", + batchUrlHint: "貼上多個 URL(每行一個)進行批次導入", + invalidUrlsDetected: "檢測到無效的 URL:", + lineLabel: "第 {line} 行", + fixInvalidUrls: "請修正或移除無效的 URL 以繼續", + selectMultipleFilesHint: "選擇多個檔案進行批次導入。支援:文件 (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD),媒體 (MP4, MP3, WAV, M4A),圖片 (JPG, PNG),歸檔 (ZIP)", + selectedFiles: "已選擇檔案:", + textPlaceholder: "在此處貼上或輸入您的內容...", + htmlDetected: "偵測到 HTML 內容。處理後將轉換為 Markdown。", + titlePlaceholder: "為您的來源取一個描述性的標題", + batchTitlesAuto: "將為每個來源自動生成標題。", + batchCommonSettings: "相同的筆記本和轉換將應用於所有項目。", + urlsCount: "{count} 個 URL", + filesCount: "{count} 個檔案", + addSource: "新增來源", + notEmbeddedAlert: "內容未嵌入向量", + notEmbeddedDesc: "此內容尚未為了向量搜尋進行嵌入。嵌入可以啟用進階搜尋功能並更好地發現內容。", + openOnYoutube: "在 YouTube 上開啟", + urlCopied: "URL 已複製到剪貼簿", viewSource: "查看來源", noInsightSelected: "未選擇見解", sourceInsight: "來源見解", manageNotebooks: "管理所屬筆記本", manageNotebooksDesc: "管理包含此來源的筆記本", noNotebooksAvailable: "暫無可用筆記本", - deleteInsight: "刪除見解", - deleteInsightConfirm: "確定要刪除此見解嗎?此操作無法撤銷。", - insightGenerationStarted: "見解生成已開始,稍後將顯示。", - notEmbeddedAlert: "內容未嵌入向量", - notEmbeddedDesc: "此內容尚未為了向量搜尋進行嵌入。嵌入可以啟用進階搜尋功能並更好地發現內容。", - openOnYoutube: "在 YouTube 上開啟", - urlCopied: "URL 已複製到剪貼簿", loadFailed: "載入來源詳情失敗", removeFromNotebook: "從筆記本移除", retryProcessing: "重試處理", deleteSource: "刪除來源", retry: "重試", - progress: "進度", addExistingTitle: "新增現有來源", addExistingDesc: "從您的所有筆記本中選擇已有的來源新增到當前筆記本。", searchPlaceholder: "通過名稱或 URL 搜尋來源...", @@ -382,35 +384,6 @@ export const zhTW = { showingFirst100: "僅顯示前 100 個來源。請使用搜尋功能查找特定來源。", selectedCount: "已選擇 {count} 個來源", added: "已新增於 {date}", - noNotesYet: '暫無筆記', - createFirstNote: "新增您的第一條筆記,記錄見解與觀察。", - deleteNote: '刪除筆記', - deleteNoteConfirm: '您確定要刪除此筆記嗎?此操作無法撤銷。', - editNote: '編輯筆記', - createNote: "新增筆記", - addTitle: '新增標題...', - untitledNote: '無標題筆記', - writeNotePlaceholder: '在此處編寫您的筆記內容...', - saveNote: '儲存筆記', - createNoteBtn: "新增筆記", - urlLabel: 'URL(s) *', - fileLabel: '檔案(s) *', - textContentLabel: '文字內容 *', - enterUrlsPlaceholder: '每行輸入一個 URL\nhttps://example.com/article1\nhttps://example.com/article2', - batchUrlHint: '貼上多個 URL(每行一個)進行批次導入', - invalidUrlsDetected: '檢測到無效的 URL:', - lineLabel: '第 {line} 行', - fixInvalidUrls: '請修正或移除無效的 URL 以繼續', - selectMultipleFilesHint: '選擇多個檔案進行批次導入。支援:文件 (PDF, DOC, DOCX, PPT, XLS, EPUB, TXT, MD),媒體 (MP4, MP3, WAV, M4A),圖片 (JPG, PNG),歸檔 (ZIP)', - selectedFiles: '已選擇檔案:', - textPlaceholder: '在此處貼上或輸入您的內容...', - htmlDetected: '偵測到 HTML 內容。處理後將轉換為 Markdown。', - titlePlaceholder: '為您的來源取一個描述性的標題', - batchTitlesAuto: '將為每個來源自動生成標題。', - batchCommonSettings: '相同的筆記本和轉換將應用於所有項目。', - urlsCount: '{count} 個 URL', - filesCount: '{count} 個檔案', - addSource: "新增來源", addUrl: "新增 URL", uploadFile: "上傳檔案", enterText: "輸入文字", @@ -435,8 +408,6 @@ export const zhTW = { batchFailed: "全部 {count} 個來源新增失敗", batchPartial: "{success} 個成功,{failed} 個失敗", submittingSource: "正在提交來源進行處理...", - contentRequired: "請提供所選來源類型所需的內容", - titleRequiredForText: "文字來源需要提供標題", processingBatchSources: "正在處理 {count} 個來源,請稍候...", processingSource: "正在處理您的來源,請稍候...", maxFilesAllowed: "每批最多允許 {count} 個檔案", @@ -445,25 +416,19 @@ export const zhTW = { sessions: "對話", sessionTitlePlaceholder: "在此輸入標題...", noSessions: "暫無對話", - startChatting: "開始針對您的來源進行聊天。", deleteSession: "刪除對話", deleteSessionDesc: "確定要刪除此聊天會話嗎?此操作無法撤銷。", sendPlaceholder: "向您的來源提問...", - newChat: "新建對話", sessionsTitle: "對話列表", - clearhistory: "清空歷史", - renameSession: "重新命名對話", - noSourcesLinked: "未連結來源", - thinking: "AI 正在思考...", chatWith: "與 {name} 對話", startConversation: "開始針對 {type} 進行對話", askQuestions: "提出問題以更好地理解內容", pressToSend: "按 {key} 發送", model: "模型", createToStart: "新增一個會話以開始。", - chatWithNotebook: '與筆記本對話', - unableToLoadChat: '無法載入聊天', - noDescription: '暫無描述', + chatWithNotebook: "與筆記本對話", + unableToLoadChat: "無法載入聊天", + noDescription: "暫無描述", startByCreating: "從新增您的第一個筆記本開始,組織您的研究。", messagesCount: "{count} 條訊息", sessionCreated: "聊天會話已建立", @@ -508,8 +473,10 @@ export const zhTW = { saveSuccess: "成功儲存到筆記本", saveError: "儲存到筆記本失敗", selectNotebook: "選擇筆記本", - createNewNotebook: "建立新筆記本", - cancel: "取消", + searchAndAsk: "搜尋與提問", + searchResultsFor: "搜尋 “{query}”", + askAbout: "提問關於 “{query}”", + orSearchKb: "或搜尋您的知識庫", saving: "儲存中...", advancedModelTitle: "進階模型選擇", advancedModelDesc: "為提問過程的每個階段選擇模型", @@ -520,10 +487,6 @@ export const zhTW = { selectAnswerPlaceholder: "選擇回答模型", selectFinalPlaceholder: "選擇最終回答模型", saveChanges: "儲存更改", - searchAndAsk: "搜尋與提問", - searchResultsFor: "搜尋 “{query}”", - askAbout: "提問關於 “{query}”", - orSearchKb: "或搜尋您的知識庫", processingQuestion: "正在處理您的問題...", }, podcasts: { @@ -637,8 +600,41 @@ export const zhTW = { createProfile: "建立簡介", createSpeakerFirst: "在新增單集簡介之前,請先建立一個發言人簡介。", noEpisodeProfiles: "暫無單集簡介。建立一個以啟動播客生成。", + speakerCreated: "發言人設定已建立", + speakerCreatedDesc: "發言人設定已準備就緒。", + failedToCreateSpeaker: "建立發言人設定失敗", + speakerUpdated: "發言人設定已更新", + speakerUpdatedDesc: "更改已成功儲存。", + failedToUpdateSpeaker: "更新發言人設定失敗", + speakerDeleted: "發言人設定已刪除", + speakerDeletedDesc: "設定已成功移除。", + failedToDeleteSpeaker: "刪除發言人設定失敗", + speakerDuplicated: "發言人設定已複製", + speakerDuplicatedDesc: "已建立設定副本。", + failedToDuplicateSpeaker: "複製發言人設定失敗", + generationStarted: "播客啟動生成", + generationStartedDesc: "劇集 \"{name}\" 正在建立中。", + failedToStartGeneration: "啟動播客生成失敗", + tryAgainMoment: "請稍後再試。", deleteProfileTitle: "刪除簡介?", deleteProfileDesc: "這將移除 “{name}”。現有單集將保留其資料,但新單集將不再使用此設定。", + profileCreated: "劇集設定已建立", + profileCreatedDesc: "新的劇集設定已準備就緒。", + failedToCreateProfile: "建立劇集設定失敗", + profileUpdated: "劇集設定已更新", + profileUpdatedDesc: "更改已成功儲存。", + failedToUpdateProfile: "更新劇集設定失敗", + profileDeleted: "劇集設定已刪除", + profileDeletedDesc: "設定已成功移除。", + failedToDeleteProfile: "刪除劇集設定失敗", + failedToDeleteProfileDesc: "請確保設定未在使用中並重試。", + profileDuplicated: "劇集設定已複製", + profileDuplicatedDesc: "已建立設定副本。", + failedToDuplicateProfile: "複製劇集設定失敗", + episodeDeleted: "劇集已刪除", + episodeDeletedDesc: "播客劇集已成功移除。", + failedToDeleteEpisode: "刪除劇集失敗", + failedToDeleteSpeakerDesc: "請確保設定未在使用中並重試。", outlineModel: "大綱模型", transcriptModel: "腳本模型", segments: "分段數量", @@ -701,42 +697,7 @@ export const zhTW = { speakerCountMin: "至少需要一個發言人", speakerCountMax: "最多只能設定 4 個發言人", delete: "刪除", - unknown: "未知", - deleteSuccess: "播客刪除成功", failedToDelete: "刪除播客失敗", - episodeDeleted: "劇集已刪除", - episodeDeletedDesc: "播客劇集已成功移除。", - failedToDeleteEpisode: "刪除劇集失敗", - profileCreated: "劇集設定已建立", - profileCreatedDesc: "新的劇集設定已準備就緒。", - failedToCreateProfile: "建立劇集設定失敗", - profileUpdated: "劇集設定已更新", - profileUpdatedDesc: "更改已成功儲存。", - failedToUpdateProfile: "更新劇集設定失敗", - profileDeleted: "劇集設定已刪除", - profileDeletedDesc: "設定已成功移除。", - failedToDeleteProfile: "刪除劇集設定失敗", - failedToDeleteProfileDesc: "請確保設定未在使用中並重試。", - profileDuplicated: "劇集設定已複製", - profileDuplicatedDesc: "已建立設定副本。", - failedToDuplicateProfile: "複製劇集設定失敗", - speakerCreated: "發言人設定已建立", - speakerCreatedDesc: "發言人設定已準備就緒。", - failedToCreateSpeaker: "建立發言人設定失敗", - speakerUpdated: "發言人設定已更新", - speakerUpdatedDesc: "更改已成功儲存。", - failedToUpdateSpeaker: "更新發言人設定失敗", - speakerDeleted: "發言人設定已刪除", - speakerDeletedDesc: "設定已成功移除。", - failedToDeleteSpeaker: "刪除發言人設定失敗", - failedToDeleteSpeakerDesc: "請確保設定未在使用中並重試。", - speakerDuplicated: "發言人設定已複製", - speakerDuplicatedDesc: "已建立設定副本。", - failedToDuplicateSpeaker: "複製發言人設定失敗", - generationStarted: "播客啟動生成", - generationStartedDesc: "劇集 \"{name}\" 正在建立中。", - failedToStartGeneration: "啟動播客生成失敗", - tryAgainMoment: "請稍後再試。", }, settings: { contentProcessing: "內容處理", @@ -772,13 +733,8 @@ export const zhTW = { title: "進階工具", desc: "針對進階使用者的調試和實用工具", systemInfo: "系統資訊", - systemInfoDesc: "查看底層系統組件的狀態", rebuildEmbeddings: "重建索引", rebuildEmbeddingsDesc: "為所有來源重建向量索引", - rebuildWarning: "此操作可能非常耗時,具體取決於您的來源數量。它將清除現有的向量索引並重新為所有內容生成嵌入。", - startRebuild: "開始重建", - rebuilding: "正在重建...", - rebuildSuccess: "索引重建已成功啟動", currentVersion: "目前版本", latestVersion: "最新版本", status: "狀態", @@ -823,105 +779,53 @@ export const zhTW = { defaultPrompt: "預設全局提示詞", defaultPromptDesc: "該提示詞將被新增到您所有的轉換提示詞中", defaultPromptPlaceholder: "輸入您的預設轉換指令...", - saveDefault: "儲存預設設定", listTitle: "自訂轉換", createNew: "新建轉換", - testInPlayground: "在實驗室測試", inputLabel: "輸入文本", - inputPlaceholder: '請輸入要轉換的文本...', - outputLabel: '輸出', - runTest: '運行轉換', - running: '運行中...', - selectToStart: '選擇一個轉換規則開始', - name: '名稱', - namePlaceholder: '唯一標識符,例如 key_topics', - titlePlaceholder: '顯示名稱,預設為名稱', - promptPlaceholder: '編寫驅動此轉換的提示詞...', - descriptionPlaceholder: '描述此轉換的作用。', - suggestDefault: '新來源預設建議', - promptHint: '提示詞應根據源內容編寫。您可以要求模型總結、提取見解或生成表格等結構化輸出。', - createSuccess: '轉換規則建立成功', - updateSuccess: '轉換規則更新成功', - deleteSuccess: '轉換規則刪除成功', + inputPlaceholder: "請輸入要轉換的文本...", + outputLabel: "輸出", + runTest: "運行轉換", + running: "運行中...", + selectToStart: "選擇一個轉換規則開始", + name: "名稱", + namePlaceholder: "唯一標識符,例如 key_topics", + titlePlaceholder: "顯示名稱,預設為名稱", + promptPlaceholder: "編寫驅動此轉換的提示詞...", + descriptionPlaceholder: "描述此轉換的作用。", + suggestDefault: "新來源預設建議", + promptHint: "提示詞應根據源內容編寫。您可以要求模型總結、提取見解或生成表格等結構化輸出。", + createSuccess: "轉換規則建立成功", + updateSuccess: "轉換規則更新成功", + deleteSuccess: "轉換規則刪除成功", noTransformations: "暫無轉換規則", createOne: "建立一個轉換規則以開始", - deleteDesc: "刪除此轉換無法復原。", selectModel: "選擇模型", deleteConfirm: "確定要刪除此轉換規則嗎?", model: "模型", systemPrompt: "系統提示詞", - type: "類型", - extraction: "提取", - summary: "摘要", - custom: "自訂", - saveChanges: "儲存更改", overrideModelDesc: "為此對話會話覆蓋預設模型。留空則使用系統預設。", sessionUseReplacement: "此會話將使用 {name} 而不是預設模型。", systemDefault: "系統預設", }, models: { - title: "模型管理", - desc: "設定用於 Open Notebook 不同用途的 AI 模型", - failedToLoad: "載入模型資料失敗", - language: "語言模型", embedding: "嵌入模型", tts: "文字轉語音", stt: "語音轉文字", - providers: "提供商", - defaultModels: "預設模型", - status: "狀態", - notConfigured: "未設定", - active: "活動", - inactive: "不活動", - configure: "設定", - saveChanges: "儲存更改", - addModel: "新增模型", - modelName: "模型名稱", provider: "提供商", apiKey: "API 密鑰", - baseUrl: "基礎 URL", - capabilities: "功能", - enabled: "已啟用", - disabled: "已禁用", - deleteConfirm: "確定要刪除此模型嗎?", deleteSuccess: "模型刪除成功", saveSuccess: "模型儲存成功", - providerStatus: "提供商狀態", - connectionOk: "連線正常", - connectionFailed: "連線失敗", - changeEmbeddingWarning: "更改預設嵌入模型將影響新的來源。現有來源可能需要重建索引。", - changeEmbeddingTitle: "更改預設嵌入模型?", - aiProviders: "AI 提供商", - providerConfigDesc: "通過環境變數設定提供商以啟用其模型。", - configuredCount: "已設定 {count} / {total}", noModels: "暫無模型", - learnMore: "了解如何設定提供商 →", - seeLess: "收起", - seeAll: "查看全部 {count} 個提供商", - language_models: "語言模型", - embedding_models: "嵌入模型", - text_to_speech: "文字轉語音", - speech_to_text: "語音轉文字", - languageDesc: "聊天、內容轉換和文本生成", - embeddingDesc: "語義搜尋和向量嵌入", - ttsDesc: "根據文本生成音訊", - sttDesc: "將語音轉錄為文本", - all: "全部", - noModelsConfigured: "未設定模型", - noProviderModelsConfigured: "未設定 {provider} 模型", - showMore: "顯示更多 ({count})", discoverModels: "探索模型", noModelsFound: "未從此提供商找到模型", modelType: "模型類型", modelTypeHint: "選擇要新增的模型類型。如果需要不同類型,請分批新增。", deleteModel: "刪除模型", - deleteModelDesc: "確定要刪除模型 “{name}” 嗎?該操作無法撤銷。", defaultAssignments: "預設模型分配", defaultAssignmentsDesc: "設定用於 Open Notebook 不同用途的預設模型", missingRequiredModels: "缺少必需的模型:{models}。如果没有這些模型,Open Notebook 可能無法正常運行。", selectModelPlaceholder: "選擇一個模型", requiredModelPlaceholder: "⚠️ 必需 - 請選擇一個模型", - whichModelToChoose: "我該選擇哪個模型?→", chatModelLabel: "聊天模型", chatModelDesc: "用於聊天對話", transformationModelLabel: "轉換模型", @@ -936,16 +840,9 @@ export const zhTW = { ttsModelDesc: "用於生成播客", sttModelLabel: "語音轉文字模型", sttModelDesc: "用於音訊轉錄", - addSpecificModel: "新增 {type} 模型", - addSpecificModelDesc: "從可用提供商設定一個新的 {type} 模型。", - noProvidersForType: "暫無可用於 {type} 模型的提供商", selectProviderPlaceholder: "選擇提供商", providerRequired: "提供商是必填項", - modelNameRequired: "模型名稱是必填項", modelRequired: "模型是必填項", - adding: "正在新增...", - azureHint: "對於 Azure,請使用部署名稱作為模型名稱", - enterModelName: "輸入模型名稱", embeddingChangeTitle: "嵌入模型變更", embeddingChangeConfirm: "您即將將嵌入模型從 {from} 更改為 {to}。", rebuildRequired: "重要提示:需要重建索引", @@ -959,7 +856,6 @@ export const zhTW = { changeModelOnly: "僅更改模型", changeAndRebuild: "更改並前往重建", autoAssign: "自動指派預設值", - autoAssignDesc: "為每個插槽自動指派最佳可用模型", autoAssigning: "正在指派...", autoAssignSuccess: "已自動指派 {count} 個預設模型", autoAssignNoModels: "沒有可指派的模型。請先同步模型。", @@ -967,25 +863,16 @@ export const zhTW = { testModel: "測試模型", testModelSuccess: "模型測試通過", testModelFailed: "模型測試失敗", - testingModel: "正在測試模型...", searchOrAddModel: "搜尋或輸入模型名稱...", - addCustomModel: '新增 "{name}"', + addCustomModel: "新增 \"{name}\"", }, apiKeys: { title: "使用您自己的 API 金鑰設定 AI", description: "將 API 金鑰安全地儲存在資料庫中,以在 Open Notebook 中啟用 AI 服務商。", - loadFailed: "載入 API 金鑰狀態失敗", encryptionRequired: "未設定加密金鑰", encryptionRequiredDescription: "請將 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數設定為任意密鑰字串,以啟用將 API 金鑰儲存至資料庫。", configured: "已設定", notConfigured: "未設定", - sourceDatabase: "資料庫", - sourceEnvironment: "環境變數", - enterApiKey: "輸入您的 API 金鑰", - enterBaseUrl: "輸入基礎 URL", - saveSuccess: "API 金鑰儲存成功", - deleteSuccess: "API 金鑰刪除成功", - fromEnvironmentHint: "此金鑰通過環境變數設定。儲存新金鑰將在資料庫中覆蓋它。", migrationAvailable: "偵測到環境變數", migrationDescription: "{count} 個 API 金鑰通過環境變數設定,可以遷移到資料庫以便於管理。", migrateToDatabase: "遷移到資料庫", @@ -993,67 +880,29 @@ export const zhTW = { migrationSuccess: "{count} 個 API 金鑰遷移成功", migrationErrors: "{count} 個金鑰遷移失敗", migrationNothingToMigrate: "所有金鑰已在資料庫中", - serviceType: "服務類型", - serviceLlm: "語言模型 (LLM)", - serviceEmbedding: "嵌入", - serviceStt: "語音轉文字 (STT)", - serviceTts: "文字轉語音 (TTS)", - serviceEndpoints: "服務端點(選填)", - azureEndpointsHint: "如有需要,為每種服務類型設定不同的端點。", - endpointPlaceholder: "https://your-resource.openai.azure.com/", - openaiCompatibleHint: "設定 OpenAI 相容的 API 端點。每種服務類型可以有自己的設定。", - baseUrlPlaceholder: "https://api.example.com/v1", learnMore: "瞭解如何設定 API 金鑰 →", testConnection: "測試連線", - testing: "測試中...", testSuccess: "連線成功", testFailed: "連線測試失敗", syncModels: "同步模型", - syncing: "同步中...", syncSuccess: "發現 {discovered} 個模型,新增 {new} 個", syncNoNew: "發現 {count} 個模型,全部已註冊", syncFailed: "同步模型失敗", - syncAllModels: "同步所有供應商", - syncAllSuccess: "在所有供應商中發現 {discovered} 個模型,新增 {new} 個", - modelsConfigured: "{count} 個模型", - noModelsConfigured: "無模型", - viewModels: "查看模型", - supportedTypes: "支援的類型", - typeLanguage: "語言", - typeEmbedding: "嵌入", - typeTts: "TTS", - typeStt: "STT", - apiEndpoint: "API 端點", getApiKey: "取得 API 金鑰", vertexProject: "GCP 專案 ID", vertexLocation: "區域", vertexCredentials: "服務帳戶 JSON 路徑", - vertexCredentialsHint: "容器內 Google Cloud 服務帳戶 JSON 檔案的路徑。", - - // Multi-config translations - configsCount: "{count} 個設定", - configuredMultiple: "已設定", addConfig: "新增設定", editConfig: "編輯設定", deleteConfig: "刪除設定", - setAsDefault: "設為預設", - defaultBadge: "預設", - defaultDescription: "此供應商的預設設定", configName: "設定名稱", configNameHint: "此設定的描述性名稱(例如:'生產環境'、'開發環境')", baseUrl: "基礎 URL", - baseUrlHint: "預設:{url}", baseUrlOverrideHint: "僅在需要覆蓋提供商預設 API 端點時更改此項。", - ollamaApiKeyHint: "僅 Ollama Cloud 需要 API 金鑰。本地 Ollama 請留空。", - noConfigs: "暫無設定", - noConfigsHint: "新增設定以開始使用此供應商", deleteConfigConfirm: "確定要刪除 '{name}' 嗎?此操作無法撤銷。", - setDefaultConfirm: "將 '{name}' 設為預設設定?", configSaveSuccess: "設定儲存成功", configUpdateSuccess: "設定更新成功", configDeleteSuccess: "設定刪除成功", - configSetDefaultSuccess: "預設設定已更新", - apiKeyHint: "輸入此設定的 API 金鑰", apiKeyEditHint: "留空以保留現有 API 金鑰", }, setupBanner: { From d28bff48882c2bf551fc3573908d9dc41150bc0e Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 14 Feb 2026 20:34:41 -0300 Subject: [PATCH 15/27] feat: add French (fr-FR) language support (#581) - Add fr-FR locale with French translations (875 keys) - Register fr-FR in locale index, LanguageToggle, and date-locale - Add common.french key to all existing locale files - Sync fr-FR key structure to match current en-US after cleanup Based on #514, rebased and adapted to the current locale structure. Closes #481 Co-authored-by: saikrishna-prathapaneni --- .../src/components/common/LanguageToggle.tsx | 6 + frontend/src/lib/locales/en-US/index.ts | 1 + frontend/src/lib/locales/fr-FR/index.ts | 917 ++++++++++++++++++ frontend/src/lib/locales/index.ts | 7 +- frontend/src/lib/locales/it-IT/index.ts | 1 + frontend/src/lib/locales/ja-JP/index.ts | 1 + frontend/src/lib/locales/pt-BR/index.ts | 1 + frontend/src/lib/locales/ru-RU/index.ts | 1 + frontend/src/lib/locales/zh-CN/index.ts | 1 + frontend/src/lib/locales/zh-TW/index.ts | 1 + frontend/src/lib/utils/date-locale.ts | 3 +- 11 files changed, 937 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/locales/fr-FR/index.ts diff --git a/frontend/src/components/common/LanguageToggle.tsx b/frontend/src/components/common/LanguageToggle.tsx index bf168c5..ba8e84b 100644 --- a/frontend/src/components/common/LanguageToggle.tsx +++ b/frontend/src/components/common/LanguageToggle.tsx @@ -64,6 +64,12 @@ export function LanguageToggle({ iconOnly = false }: LanguageToggleProps) { > {t.common.japanese} + setLanguage('fr-FR')} + className={currentLang === 'fr-FR' || currentLang.startsWith('fr') ? 'bg-accent' : ''} + > + {t.common.french} + setLanguage('ru-RU')} className={currentLang === 'ru-RU' || currentLang.startsWith('ru') ? 'bg-accent' : ''} diff --git a/frontend/src/lib/locales/en-US/index.ts b/frontend/src/lib/locales/en-US/index.ts index e02bc17..d1bb54c 100644 --- a/frontend/src/lib/locales/en-US/index.ts +++ b/frontend/src/lib/locales/en-US/index.ts @@ -23,6 +23,7 @@ export const enUS = { english: "English", chinese: "简体中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "Source", notebook: "Notebook", diff --git a/frontend/src/lib/locales/fr-FR/index.ts b/frontend/src/lib/locales/fr-FR/index.ts new file mode 100644 index 0000000..9d97a21 --- /dev/null +++ b/frontend/src/lib/locales/fr-FR/index.ts @@ -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", + }, +} diff --git a/frontend/src/lib/locales/index.ts b/frontend/src/lib/locales/index.ts index e1da611..b22a6e2 100644 --- a/frontend/src/lib/locales/index.ts +++ b/frontend/src/lib/locales/index.ts @@ -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 }; diff --git a/frontend/src/lib/locales/it-IT/index.ts b/frontend/src/lib/locales/it-IT/index.ts index 4dfad9b..cf58ae5 100644 --- a/frontend/src/lib/locales/it-IT/index.ts +++ b/frontend/src/lib/locales/it-IT/index.ts @@ -23,6 +23,7 @@ export const itIT = { english: "English", chinese: "简体中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "Fonte", notebook: "Quaderno", diff --git a/frontend/src/lib/locales/ja-JP/index.ts b/frontend/src/lib/locales/ja-JP/index.ts index daf6cdf..7014bb2 100644 --- a/frontend/src/lib/locales/ja-JP/index.ts +++ b/frontend/src/lib/locales/ja-JP/index.ts @@ -23,6 +23,7 @@ export const jaJP = { english: "English", chinese: "简体中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "ソース", notebook: "ノートブック", diff --git a/frontend/src/lib/locales/pt-BR/index.ts b/frontend/src/lib/locales/pt-BR/index.ts index 1b657b8..e130931 100644 --- a/frontend/src/lib/locales/pt-BR/index.ts +++ b/frontend/src/lib/locales/pt-BR/index.ts @@ -23,6 +23,7 @@ export const ptBR = { english: "English", chinese: "简体中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "Fonte", notebook: "Caderno", diff --git a/frontend/src/lib/locales/ru-RU/index.ts b/frontend/src/lib/locales/ru-RU/index.ts index 7410b81..4e51e21 100644 --- a/frontend/src/lib/locales/ru-RU/index.ts +++ b/frontend/src/lib/locales/ru-RU/index.ts @@ -23,6 +23,7 @@ export const ruRU = { english: "English", chinese: "简体中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "Источник", notebook: "Блокнот", diff --git a/frontend/src/lib/locales/zh-CN/index.ts b/frontend/src/lib/locales/zh-CN/index.ts index 8fbb2fe..3b0840a 100644 --- a/frontend/src/lib/locales/zh-CN/index.ts +++ b/frontend/src/lib/locales/zh-CN/index.ts @@ -23,6 +23,7 @@ export const zhCN = { english: "English", chinese: "简体中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "来源", notebook: "笔记本", diff --git a/frontend/src/lib/locales/zh-TW/index.ts b/frontend/src/lib/locales/zh-TW/index.ts index 6e25ba4..8dbf438 100644 --- a/frontend/src/lib/locales/zh-TW/index.ts +++ b/frontend/src/lib/locales/zh-TW/index.ts @@ -23,6 +23,7 @@ export const zhTW = { english: "English", chinese: "簡體中文", japanese: "日本語", + french: "Français", russian: "Русский", source: "來源", notebook: "筆記本", diff --git a/frontend/src/lib/utils/date-locale.ts b/frontend/src/lib/utils/date-locale.ts index 4fd976d..eeb2216 100644 --- a/frontend/src/lib/utils/date-locale.ts +++ b/frontend/src/lib/utils/date-locale.ts @@ -1,4 +1,4 @@ -import { zhCN, enUS, zhTW, ptBR, ja, ru, Locale } from 'date-fns/locale' +import { zhCN, enUS, zhTW, ptBR, ja, fr, ru, Locale } from 'date-fns/locale' /** * Mapping of language codes to date-fns locales. @@ -10,6 +10,7 @@ const LOCALE_MAP: Record = { 'en-US': enUS, 'pt-BR': ptBR, 'ja-JP': ja, + 'fr-FR': fr, 'ru-RU': ru, } From 78ae2096e66a6a2b436d7a6a3dcd9826adc45669 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sat, 14 Feb 2026 21:06:00 -0300 Subject: [PATCH 16/27] chore: bump version to 1.7.1 --- CHANGELOG.md | 20 ++++++++++++++++++++ CLAUDE.md | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 563bd3e..7b974ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [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 diff --git a/CLAUDE.md b/CLAUDE.md index 39c3bc3..bed67c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -218,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.1 diff --git a/pyproject.toml b/pyproject.toml index 553780c..cb83a40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "open-notebook" -version = "1.7.0" +version = "1.7.1" description = "An open source implementation of a research assistant, inspired by Google Notebook LM" authors = [ {name = "Luis Novo", email = "lfnovo@gmail.com"} diff --git a/uv.lock b/uv.lock index d269800..1bb6675 100644 --- a/uv.lock +++ b/uv.lock @@ -2095,7 +2095,7 @@ wheels = [ [[package]] name = "open-notebook" -version = "1.7.0" +version = "1.7.1" source = { editable = "." } dependencies = [ { name = "ai-prompter" }, From e66111b0decd4157f4d31f98674d5bc95fdce231 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Sun, 15 Feb 2026 08:32:22 -0300 Subject: [PATCH 17/27] fix: bump esperanto to 2.19.3 to fix openai_compatible provider name Esperanto 2.19.3 normalizes provider names by converting underscores to hyphens, fixing the ValueError when using openai_compatible. Closes #570 --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index cb83a40..28aa707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dependencies = [ "httpx[socks]>=0.27.0", "content-core>=1.14.1,<2", "ai-prompter>=0.3,<1", - "esperanto>=2.19.2,<3", + "esperanto>=2.19.3,<3", "surrealdb>=1.0.4", "podcast-creator>=0.9,<1", "surreal-commands>=1.3.1,<2", diff --git a/uv.lock b/uv.lock index 1bb6675..9c328cd 100644 --- a/uv.lock +++ b/uv.lock @@ -637,15 +637,15 @@ wheels = [ [[package]] name = "esperanto" -version = "2.19.2" +version = "2.19.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a8/50/cb258aa994d190d327b43cfc760b7d4db7714c1be57158233643606fb3d2/esperanto-2.19.2.tar.gz", hash = "sha256:cfe34893c31619dd65502787d54ab8182445abf9827cbff8bbbe13314201c8c1", size = 832654, upload-time = "2026-02-14T22:05:29.5Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/9d/51d65e448e52aa76ceaaa9eb4d50cebb7c16ead6508eb56bf159cc22718d/esperanto-2.19.3.tar.gz", hash = "sha256:3be09ac90bd976b9299f26ef829bd1a46d3aa6137e09700032512cad883d531a", size = 832672, upload-time = "2026-02-15T02:07:48.58Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/45/cc/807d7c26d775cb512f2fa372613d7bd1939748d66d48873d1d5a2d7b4187/esperanto-2.19.2-py3-none-any.whl", hash = "sha256:c16ffe084dd39f417d9660e4cc1fd0134b680d6d762118fa0007b66c406f6f98", size = 202161, upload-time = "2026-02-14T22:05:28.206Z" }, + { url = "https://files.pythonhosted.org/packages/d2/9b/fc5963cf21165b1e4f135b365ce7d78c9d8fd64e959434ff05521ca11c30/esperanto-2.19.3-py3-none-any.whl", hash = "sha256:a0dfe5f65e24a4b892fbe43c0abc091ae10406bd40e9aed9b0372f75f7358d40", size = 202177, upload-time = "2026-02-15T02:07:50.213Z" }, ] [[package]] @@ -2149,7 +2149,7 @@ dev = [ requires-dist = [ { name = "ai-prompter", specifier = ">=0.3,<1" }, { name = "content-core", specifier = ">=1.14.1,<2" }, - { name = "esperanto", specifier = ">=2.19.2,<3" }, + { name = "esperanto", specifier = ">=2.19.3,<3" }, { name = "fastapi", specifier = ">=0.104.0" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27.0" }, { name = "ipykernel", marker = "extra == 'dev'", specifier = ">=6.29.5" }, From d147994b9246a8b2509570603e98fe60ee4ea572 Mon Sep 17 00:00:00 2001 From: Luis Novo Date: Mon, 16 Feb 2026 14:46:42 -0300 Subject: [PATCH 18/27] fix: resolve translation proxy shadowing `name` keys The Proxy's get handler checked `prop in target` before looking up translations. Since the target is a function, `Function.name` is a built-in property that shadowed translation keys like `t.common.name`, causing the raw proxy path to render instead of the translated string. Move translation lookup before target property checks so i18n keys always take priority. Also remove unnecessary `|| 'Name'` fallback in CreateNotebookDialog. --- .../src/components/notebooks/CreateNotebookDialog.tsx | 2 +- frontend/src/lib/hooks/use-translation.ts | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/notebooks/CreateNotebookDialog.tsx b/frontend/src/components/notebooks/CreateNotebookDialog.tsx index a224c34..ced84c4 100644 --- a/frontend/src/components/notebooks/CreateNotebookDialog.tsx +++ b/frontend/src/components/notebooks/CreateNotebookDialog.tsx @@ -75,7 +75,7 @@ export function CreateNotebookDialog({ open, onOpenChange }: CreateNotebookDialo
- + Date: Mon, 16 Feb 2026 14:46:48 -0300 Subject: [PATCH 19/27] fix: prevent dialog content overflow with long unbroken strings Long strings without spaces caused the Add Source dialog to expand beyond the viewport due to field-sizing-content on textareas propagating width through CSS grid/flex layout. Add min-w-0 to Textarea, WizardContainer content layers, and the AddSourceDialog form to prevent flex/grid items from expanding to fit content. Add overflow-hidden to base DialogContent as a safeguard. --- frontend/src/components/sources/AddSourceDialog.tsx | 2 +- frontend/src/components/ui/dialog.tsx | 2 +- frontend/src/components/ui/textarea.tsx | 2 +- frontend/src/components/ui/wizard-container.tsx | 10 +++++----- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/sources/AddSourceDialog.tsx b/frontend/src/components/sources/AddSourceDialog.tsx index a49b2d4..3f31adb 100644 --- a/frontend/src/components/sources/AddSourceDialog.tsx +++ b/frontend/src/components/sources/AddSourceDialog.tsx @@ -542,7 +542,7 @@ export function AddSourceDialog({ - + ) {