Spaces:
Paused
Paused
Commit ·
bd0c393
0
Parent(s):
Deploy Open Notebook to HuggingFace Spaces
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +52 -0
- .env.example +59 -0
- .gitattributes +11 -0
- .github/ISSUE_TEMPLATE/bug_report.yml +104 -0
- .github/ISSUE_TEMPLATE/config.yml +11 -0
- .github/ISSUE_TEMPLATE/feature_request.yml +65 -0
- .github/ISSUE_TEMPLATE/installation_issue.yml +148 -0
- .github/pull_request_template.md +107 -0
- .github/workflows/build-and-release.yml +298 -0
- .github/workflows/build-dev.yml +273 -0
- .github/workflows/test.yml +59 -0
- .gitignore +144 -0
- .python-version +1 -0
- .worktreeinclude +5 -0
- CHANGELOG.md +367 -0
- CLAUDE.md +218 -0
- CONFIGURATION.md +36 -0
- CONTRIBUTING.md +29 -0
- Dockerfile +149 -0
- Dockerfile.hf +149 -0
- Dockerfile.single +106 -0
- LICENSE +17 -0
- MAINTAINER_GUIDE.md +19 -0
- Makefile +210 -0
- README.dev.md +449 -0
- README.hf.md +75 -0
- README.md +75 -0
- api/CLAUDE.md +260 -0
- api/__init__.py +0 -0
- api/auth.py +114 -0
- api/chat_service.py +168 -0
- api/client.py +529 -0
- api/command_service.py +92 -0
- api/context_service.py +29 -0
- api/credentials_service.py +915 -0
- api/embedding_service.py +27 -0
- api/episode_profiles_service.py +112 -0
- api/insights_service.py +100 -0
- api/main.py +322 -0
- api/models.py +693 -0
- api/models_service.py +112 -0
- api/notebook_service.py +87 -0
- api/notes_service.py +103 -0
- api/podcast_api_service.py +125 -0
- api/podcast_service.py +206 -0
- api/routers/__init__.py +0 -0
- api/routers/auth.py +27 -0
- api/routers/chat.py +526 -0
- api/routers/commands.py +166 -0
- api/routers/config.py +160 -0
.dockerignore
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Git
|
| 2 |
+
.git
|
| 3 |
+
.gitignore
|
| 4 |
+
|
| 5 |
+
# Python
|
| 6 |
+
__pycache__
|
| 7 |
+
*.pyc
|
| 8 |
+
*.pyo
|
| 9 |
+
*.pyd
|
| 10 |
+
.venv
|
| 11 |
+
venv
|
| 12 |
+
ENV
|
| 13 |
+
env
|
| 14 |
+
.pytest_cache
|
| 15 |
+
.mypy_cache
|
| 16 |
+
.ruff_cache
|
| 17 |
+
|
| 18 |
+
# Frontend
|
| 19 |
+
frontend/node_modules
|
| 20 |
+
frontend/.next
|
| 21 |
+
frontend/dist
|
| 22 |
+
frontend/out
|
| 23 |
+
frontend/.env*
|
| 24 |
+
frontend/*.log
|
| 25 |
+
|
| 26 |
+
# Project data
|
| 27 |
+
.antigravity
|
| 28 |
+
.gemini
|
| 29 |
+
tmp
|
| 30 |
+
data
|
| 31 |
+
mydata
|
| 32 |
+
notebook_data
|
| 33 |
+
surreal_data
|
| 34 |
+
surreal-data
|
| 35 |
+
surreal_single_data
|
| 36 |
+
*.db
|
| 37 |
+
*.log
|
| 38 |
+
docker.env
|
| 39 |
+
.env
|
| 40 |
+
docker-compose*
|
| 41 |
+
|
| 42 |
+
# Documentation & CI (not needed in image)
|
| 43 |
+
docs
|
| 44 |
+
.github
|
| 45 |
+
|
| 46 |
+
# IDE and OS files
|
| 47 |
+
.vscode
|
| 48 |
+
.idea
|
| 49 |
+
*.swp
|
| 50 |
+
*.swo
|
| 51 |
+
*~
|
| 52 |
+
.DS_Store
|
.env.example
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Open Notebook Configuration
|
| 2 |
+
# Copy this file to .env and customize as needed
|
| 3 |
+
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# REQUIRED
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
# Encryption key for storing API credentials securely in the database
|
| 9 |
+
# Change this to any secret string (minimum 16 characters recommended)
|
| 10 |
+
OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
|
| 11 |
+
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# DATABASE (Default values work with docker-compose.yml)
|
| 14 |
+
# =============================================================================
|
| 15 |
+
|
| 16 |
+
SURREAL_URL=ws://surrealdb:8000/rpc
|
| 17 |
+
SURREAL_USER=root
|
| 18 |
+
SURREAL_PASSWORD=root
|
| 19 |
+
SURREAL_NAMESPACE=open_notebook
|
| 20 |
+
SURREAL_DATABASE=open_notebook
|
| 21 |
+
|
| 22 |
+
# =============================================================================
|
| 23 |
+
# OPTIONAL: AI Provider API Keys
|
| 24 |
+
# =============================================================================
|
| 25 |
+
# You can configure these via the UI (Settings → API Keys) or set them here
|
| 26 |
+
# UI configuration is recommended for better security and flexibility
|
| 27 |
+
|
| 28 |
+
# OpenAI
|
| 29 |
+
# OPENAI_API_KEY=sk-...
|
| 30 |
+
|
| 31 |
+
# Anthropic
|
| 32 |
+
# ANTHROPIC_API_KEY=sk-ant-...
|
| 33 |
+
|
| 34 |
+
# Google AI
|
| 35 |
+
# GOOGLE_API_KEY=...
|
| 36 |
+
|
| 37 |
+
# Groq
|
| 38 |
+
# GROQ_API_KEY=gsk_...
|
| 39 |
+
|
| 40 |
+
# =============================================================================
|
| 41 |
+
# OPTIONAL: Advanced Configuration
|
| 42 |
+
# =============================================================================
|
| 43 |
+
|
| 44 |
+
# External API URL (for webhooks, callbacks, etc.)
|
| 45 |
+
# API_URL=http://localhost:5055
|
| 46 |
+
|
| 47 |
+
# Ollama endpoint (if running locally)
|
| 48 |
+
# OLLAMA_BASE_URL=http://ollama:11434
|
| 49 |
+
|
| 50 |
+
# Content processing
|
| 51 |
+
# CHUNK_SIZE=1500
|
| 52 |
+
# CHUNK_OVERLAP=150
|
| 53 |
+
|
| 54 |
+
# Security
|
| 55 |
+
# BASIC_AUTH_USERNAME=admin
|
| 56 |
+
# BASIC_AUTH_PASSWORD=secret
|
| 57 |
+
|
| 58 |
+
# For more configuration options, see:
|
| 59 |
+
# https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/environment-reference.md
|
.gitattributes
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ensure shell scripts always use LF so they run in Linux containers (e.g. Docker)
|
| 2 |
+
*.sh text eol=lf
|
| 3 |
+
*.gif filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.svg filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.wav filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.ico filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
.github/ISSUE_TEMPLATE/bug_report.yml
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: 🐛 Bug Report
|
| 2 |
+
description: Report a bug or unexpected behavior (app is running but misbehaving)
|
| 3 |
+
title: "[Bug]: "
|
| 4 |
+
labels: ["bug", "needs-triage"]
|
| 5 |
+
body:
|
| 6 |
+
- type: markdown
|
| 7 |
+
attributes:
|
| 8 |
+
value: |
|
| 9 |
+
Thanks for reporting a bug! Please fill out the information below to help us understand and fix the issue.
|
| 10 |
+
|
| 11 |
+
**Note**: If you're having installation or setup issues, please use the "Installation Issue" template instead.
|
| 12 |
+
|
| 13 |
+
- type: textarea
|
| 14 |
+
id: what-happened
|
| 15 |
+
attributes:
|
| 16 |
+
label: What did you do when it broke?
|
| 17 |
+
description: Describe the steps you took that led to the bug
|
| 18 |
+
placeholder: |
|
| 19 |
+
1. I went to the Notebooks page
|
| 20 |
+
2. I clicked on "Create New Notebook"
|
| 21 |
+
3. I filled in the form and clicked "Save"
|
| 22 |
+
4. Then the error occurred...
|
| 23 |
+
validations:
|
| 24 |
+
required: true
|
| 25 |
+
|
| 26 |
+
- type: textarea
|
| 27 |
+
id: how-broke
|
| 28 |
+
attributes:
|
| 29 |
+
label: How did it break?
|
| 30 |
+
description: What happened that was unexpected? What did you expect to happen instead?
|
| 31 |
+
placeholder: |
|
| 32 |
+
Expected: The notebook should be created and I should see it in the list
|
| 33 |
+
Actual: I got an error message saying "Failed to create notebook"
|
| 34 |
+
validations:
|
| 35 |
+
required: true
|
| 36 |
+
|
| 37 |
+
- type: textarea
|
| 38 |
+
id: logs-screenshots
|
| 39 |
+
attributes:
|
| 40 |
+
label: Logs or Screenshots
|
| 41 |
+
description: |
|
| 42 |
+
Please provide any error messages, logs, or screenshots that might help us understand the issue.
|
| 43 |
+
|
| 44 |
+
**How to get logs:**
|
| 45 |
+
- Docker: `docker compose logs -f open_notebook`
|
| 46 |
+
- Check browser console (F12 → Console tab)
|
| 47 |
+
placeholder: |
|
| 48 |
+
Paste logs here or drag and drop screenshots.
|
| 49 |
+
|
| 50 |
+
Error messages, stack traces, or browser console errors are very helpful!
|
| 51 |
+
validations:
|
| 52 |
+
required: false
|
| 53 |
+
|
| 54 |
+
- type: dropdown
|
| 55 |
+
id: version
|
| 56 |
+
attributes:
|
| 57 |
+
label: Open Notebook Version
|
| 58 |
+
description: Which version are you using?
|
| 59 |
+
options:
|
| 60 |
+
- v1-latest (Docker)
|
| 61 |
+
- v1-latest-single (Docker, deprecated)
|
| 62 |
+
- Latest from main branch
|
| 63 |
+
- Other (please specify in additional context)
|
| 64 |
+
validations:
|
| 65 |
+
required: true
|
| 66 |
+
|
| 67 |
+
- type: textarea
|
| 68 |
+
id: environment
|
| 69 |
+
attributes:
|
| 70 |
+
label: Environment
|
| 71 |
+
description: What environment are you running in?
|
| 72 |
+
placeholder: |
|
| 73 |
+
- OS: Ubuntu 22.04 / Windows 11 / macOS 14
|
| 74 |
+
- Browser: Chrome 120
|
| 75 |
+
validations:
|
| 76 |
+
required: false
|
| 77 |
+
|
| 78 |
+
- type: textarea
|
| 79 |
+
id: additional-context
|
| 80 |
+
attributes:
|
| 81 |
+
label: Additional Context
|
| 82 |
+
description: Any other information that might be helpful
|
| 83 |
+
placeholder: "This started happening after I upgraded to v1.5.0..."
|
| 84 |
+
validations:
|
| 85 |
+
required: false
|
| 86 |
+
|
| 87 |
+
- type: checkboxes
|
| 88 |
+
id: willing-to-contribute
|
| 89 |
+
attributes:
|
| 90 |
+
label: Contribution
|
| 91 |
+
description: Would you like to work on fixing this bug?
|
| 92 |
+
options:
|
| 93 |
+
- label: I am a developer and would like to work on fixing this issue (pending maintainer approval)
|
| 94 |
+
required: false
|
| 95 |
+
|
| 96 |
+
- type: markdown
|
| 97 |
+
attributes:
|
| 98 |
+
value: |
|
| 99 |
+
---
|
| 100 |
+
**Next Steps:**
|
| 101 |
+
1. A maintainer will review your bug report
|
| 102 |
+
2. If you checked the box above and want to fix it, please propose your solution approach
|
| 103 |
+
3. Wait for assignment before starting development
|
| 104 |
+
4. See our [Contributing Guide](https://github.com/lfnovo/open-notebook/blob/main/CONTRIBUTING.md) for more details
|
.github/ISSUE_TEMPLATE/config.yml
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
blank_issues_enabled: false
|
| 2 |
+
contact_links:
|
| 3 |
+
- name: 💬 Discord Community
|
| 4 |
+
url: https://discord.gg/37XJPXfz2w
|
| 5 |
+
about: Get help from the community and share ideas
|
| 6 |
+
- name: 🤖 Installation Assistant (ChatGPT)
|
| 7 |
+
url: https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant
|
| 8 |
+
about: CustomGPT that knows all our docs. Really useful. Try it.
|
| 9 |
+
- name: 📚 Documentation
|
| 10 |
+
url: https://github.com/lfnovo/open-notebook/tree/main/docs
|
| 11 |
+
about: Browse our comprehensive documentation
|
.github/ISSUE_TEMPLATE/feature_request.yml
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: ✨ Feature Suggestion
|
| 2 |
+
description: Suggest a new feature or improvement for Open Notebook
|
| 3 |
+
title: "[Feature]: "
|
| 4 |
+
labels: ["enhancement", "needs-triage"]
|
| 5 |
+
body:
|
| 6 |
+
- type: markdown
|
| 7 |
+
attributes:
|
| 8 |
+
value: |
|
| 9 |
+
Thanks for taking the time to suggest a feature! Your ideas help make Open Notebook better for everyone.
|
| 10 |
+
|
| 11 |
+
- type: textarea
|
| 12 |
+
id: feature-description
|
| 13 |
+
attributes:
|
| 14 |
+
label: Feature Description
|
| 15 |
+
description: What feature would you like to see added or improved?
|
| 16 |
+
placeholder: "I would like to be able to..."
|
| 17 |
+
validations:
|
| 18 |
+
required: true
|
| 19 |
+
|
| 20 |
+
- type: textarea
|
| 21 |
+
id: why-helpful
|
| 22 |
+
attributes:
|
| 23 |
+
label: Why would this be helpful?
|
| 24 |
+
description: Explain how this feature would benefit you and other users
|
| 25 |
+
placeholder: "This would help because..."
|
| 26 |
+
validations:
|
| 27 |
+
required: true
|
| 28 |
+
|
| 29 |
+
- type: textarea
|
| 30 |
+
id: proposed-solution
|
| 31 |
+
attributes:
|
| 32 |
+
label: Proposed Solution (Optional)
|
| 33 |
+
description: If you have ideas on how to implement this feature, please share them
|
| 34 |
+
placeholder: "This could be implemented by..."
|
| 35 |
+
validations:
|
| 36 |
+
required: false
|
| 37 |
+
|
| 38 |
+
- type: textarea
|
| 39 |
+
id: additional-context
|
| 40 |
+
attributes:
|
| 41 |
+
label: Additional Context
|
| 42 |
+
description: Any other context, screenshots, or examples that might be helpful
|
| 43 |
+
placeholder: "For example, other tools do this by..."
|
| 44 |
+
validations:
|
| 45 |
+
required: false
|
| 46 |
+
|
| 47 |
+
- type: checkboxes
|
| 48 |
+
id: willing-to-contribute
|
| 49 |
+
attributes:
|
| 50 |
+
label: Contribution
|
| 51 |
+
description: Would you like to work on implementing this feature?
|
| 52 |
+
options:
|
| 53 |
+
- label: I am a developer and would like to work on implementing this feature (pending maintainer approval)
|
| 54 |
+
required: false
|
| 55 |
+
|
| 56 |
+
- type: markdown
|
| 57 |
+
attributes:
|
| 58 |
+
value: |
|
| 59 |
+
---
|
| 60 |
+
**Next Steps:**
|
| 61 |
+
1. A maintainer will review your feature request
|
| 62 |
+
2. If approved and you checked the box above, the issue will be assigned to you
|
| 63 |
+
3. Please wait for assignment before starting development
|
| 64 |
+
4. See our [Contributing Guide](https://github.com/lfnovo/open-notebook/blob/main/CONTRIBUTING.md) for more details
|
| 65 |
+
|
.github/ISSUE_TEMPLATE/installation_issue.yml
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: 🔧 Installation Issue
|
| 2 |
+
description: Report problems with installation, setup, or connectivity
|
| 3 |
+
title: "[Install]: "
|
| 4 |
+
labels: ["installation", "needs-triage"]
|
| 5 |
+
body:
|
| 6 |
+
- type: markdown
|
| 7 |
+
attributes:
|
| 8 |
+
value: |
|
| 9 |
+
## ⚠️ Before You Continue
|
| 10 |
+
|
| 11 |
+
**Please try these resources first:**
|
| 12 |
+
|
| 13 |
+
1. 🤖 **[Installation Assistant ChatGPT](https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant)** - Our AI assistant can help you troubleshoot most installation issues instantly!
|
| 14 |
+
|
| 15 |
+
2. 📚 **[Installation Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/getting-started/installation.md)** - Comprehensive setup instructions
|
| 16 |
+
|
| 17 |
+
3. 🐋 **[Docker Deployment Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/docker.md)** - Detailed Docker setup
|
| 18 |
+
|
| 19 |
+
4. 🦙 **Ollama Issues?** Read our [Ollama Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/features/ollama.md) first
|
| 20 |
+
|
| 21 |
+
5. 💬 **[Discord Community](https://discord.gg/37XJPXfz2w)** - Get real-time help from the community
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
If you've tried the above and still need help, please fill out the form below with as much detail as possible.
|
| 26 |
+
|
| 27 |
+
- type: dropdown
|
| 28 |
+
id: installation-method
|
| 29 |
+
attributes:
|
| 30 |
+
label: Installation Method
|
| 31 |
+
description: How are you trying to install Open Notebook?
|
| 32 |
+
options:
|
| 33 |
+
- Docker (docker-compose - recommended)
|
| 34 |
+
- Docker (single container - v1-latest-single, deprecated)
|
| 35 |
+
- Local development (make start-all)
|
| 36 |
+
- Other (please specify below)
|
| 37 |
+
validations:
|
| 38 |
+
required: true
|
| 39 |
+
|
| 40 |
+
- type: textarea
|
| 41 |
+
id: issue-description
|
| 42 |
+
attributes:
|
| 43 |
+
label: What is the issue?
|
| 44 |
+
description: Describe the installation or setup problem you're experiencing
|
| 45 |
+
placeholder: |
|
| 46 |
+
Example: "I can't connect to the database" or "The container won't start" or "Getting 404 errors when accessing the UI"
|
| 47 |
+
validations:
|
| 48 |
+
required: true
|
| 49 |
+
|
| 50 |
+
- type: textarea
|
| 51 |
+
id: logs
|
| 52 |
+
attributes:
|
| 53 |
+
label: Logs
|
| 54 |
+
description: |
|
| 55 |
+
Please provide relevant logs. **This is very important for diagnosing issues!**
|
| 56 |
+
|
| 57 |
+
**How to get logs:**
|
| 58 |
+
- Docker single container: `docker logs open-notebook`
|
| 59 |
+
- Docker Compose: `docker compose logs -f`
|
| 60 |
+
- Specific service: `docker compose logs -f open_notebook`
|
| 61 |
+
placeholder: |
|
| 62 |
+
Paste your logs here. Include the full error message and stack trace if available.
|
| 63 |
+
render: shell
|
| 64 |
+
validations:
|
| 65 |
+
required: false
|
| 66 |
+
|
| 67 |
+
- type: textarea
|
| 68 |
+
id: docker-compose
|
| 69 |
+
attributes:
|
| 70 |
+
label: Docker Compose Configuration
|
| 71 |
+
description: |
|
| 72 |
+
If using Docker Compose, please paste your `docker-compose.yml` file here.
|
| 73 |
+
|
| 74 |
+
**⚠️ IMPORTANT: Redact any sensitive information (API keys, passwords, etc.)**
|
| 75 |
+
placeholder: |
|
| 76 |
+
services:
|
| 77 |
+
open_notebook:
|
| 78 |
+
image: lfnovo/open_notebook:v1-latest-single
|
| 79 |
+
ports:
|
| 80 |
+
- "8502:8502"
|
| 81 |
+
- "5055:5055"
|
| 82 |
+
environment:
|
| 83 |
+
- OPENAI_API_KEY=sk-***REDACTED***
|
| 84 |
+
...
|
| 85 |
+
render: yaml
|
| 86 |
+
validations:
|
| 87 |
+
required: false
|
| 88 |
+
|
| 89 |
+
- type: textarea
|
| 90 |
+
id: env-file
|
| 91 |
+
attributes:
|
| 92 |
+
label: Environment File
|
| 93 |
+
description: |
|
| 94 |
+
If using an `.env` or `docker.env` file, please paste it here.
|
| 95 |
+
|
| 96 |
+
**⚠️ IMPORTANT: REDACT ALL API KEYS AND PASSWORDS!**
|
| 97 |
+
placeholder: |
|
| 98 |
+
SURREAL_URL=ws://surrealdb:8000/rpc
|
| 99 |
+
SURREAL_USER=root
|
| 100 |
+
SURREAL_PASSWORD=***REDACTED***
|
| 101 |
+
OPENAI_API_KEY=sk-***REDACTED***
|
| 102 |
+
ANTHROPIC_API_KEY=sk-ant-***REDACTED***
|
| 103 |
+
render: shell
|
| 104 |
+
validations:
|
| 105 |
+
required: false
|
| 106 |
+
|
| 107 |
+
- type: textarea
|
| 108 |
+
id: system-info
|
| 109 |
+
attributes:
|
| 110 |
+
label: System Information
|
| 111 |
+
description: Tell us about your setup
|
| 112 |
+
placeholder: |
|
| 113 |
+
- Operating System: Ubuntu 22.04 / Windows 11 / macOS 14
|
| 114 |
+
- Docker version: `docker --version`
|
| 115 |
+
- Docker Compose version: `docker compose version`
|
| 116 |
+
- Architecture: amd64 / arm64 (Apple Silicon)
|
| 117 |
+
- Available disk space: `df -h`
|
| 118 |
+
- Available memory: `free -h` (Linux) or Activity Monitor (Mac)
|
| 119 |
+
validations:
|
| 120 |
+
required: false
|
| 121 |
+
|
| 122 |
+
- type: textarea
|
| 123 |
+
id: additional-context
|
| 124 |
+
attributes:
|
| 125 |
+
label: Additional Context
|
| 126 |
+
description: Any other information that might be helpful
|
| 127 |
+
placeholder: |
|
| 128 |
+
- Are you behind a corporate proxy or firewall?
|
| 129 |
+
- Are you using a VPN?
|
| 130 |
+
- Have you made any custom modifications?
|
| 131 |
+
- Did this work before and suddenly break?
|
| 132 |
+
validations:
|
| 133 |
+
required: false
|
| 134 |
+
|
| 135 |
+
- type: checkboxes
|
| 136 |
+
id: checklist
|
| 137 |
+
attributes:
|
| 138 |
+
label: Pre-submission Checklist
|
| 139 |
+
description: Please confirm you've tried these steps
|
| 140 |
+
options:
|
| 141 |
+
- label: I tried the [Installation Assistant ChatGPT](https://chatgpt.com/g/g-68776e2765b48191bd1bae3f30212631-open-notebook-installation-assistant)
|
| 142 |
+
required: false
|
| 143 |
+
- label: I read the relevant documentation ([Installation Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/getting-started/installation.md) or [Ollama Guide](https://github.com/lfnovo/open-notebook/blob/main/docs/features/ollama.md))
|
| 144 |
+
required: false
|
| 145 |
+
- label: I searched existing issues to see if this was already reported
|
| 146 |
+
required: true
|
| 147 |
+
- label: I redacted all sensitive information (API keys, passwords, etc.)
|
| 148 |
+
required: true
|
.github/pull_request_template.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Description
|
| 2 |
+
|
| 3 |
+
<!-- Provide a clear and concise description of what this PR does -->
|
| 4 |
+
|
| 5 |
+
## Related Issue
|
| 6 |
+
|
| 7 |
+
<!-- This PR should be linked to an approved issue. If not, please create an issue first. -->
|
| 8 |
+
|
| 9 |
+
Fixes #<!-- issue number -->
|
| 10 |
+
|
| 11 |
+
## Type of Change
|
| 12 |
+
|
| 13 |
+
<!-- Mark the relevant option with an "x" -->
|
| 14 |
+
|
| 15 |
+
- [ ] Bug fix (non-breaking change that fixes an issue)
|
| 16 |
+
- [ ] New feature (non-breaking change that adds functionality)
|
| 17 |
+
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
| 18 |
+
- [ ] Documentation update
|
| 19 |
+
- [ ] Code refactoring (no functional changes)
|
| 20 |
+
- [ ] Performance improvement
|
| 21 |
+
- [ ] Test coverage improvement
|
| 22 |
+
|
| 23 |
+
## How Has This Been Tested?
|
| 24 |
+
|
| 25 |
+
<!-- Describe the tests you ran and/or how you verified your changes work -->
|
| 26 |
+
|
| 27 |
+
- [ ] Tested locally with Docker
|
| 28 |
+
- [ ] Tested locally with development setup
|
| 29 |
+
- [ ] Added new unit tests
|
| 30 |
+
- [ ] Existing tests pass (`uv run pytest`)
|
| 31 |
+
- [ ] Manual testing performed (describe below)
|
| 32 |
+
|
| 33 |
+
**Test Details:**
|
| 34 |
+
<!-- Describe your testing approach -->
|
| 35 |
+
|
| 36 |
+
## Design Alignment
|
| 37 |
+
|
| 38 |
+
<!-- This section helps ensure your PR aligns with our project vision -->
|
| 39 |
+
|
| 40 |
+
**Which design principles does this PR support?** (See [DESIGN_PRINCIPLES.md](../DESIGN_PRINCIPLES.md))
|
| 41 |
+
|
| 42 |
+
- [ ] Privacy First
|
| 43 |
+
- [ ] Simplicity Over Features
|
| 44 |
+
- [ ] API-First Architecture
|
| 45 |
+
- [ ] Multi-Provider Flexibility
|
| 46 |
+
- [ ] Extensibility Through Standards
|
| 47 |
+
- [ ] Async-First for Performance
|
| 48 |
+
|
| 49 |
+
**Explanation:**
|
| 50 |
+
<!-- Brief explanation of how your changes align with these principles -->
|
| 51 |
+
|
| 52 |
+
## Checklist
|
| 53 |
+
|
| 54 |
+
<!-- Mark completed items with an "x" -->
|
| 55 |
+
|
| 56 |
+
### Code Quality
|
| 57 |
+
- [ ] My code follows PEP 8 style guidelines (Python)
|
| 58 |
+
- [ ] My code follows TypeScript best practices (Frontend)
|
| 59 |
+
- [ ] I have added type hints to my code (Python)
|
| 60 |
+
- [ ] I have added JSDoc comments where appropriate (TypeScript)
|
| 61 |
+
- [ ] I have performed a self-review of my code
|
| 62 |
+
- [ ] I have commented my code, particularly in hard-to-understand areas
|
| 63 |
+
- [ ] My changes generate no new warnings or errors
|
| 64 |
+
|
| 65 |
+
### Testing
|
| 66 |
+
- [ ] I have added tests that prove my fix is effective or that my feature works
|
| 67 |
+
- [ ] New and existing unit tests pass locally with my changes
|
| 68 |
+
- [ ] I ran linting: `make ruff` or `ruff check . --fix`
|
| 69 |
+
- [ ] I ran type checking: `make lint` or `uv run python -m mypy .`
|
| 70 |
+
|
| 71 |
+
### Documentation
|
| 72 |
+
- [ ] I have updated the relevant documentation in `/docs` (if applicable)
|
| 73 |
+
- [ ] I have added/updated docstrings for new/modified functions
|
| 74 |
+
- [ ] I have updated the API documentation (if API changes were made)
|
| 75 |
+
- [ ] I have added comments to complex logic
|
| 76 |
+
|
| 77 |
+
### Database Changes
|
| 78 |
+
- [ ] I have created migration scripts for any database schema changes (in `/migrations`)
|
| 79 |
+
- [ ] Migration includes both up and down scripts
|
| 80 |
+
- [ ] Migration has been tested locally
|
| 81 |
+
|
| 82 |
+
### Breaking Changes
|
| 83 |
+
- [ ] This PR includes breaking changes
|
| 84 |
+
- [ ] I have documented the migration path for users
|
| 85 |
+
- [ ] I have updated MIGRATION.md (if applicable)
|
| 86 |
+
|
| 87 |
+
## Screenshots (if applicable)
|
| 88 |
+
|
| 89 |
+
<!-- Add screenshots for UI changes -->
|
| 90 |
+
|
| 91 |
+
## Additional Context
|
| 92 |
+
|
| 93 |
+
<!-- Add any other context about the PR here -->
|
| 94 |
+
|
| 95 |
+
## Pre-Submission Verification
|
| 96 |
+
|
| 97 |
+
Before submitting, please verify:
|
| 98 |
+
|
| 99 |
+
- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md)
|
| 100 |
+
- [ ] I have read [DESIGN_PRINCIPLES.md](../DESIGN_PRINCIPLES.md)
|
| 101 |
+
- [ ] This PR addresses an approved issue that was assigned to me
|
| 102 |
+
- [ ] I have not included unrelated changes in this PR
|
| 103 |
+
- [ ] My PR title follows conventional commits format (e.g., "feat: add user authentication")
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
**Thank you for contributing to Open Notebook!** 🎉
|
.github/workflows/build-and-release.yml
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Build and Release
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
inputs:
|
| 6 |
+
push_latest:
|
| 7 |
+
description: 'Also push v1-latest tags'
|
| 8 |
+
required: true
|
| 9 |
+
default: false
|
| 10 |
+
type: boolean
|
| 11 |
+
release:
|
| 12 |
+
types: [published]
|
| 13 |
+
|
| 14 |
+
permissions:
|
| 15 |
+
contents: read
|
| 16 |
+
packages: write
|
| 17 |
+
|
| 18 |
+
env:
|
| 19 |
+
GHCR_IMAGE: ghcr.io/lfnovo/open-notebook
|
| 20 |
+
DOCKERHUB_IMAGE: lfnovo/open_notebook
|
| 21 |
+
|
| 22 |
+
jobs:
|
| 23 |
+
extract-version:
|
| 24 |
+
runs-on: ubuntu-latest
|
| 25 |
+
outputs:
|
| 26 |
+
version: ${{ steps.version.outputs.version }}
|
| 27 |
+
has_dockerhub_secrets: ${{ steps.check.outputs.has_dockerhub_secrets }}
|
| 28 |
+
steps:
|
| 29 |
+
- name: Checkout
|
| 30 |
+
uses: actions/checkout@v6
|
| 31 |
+
|
| 32 |
+
- name: Extract version from pyproject.toml
|
| 33 |
+
id: version
|
| 34 |
+
run: |
|
| 35 |
+
VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
|
| 36 |
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
| 37 |
+
echo "Extracted version: $VERSION"
|
| 38 |
+
|
| 39 |
+
- name: Check for Docker Hub credentials
|
| 40 |
+
id: check
|
| 41 |
+
env:
|
| 42 |
+
SECRET_DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
| 43 |
+
SECRET_DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
| 44 |
+
run: |
|
| 45 |
+
if [[ -n ""$SECRET_DOCKER_USERNAME"" && -n ""$SECRET_DOCKER_PASSWORD"" ]]; then
|
| 46 |
+
echo "has_dockerhub_secrets=true" >> $GITHUB_OUTPUT
|
| 47 |
+
echo "Docker Hub credentials available"
|
| 48 |
+
else
|
| 49 |
+
echo "has_dockerhub_secrets=false" >> $GITHUB_OUTPUT
|
| 50 |
+
echo "Docker Hub credentials not available - will only push to GHCR"
|
| 51 |
+
fi
|
| 52 |
+
|
| 53 |
+
build-regular:
|
| 54 |
+
needs: extract-version
|
| 55 |
+
runs-on: ubuntu-latest
|
| 56 |
+
steps:
|
| 57 |
+
- name: Checkout
|
| 58 |
+
uses: actions/checkout@v6
|
| 59 |
+
|
| 60 |
+
- name: Free up disk space
|
| 61 |
+
run: |
|
| 62 |
+
sudo rm -rf /usr/share/dotnet
|
| 63 |
+
sudo rm -rf /usr/local/lib/android
|
| 64 |
+
sudo rm -rf /opt/ghc
|
| 65 |
+
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
| 66 |
+
sudo docker image prune --all --force
|
| 67 |
+
df -h
|
| 68 |
+
|
| 69 |
+
- name: Set up Docker Buildx
|
| 70 |
+
uses: docker/setup-buildx-action@v4
|
| 71 |
+
|
| 72 |
+
- name: Login to GitHub Container Registry
|
| 73 |
+
uses: docker/login-action@v4
|
| 74 |
+
with:
|
| 75 |
+
registry: ghcr.io
|
| 76 |
+
username: ${{ github.actor }}
|
| 77 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 78 |
+
|
| 79 |
+
- name: Login to Docker Hub
|
| 80 |
+
if: needs.extract-version.outputs.has_dockerhub_secrets == 'true'
|
| 81 |
+
uses: docker/login-action@v4
|
| 82 |
+
with:
|
| 83 |
+
username: ${{ secrets.DOCKER_USERNAME }}
|
| 84 |
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
| 85 |
+
|
| 86 |
+
- name: Cache Docker layers
|
| 87 |
+
uses: actions/cache@v5
|
| 88 |
+
with:
|
| 89 |
+
path: /tmp/.buildx-cache
|
| 90 |
+
key: ${{ runner.os }}-buildx-regular-${{ github.sha }}
|
| 91 |
+
restore-keys: |
|
| 92 |
+
${{ runner.os }}-buildx-regular-
|
| 93 |
+
|
| 94 |
+
- name: Prepare Docker tags for regular build
|
| 95 |
+
id: tags-regular
|
| 96 |
+
env:
|
| 97 |
+
ENV_GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
|
| 98 |
+
GITHUB_EVENT_INPUTS_PUSH_LATEST: ${{ github.event.inputs.push_latest }}
|
| 99 |
+
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
| 100 |
+
GITHUB_EVENT_RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
|
| 101 |
+
ENV_DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
|
| 102 |
+
run: |
|
| 103 |
+
TAGS=""$ENV_GHCR_IMAGE":${{ needs.extract-version.outputs.version }}"
|
| 104 |
+
|
| 105 |
+
# Determine if we should push latest tags
|
| 106 |
+
PUSH_LATEST=""$GITHUB_EVENT_INPUTS_PUSH_LATEST""
|
| 107 |
+
if [[ -z "$PUSH_LATEST" ]]; then
|
| 108 |
+
PUSH_LATEST="false"
|
| 109 |
+
fi
|
| 110 |
+
|
| 111 |
+
# Add GHCR latest tag if requested or for non-prerelease releases
|
| 112 |
+
if [[ "$PUSH_LATEST" == "true" ]] || [[ ""$GITHUB_EVENT_NAME"" == "release" && ""$GITHUB_EVENT_RELEASE_PRERELEASE"" != "true" ]]; then
|
| 113 |
+
TAGS="${TAGS},"$ENV_GHCR_IMAGE":v1-latest"
|
| 114 |
+
fi
|
| 115 |
+
|
| 116 |
+
# Add Docker Hub tags if credentials available
|
| 117 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 118 |
+
TAGS="${TAGS},"$ENV_DOCKERHUB_IMAGE":${{ needs.extract-version.outputs.version }}"
|
| 119 |
+
|
| 120 |
+
if [[ "$PUSH_LATEST" == "true" ]] || [[ ""$GITHUB_EVENT_NAME"" == "release" && ""$GITHUB_EVENT_RELEASE_PRERELEASE"" != "true" ]]; then
|
| 121 |
+
TAGS="${TAGS},"$ENV_DOCKERHUB_IMAGE":v1-latest"
|
| 122 |
+
fi
|
| 123 |
+
fi
|
| 124 |
+
|
| 125 |
+
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
| 126 |
+
echo "Generated tags: ${TAGS}"
|
| 127 |
+
|
| 128 |
+
- name: Build and push regular image
|
| 129 |
+
uses: docker/build-push-action@v7
|
| 130 |
+
with:
|
| 131 |
+
context: .
|
| 132 |
+
file: ./Dockerfile
|
| 133 |
+
platforms: linux/amd64,linux/arm64
|
| 134 |
+
push: true
|
| 135 |
+
tags: ${{ steps.tags-regular.outputs.tags }}
|
| 136 |
+
cache-from: type=local,src=/tmp/.buildx-cache
|
| 137 |
+
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
| 138 |
+
|
| 139 |
+
- name: Move cache
|
| 140 |
+
run: |
|
| 141 |
+
rm -rf /tmp/.buildx-cache
|
| 142 |
+
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
| 143 |
+
|
| 144 |
+
build-single:
|
| 145 |
+
needs: extract-version
|
| 146 |
+
runs-on: ubuntu-latest
|
| 147 |
+
steps:
|
| 148 |
+
- name: Checkout
|
| 149 |
+
uses: actions/checkout@v6
|
| 150 |
+
|
| 151 |
+
- name: Free up disk space
|
| 152 |
+
run: |
|
| 153 |
+
sudo rm -rf /usr/share/dotnet
|
| 154 |
+
sudo rm -rf /usr/local/lib/android
|
| 155 |
+
sudo rm -rf /opt/ghc
|
| 156 |
+
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
| 157 |
+
sudo docker image prune --all --force
|
| 158 |
+
df -h
|
| 159 |
+
|
| 160 |
+
- name: Set up Docker Buildx
|
| 161 |
+
uses: docker/setup-buildx-action@v4
|
| 162 |
+
|
| 163 |
+
- name: Login to GitHub Container Registry
|
| 164 |
+
uses: docker/login-action@v4
|
| 165 |
+
with:
|
| 166 |
+
registry: ghcr.io
|
| 167 |
+
username: ${{ github.actor }}
|
| 168 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 169 |
+
|
| 170 |
+
- name: Login to Docker Hub
|
| 171 |
+
if: needs.extract-version.outputs.has_dockerhub_secrets == 'true'
|
| 172 |
+
uses: docker/login-action@v4
|
| 173 |
+
with:
|
| 174 |
+
username: ${{ secrets.DOCKER_USERNAME }}
|
| 175 |
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
| 176 |
+
|
| 177 |
+
- name: Cache Docker layers
|
| 178 |
+
uses: actions/cache@v5
|
| 179 |
+
with:
|
| 180 |
+
path: /tmp/.buildx-cache-single
|
| 181 |
+
key: ${{ runner.os }}-buildx-single-${{ github.sha }}
|
| 182 |
+
restore-keys: |
|
| 183 |
+
${{ runner.os }}-buildx-single-
|
| 184 |
+
|
| 185 |
+
- name: Prepare Docker tags for single build
|
| 186 |
+
id: tags-single
|
| 187 |
+
env:
|
| 188 |
+
ENV_GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
|
| 189 |
+
GITHUB_EVENT_INPUTS_PUSH_LATEST: ${{ github.event.inputs.push_latest }}
|
| 190 |
+
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
| 191 |
+
GITHUB_EVENT_RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
|
| 192 |
+
ENV_DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
|
| 193 |
+
run: |
|
| 194 |
+
TAGS=""$ENV_GHCR_IMAGE":${{ needs.extract-version.outputs.version }}-single"
|
| 195 |
+
|
| 196 |
+
# Determine if we should push latest tags
|
| 197 |
+
PUSH_LATEST=""$GITHUB_EVENT_INPUTS_PUSH_LATEST""
|
| 198 |
+
if [[ -z "$PUSH_LATEST" ]]; then
|
| 199 |
+
PUSH_LATEST="false"
|
| 200 |
+
fi
|
| 201 |
+
|
| 202 |
+
# Add GHCR latest tag if requested or for non-prerelease releases
|
| 203 |
+
if [[ "$PUSH_LATEST" == "true" ]] || [[ ""$GITHUB_EVENT_NAME"" == "release" && ""$GITHUB_EVENT_RELEASE_PRERELEASE"" != "true" ]]; then
|
| 204 |
+
TAGS="${TAGS},"$ENV_GHCR_IMAGE":v1-latest-single"
|
| 205 |
+
fi
|
| 206 |
+
|
| 207 |
+
# Add Docker Hub tags if credentials available
|
| 208 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 209 |
+
TAGS="${TAGS},"$ENV_DOCKERHUB_IMAGE":${{ needs.extract-version.outputs.version }}-single"
|
| 210 |
+
|
| 211 |
+
if [[ "$PUSH_LATEST" == "true" ]] || [[ ""$GITHUB_EVENT_NAME"" == "release" && ""$GITHUB_EVENT_RELEASE_PRERELEASE"" != "true" ]]; then
|
| 212 |
+
TAGS="${TAGS},"$ENV_DOCKERHUB_IMAGE":v1-latest-single"
|
| 213 |
+
fi
|
| 214 |
+
fi
|
| 215 |
+
|
| 216 |
+
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
| 217 |
+
echo "Generated tags: ${TAGS}"
|
| 218 |
+
|
| 219 |
+
- name: Build and push single-container image
|
| 220 |
+
uses: docker/build-push-action@v7
|
| 221 |
+
with:
|
| 222 |
+
context: .
|
| 223 |
+
file: ./Dockerfile.single
|
| 224 |
+
platforms: linux/amd64,linux/arm64
|
| 225 |
+
push: true
|
| 226 |
+
tags: ${{ steps.tags-single.outputs.tags }}
|
| 227 |
+
cache-from: type=local,src=/tmp/.buildx-cache-single
|
| 228 |
+
cache-to: type=local,dest=/tmp/.buildx-cache-single-new,mode=max
|
| 229 |
+
|
| 230 |
+
- name: Move cache
|
| 231 |
+
run: |
|
| 232 |
+
rm -rf /tmp/.buildx-cache-single
|
| 233 |
+
mv /tmp/.buildx-cache-single-new /tmp/.buildx-cache-single
|
| 234 |
+
|
| 235 |
+
summary:
|
| 236 |
+
needs: [extract-version, build-regular, build-single]
|
| 237 |
+
runs-on: ubuntu-latest
|
| 238 |
+
if: always()
|
| 239 |
+
steps:
|
| 240 |
+
- name: Build Summary
|
| 241 |
+
env:
|
| 242 |
+
GITHUB_EVENT_INPUTS_PUSH_LATEST_____FALSE_: ${{ github.event.inputs.push_latest || 'false' }}
|
| 243 |
+
ENV_GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
|
| 244 |
+
ENV_DOCKERHUB_IMAGE: ${{ env.DOCKERHUB_IMAGE }}
|
| 245 |
+
GITHUB_EVENT_INPUTS_PUSH_LATEST: ${{ github.event.inputs.push_latest }}
|
| 246 |
+
run: |
|
| 247 |
+
echo "## Build Summary" >> $GITHUB_STEP_SUMMARY
|
| 248 |
+
echo "**Version:** ${{ needs.extract-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
| 249 |
+
echo "**Push v1-Latest:** "$GITHUB_EVENT_INPUTS_PUSH_LATEST_____FALSE_"" >> $GITHUB_STEP_SUMMARY
|
| 250 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 251 |
+
echo "### Registries:" >> $GITHUB_STEP_SUMMARY
|
| 252 |
+
echo "✅ **GHCR:** \`"$ENV_GHCR_IMAGE"\`" >> $GITHUB_STEP_SUMMARY
|
| 253 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 254 |
+
echo "✅ **Docker Hub:** \`"$ENV_DOCKERHUB_IMAGE"\`" >> $GITHUB_STEP_SUMMARY
|
| 255 |
+
else
|
| 256 |
+
echo "⏭️ **Docker Hub:** Skipped (credentials not configured)" >> $GITHUB_STEP_SUMMARY
|
| 257 |
+
fi
|
| 258 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 259 |
+
echo "### Images Built:" >> $GITHUB_STEP_SUMMARY
|
| 260 |
+
|
| 261 |
+
if [[ "${{ needs.build-regular.result }}" == "success" ]]; then
|
| 262 |
+
echo "✅ **Regular (GHCR):** \`"$ENV_GHCR_IMAGE":${{ needs.extract-version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
| 263 |
+
if [[ ""$GITHUB_EVENT_INPUTS_PUSH_LATEST"" == "true" ]]; then
|
| 264 |
+
echo "✅ **Regular v1-Latest (GHCR):** \`"$ENV_GHCR_IMAGE":v1-latest\`" >> $GITHUB_STEP_SUMMARY
|
| 265 |
+
fi
|
| 266 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 267 |
+
echo "✅ **Regular (Docker Hub):** \`"$ENV_DOCKERHUB_IMAGE":${{ needs.extract-version.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
| 268 |
+
if [[ ""$GITHUB_EVENT_INPUTS_PUSH_LATEST"" == "true" ]]; then
|
| 269 |
+
echo "✅ **Regular v1-Latest (Docker Hub):** \`"$ENV_DOCKERHUB_IMAGE":v1-latest\`" >> $GITHUB_STEP_SUMMARY
|
| 270 |
+
fi
|
| 271 |
+
fi
|
| 272 |
+
elif [[ "${{ needs.build-regular.result }}" == "skipped" ]]; then
|
| 273 |
+
echo "⏭️ **Regular:** Skipped" >> $GITHUB_STEP_SUMMARY
|
| 274 |
+
else
|
| 275 |
+
echo "❌ **Regular:** Failed" >> $GITHUB_STEP_SUMMARY
|
| 276 |
+
fi
|
| 277 |
+
|
| 278 |
+
if [[ "${{ needs.build-single.result }}" == "success" ]]; then
|
| 279 |
+
echo "✅ **Single (GHCR):** \`"$ENV_GHCR_IMAGE":${{ needs.extract-version.outputs.version }}-single\`" >> $GITHUB_STEP_SUMMARY
|
| 280 |
+
if [[ ""$GITHUB_EVENT_INPUTS_PUSH_LATEST"" == "true" ]]; then
|
| 281 |
+
echo "✅ **Single v1-Latest (GHCR):** \`"$ENV_GHCR_IMAGE":v1-latest-single\`" >> $GITHUB_STEP_SUMMARY
|
| 282 |
+
fi
|
| 283 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 284 |
+
echo "✅ **Single (Docker Hub):** \`"$ENV_DOCKERHUB_IMAGE":${{ needs.extract-version.outputs.version }}-single\`" >> $GITHUB_STEP_SUMMARY
|
| 285 |
+
if [[ ""$GITHUB_EVENT_INPUTS_PUSH_LATEST"" == "true" ]]; then
|
| 286 |
+
echo "✅ **Single v1-Latest (Docker Hub):** \`"$ENV_DOCKERHUB_IMAGE":v1-latest-single\`" >> $GITHUB_STEP_SUMMARY
|
| 287 |
+
fi
|
| 288 |
+
fi
|
| 289 |
+
elif [[ "${{ needs.build-single.result }}" == "skipped" ]]; then
|
| 290 |
+
echo "⏭️ **Single:** Skipped" >> $GITHUB_STEP_SUMMARY
|
| 291 |
+
else
|
| 292 |
+
echo "❌ **Single:** Failed" >> $GITHUB_STEP_SUMMARY
|
| 293 |
+
fi
|
| 294 |
+
|
| 295 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 296 |
+
echo "### Platforms:" >> $GITHUB_STEP_SUMMARY
|
| 297 |
+
echo "- linux/amd64" >> $GITHUB_STEP_SUMMARY
|
| 298 |
+
echo "- linux/arm64" >> $GITHUB_STEP_SUMMARY
|
.github/workflows/build-dev.yml
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Development Build
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
branches: [ main ]
|
| 6 |
+
push:
|
| 7 |
+
branches: [ main ]
|
| 8 |
+
paths-ignore:
|
| 9 |
+
- '**.md'
|
| 10 |
+
- 'docs/**'
|
| 11 |
+
- 'notebooks/**'
|
| 12 |
+
- '.github/workflows/claude*.yml'
|
| 13 |
+
workflow_dispatch:
|
| 14 |
+
inputs:
|
| 15 |
+
platform:
|
| 16 |
+
description: 'Platform to build'
|
| 17 |
+
required: true
|
| 18 |
+
default: 'linux/amd64'
|
| 19 |
+
type: choice
|
| 20 |
+
options:
|
| 21 |
+
- linux/amd64
|
| 22 |
+
- linux/arm64
|
| 23 |
+
- linux/amd64,linux/arm64
|
| 24 |
+
|
| 25 |
+
permissions:
|
| 26 |
+
contents: read
|
| 27 |
+
packages: write
|
| 28 |
+
|
| 29 |
+
env:
|
| 30 |
+
GHCR_IMAGE: ghcr.io/lfnovo/open-notebook
|
| 31 |
+
DOCKERHUB_IMAGE: lfnovo/open_notebook
|
| 32 |
+
|
| 33 |
+
jobs:
|
| 34 |
+
extract-version:
|
| 35 |
+
runs-on: ubuntu-latest
|
| 36 |
+
outputs:
|
| 37 |
+
version: ${{ steps.version.outputs.version }}
|
| 38 |
+
has_dockerhub_secrets: ${{ steps.check.outputs.has_dockerhub_secrets }}
|
| 39 |
+
is_push_to_main: ${{ steps.check.outputs.is_push_to_main }}
|
| 40 |
+
steps:
|
| 41 |
+
- name: Checkout
|
| 42 |
+
uses: actions/checkout@v6
|
| 43 |
+
|
| 44 |
+
- name: Extract version from pyproject.toml
|
| 45 |
+
id: version
|
| 46 |
+
run: |
|
| 47 |
+
VERSION=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
|
| 48 |
+
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
| 49 |
+
echo "Extracted version: $VERSION"
|
| 50 |
+
|
| 51 |
+
- name: Check environment
|
| 52 |
+
id: check
|
| 53 |
+
env:
|
| 54 |
+
SECRET_DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
| 55 |
+
SECRET_DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
| 56 |
+
run: |
|
| 57 |
+
# Check for Docker Hub credentials
|
| 58 |
+
if [[ -n "$SECRET_DOCKER_USERNAME" && -n "$SECRET_DOCKER_PASSWORD" ]]; then
|
| 59 |
+
echo "has_dockerhub_secrets=true" >> $GITHUB_OUTPUT
|
| 60 |
+
echo "Docker Hub credentials available"
|
| 61 |
+
else
|
| 62 |
+
echo "has_dockerhub_secrets=false" >> $GITHUB_OUTPUT
|
| 63 |
+
echo "Docker Hub credentials not available"
|
| 64 |
+
fi
|
| 65 |
+
|
| 66 |
+
# Check if this is a push to main (not a PR)
|
| 67 |
+
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
| 68 |
+
echo "is_push_to_main=true" >> $GITHUB_OUTPUT
|
| 69 |
+
echo "This is a push to main - will publish v1-dev tags"
|
| 70 |
+
else
|
| 71 |
+
echo "is_push_to_main=false" >> $GITHUB_OUTPUT
|
| 72 |
+
echo "This is a PR or manual run - test build only"
|
| 73 |
+
fi
|
| 74 |
+
|
| 75 |
+
build-regular:
|
| 76 |
+
needs: extract-version
|
| 77 |
+
runs-on: ubuntu-latest
|
| 78 |
+
steps:
|
| 79 |
+
- name: Checkout
|
| 80 |
+
uses: actions/checkout@v6
|
| 81 |
+
|
| 82 |
+
- name: Free up disk space
|
| 83 |
+
if: needs.extract-version.outputs.is_push_to_main == 'true'
|
| 84 |
+
run: |
|
| 85 |
+
sudo rm -rf /usr/share/dotnet
|
| 86 |
+
sudo rm -rf /usr/local/lib/android
|
| 87 |
+
sudo rm -rf /opt/ghc
|
| 88 |
+
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
| 89 |
+
sudo docker image prune --all --force
|
| 90 |
+
df -h
|
| 91 |
+
|
| 92 |
+
- name: Set up Docker Buildx
|
| 93 |
+
uses: docker/setup-buildx-action@v4
|
| 94 |
+
|
| 95 |
+
- name: Login to GitHub Container Registry
|
| 96 |
+
if: needs.extract-version.outputs.is_push_to_main == 'true'
|
| 97 |
+
uses: docker/login-action@v4
|
| 98 |
+
with:
|
| 99 |
+
registry: ghcr.io
|
| 100 |
+
username: ${{ github.actor }}
|
| 101 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 102 |
+
|
| 103 |
+
- name: Login to Docker Hub
|
| 104 |
+
if: needs.extract-version.outputs.is_push_to_main == 'true' && needs.extract-version.outputs.has_dockerhub_secrets == 'true'
|
| 105 |
+
uses: docker/login-action@v4
|
| 106 |
+
with:
|
| 107 |
+
username: ${{ secrets.DOCKER_USERNAME }}
|
| 108 |
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
| 109 |
+
|
| 110 |
+
- name: Cache Docker layers
|
| 111 |
+
uses: actions/cache@v5
|
| 112 |
+
with:
|
| 113 |
+
path: /tmp/.buildx-cache-dev
|
| 114 |
+
key: ${{ runner.os }}-buildx-dev-${{ github.sha }}
|
| 115 |
+
restore-keys: |
|
| 116 |
+
${{ runner.os }}-buildx-dev-
|
| 117 |
+
|
| 118 |
+
- name: Prepare Docker tags
|
| 119 |
+
id: tags
|
| 120 |
+
run: |
|
| 121 |
+
if [[ "${{ needs.extract-version.outputs.is_push_to_main }}" == "true" ]]; then
|
| 122 |
+
# Push to main: build and push v1-dev tags
|
| 123 |
+
TAGS="${{ env.GHCR_IMAGE }}:v1-dev"
|
| 124 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 125 |
+
TAGS="${TAGS},${{ env.DOCKERHUB_IMAGE }}:v1-dev"
|
| 126 |
+
fi
|
| 127 |
+
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
| 128 |
+
echo "push=true" >> $GITHUB_OUTPUT
|
| 129 |
+
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
|
| 130 |
+
else
|
| 131 |
+
# PR or manual: test build only
|
| 132 |
+
echo "tags=${{ env.DOCKERHUB_IMAGE }}:${{ needs.extract-version.outputs.version }}-dev" >> $GITHUB_OUTPUT
|
| 133 |
+
echo "push=false" >> $GITHUB_OUTPUT
|
| 134 |
+
echo "platforms=${{ github.event.inputs.platform || 'linux/amd64' }}" >> $GITHUB_OUTPUT
|
| 135 |
+
fi
|
| 136 |
+
|
| 137 |
+
- name: Build and push regular image
|
| 138 |
+
uses: docker/build-push-action@v7
|
| 139 |
+
with:
|
| 140 |
+
context: .
|
| 141 |
+
file: ./Dockerfile
|
| 142 |
+
platforms: ${{ steps.tags.outputs.platforms }}
|
| 143 |
+
push: ${{ steps.tags.outputs.push }}
|
| 144 |
+
tags: ${{ steps.tags.outputs.tags }}
|
| 145 |
+
cache-from: type=local,src=/tmp/.buildx-cache-dev
|
| 146 |
+
cache-to: type=local,dest=/tmp/.buildx-cache-dev-new,mode=max
|
| 147 |
+
|
| 148 |
+
- name: Move cache
|
| 149 |
+
run: |
|
| 150 |
+
rm -rf /tmp/.buildx-cache-dev
|
| 151 |
+
mv /tmp/.buildx-cache-dev-new /tmp/.buildx-cache-dev
|
| 152 |
+
|
| 153 |
+
build-single:
|
| 154 |
+
needs: extract-version
|
| 155 |
+
# Only build single image on push to main
|
| 156 |
+
if: needs.extract-version.outputs.is_push_to_main == 'true'
|
| 157 |
+
runs-on: ubuntu-latest
|
| 158 |
+
steps:
|
| 159 |
+
- name: Checkout
|
| 160 |
+
uses: actions/checkout@v6
|
| 161 |
+
|
| 162 |
+
- name: Free up disk space
|
| 163 |
+
run: |
|
| 164 |
+
sudo rm -rf /usr/share/dotnet
|
| 165 |
+
sudo rm -rf /usr/local/lib/android
|
| 166 |
+
sudo rm -rf /opt/ghc
|
| 167 |
+
sudo rm -rf /opt/hostedtoolcache/CodeQL
|
| 168 |
+
sudo docker image prune --all --force
|
| 169 |
+
df -h
|
| 170 |
+
|
| 171 |
+
- name: Set up Docker Buildx
|
| 172 |
+
uses: docker/setup-buildx-action@v4
|
| 173 |
+
|
| 174 |
+
- name: Login to GitHub Container Registry
|
| 175 |
+
uses: docker/login-action@v4
|
| 176 |
+
with:
|
| 177 |
+
registry: ghcr.io
|
| 178 |
+
username: ${{ github.actor }}
|
| 179 |
+
password: ${{ secrets.GITHUB_TOKEN }}
|
| 180 |
+
|
| 181 |
+
- name: Login to Docker Hub
|
| 182 |
+
if: needs.extract-version.outputs.has_dockerhub_secrets == 'true'
|
| 183 |
+
uses: docker/login-action@v4
|
| 184 |
+
with:
|
| 185 |
+
username: ${{ secrets.DOCKER_USERNAME }}
|
| 186 |
+
password: ${{ secrets.DOCKER_PASSWORD }}
|
| 187 |
+
|
| 188 |
+
- name: Cache Docker layers
|
| 189 |
+
uses: actions/cache@v5
|
| 190 |
+
with:
|
| 191 |
+
path: /tmp/.buildx-cache-dev-single
|
| 192 |
+
key: ${{ runner.os }}-buildx-dev-single-${{ github.sha }}
|
| 193 |
+
restore-keys: |
|
| 194 |
+
${{ runner.os }}-buildx-dev-single-
|
| 195 |
+
|
| 196 |
+
- name: Prepare Docker tags
|
| 197 |
+
id: tags
|
| 198 |
+
run: |
|
| 199 |
+
TAGS="${{ env.GHCR_IMAGE }}:v1-dev-single"
|
| 200 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 201 |
+
TAGS="${TAGS},${{ env.DOCKERHUB_IMAGE }}:v1-dev-single"
|
| 202 |
+
fi
|
| 203 |
+
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
|
| 204 |
+
|
| 205 |
+
- name: Build and push single-container image
|
| 206 |
+
uses: docker/build-push-action@v7
|
| 207 |
+
with:
|
| 208 |
+
context: .
|
| 209 |
+
file: ./Dockerfile.single
|
| 210 |
+
platforms: linux/amd64,linux/arm64
|
| 211 |
+
push: true
|
| 212 |
+
tags: ${{ steps.tags.outputs.tags }}
|
| 213 |
+
cache-from: type=local,src=/tmp/.buildx-cache-dev-single
|
| 214 |
+
cache-to: type=local,dest=/tmp/.buildx-cache-dev-single-new,mode=max
|
| 215 |
+
|
| 216 |
+
- name: Move cache
|
| 217 |
+
run: |
|
| 218 |
+
rm -rf /tmp/.buildx-cache-dev-single
|
| 219 |
+
mv /tmp/.buildx-cache-dev-single-new /tmp/.buildx-cache-dev-single
|
| 220 |
+
|
| 221 |
+
summary:
|
| 222 |
+
needs: [extract-version, build-regular, build-single]
|
| 223 |
+
runs-on: ubuntu-latest
|
| 224 |
+
if: always()
|
| 225 |
+
steps:
|
| 226 |
+
- name: Development Build Summary
|
| 227 |
+
run: |
|
| 228 |
+
echo "## Development Build Summary" >> $GITHUB_STEP_SUMMARY
|
| 229 |
+
echo "**Version:** ${{ needs.extract-version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
| 230 |
+
echo "**Event:** ${{ github.event_name }}" >> $GITHUB_STEP_SUMMARY
|
| 231 |
+
echo "**Push to Main:** ${{ needs.extract-version.outputs.is_push_to_main }}" >> $GITHUB_STEP_SUMMARY
|
| 232 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 233 |
+
|
| 234 |
+
if [[ "${{ needs.extract-version.outputs.is_push_to_main }}" == "true" ]]; then
|
| 235 |
+
echo "### Published Tags:" >> $GITHUB_STEP_SUMMARY
|
| 236 |
+
|
| 237 |
+
if [[ "${{ needs.build-regular.result }}" == "success" ]]; then
|
| 238 |
+
echo "✅ **Regular:** \`${{ env.GHCR_IMAGE }}:v1-dev\`" >> $GITHUB_STEP_SUMMARY
|
| 239 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 240 |
+
echo "✅ **Regular (Docker Hub):** \`${{ env.DOCKERHUB_IMAGE }}:v1-dev\`" >> $GITHUB_STEP_SUMMARY
|
| 241 |
+
fi
|
| 242 |
+
else
|
| 243 |
+
echo "❌ **Regular:** Build failed" >> $GITHUB_STEP_SUMMARY
|
| 244 |
+
fi
|
| 245 |
+
|
| 246 |
+
if [[ "${{ needs.build-single.result }}" == "success" ]]; then
|
| 247 |
+
echo "✅ **Single:** \`${{ env.GHCR_IMAGE }}:v1-dev-single\`" >> $GITHUB_STEP_SUMMARY
|
| 248 |
+
if [[ "${{ needs.extract-version.outputs.has_dockerhub_secrets }}" == "true" ]]; then
|
| 249 |
+
echo "✅ **Single (Docker Hub):** \`${{ env.DOCKERHUB_IMAGE }}:v1-dev-single\`" >> $GITHUB_STEP_SUMMARY
|
| 250 |
+
fi
|
| 251 |
+
elif [[ "${{ needs.build-single.result }}" == "skipped" ]]; then
|
| 252 |
+
echo "⏭️ **Single:** Skipped" >> $GITHUB_STEP_SUMMARY
|
| 253 |
+
else
|
| 254 |
+
echo "❌ **Single:** Build failed" >> $GITHUB_STEP_SUMMARY
|
| 255 |
+
fi
|
| 256 |
+
|
| 257 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 258 |
+
echo "### Platforms:" >> $GITHUB_STEP_SUMMARY
|
| 259 |
+
echo "- linux/amd64" >> $GITHUB_STEP_SUMMARY
|
| 260 |
+
echo "- linux/arm64" >> $GITHUB_STEP_SUMMARY
|
| 261 |
+
else
|
| 262 |
+
echo "### Test Build Results:" >> $GITHUB_STEP_SUMMARY
|
| 263 |
+
if [[ "${{ needs.build-regular.result }}" == "success" ]]; then
|
| 264 |
+
echo "✅ **Dockerfile:** Build successful" >> $GITHUB_STEP_SUMMARY
|
| 265 |
+
else
|
| 266 |
+
echo "❌ **Dockerfile:** Build failed" >> $GITHUB_STEP_SUMMARY
|
| 267 |
+
fi
|
| 268 |
+
echo "" >> $GITHUB_STEP_SUMMARY
|
| 269 |
+
echo "### Notes:" >> $GITHUB_STEP_SUMMARY
|
| 270 |
+
echo "- This is a test build (no images pushed to registry)" >> $GITHUB_STEP_SUMMARY
|
| 271 |
+
echo "- Merge to main to publish \`v1-dev\` tags" >> $GITHUB_STEP_SUMMARY
|
| 272 |
+
echo "- For stable releases, use the 'Build and Release' workflow" >> $GITHUB_STEP_SUMMARY
|
| 273 |
+
fi
|
.github/workflows/test.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Tests
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
pull_request:
|
| 5 |
+
branches: [main]
|
| 6 |
+
push:
|
| 7 |
+
branches: [main]
|
| 8 |
+
paths-ignore:
|
| 9 |
+
- '**.md'
|
| 10 |
+
- 'docs/**'
|
| 11 |
+
- '.github/workflows/claude*.yml'
|
| 12 |
+
|
| 13 |
+
permissions:
|
| 14 |
+
contents: read
|
| 15 |
+
|
| 16 |
+
jobs:
|
| 17 |
+
backend:
|
| 18 |
+
name: Backend Tests
|
| 19 |
+
runs-on: ubuntu-latest
|
| 20 |
+
steps:
|
| 21 |
+
- name: Checkout
|
| 22 |
+
uses: actions/checkout@v6
|
| 23 |
+
|
| 24 |
+
- name: Set up uv
|
| 25 |
+
uses: astral-sh/setup-uv@v8.1.0
|
| 26 |
+
with:
|
| 27 |
+
enable-cache: true
|
| 28 |
+
|
| 29 |
+
- name: Set up Python
|
| 30 |
+
run: uv python install
|
| 31 |
+
|
| 32 |
+
- name: Install dependencies
|
| 33 |
+
run: uv sync
|
| 34 |
+
|
| 35 |
+
- name: Run tests
|
| 36 |
+
run: uv run pytest tests/ -v
|
| 37 |
+
|
| 38 |
+
frontend:
|
| 39 |
+
name: Frontend Tests
|
| 40 |
+
runs-on: ubuntu-latest
|
| 41 |
+
defaults:
|
| 42 |
+
run:
|
| 43 |
+
working-directory: frontend
|
| 44 |
+
steps:
|
| 45 |
+
- name: Checkout
|
| 46 |
+
uses: actions/checkout@v6
|
| 47 |
+
|
| 48 |
+
- name: Set up Node.js
|
| 49 |
+
uses: actions/setup-node@v6
|
| 50 |
+
with:
|
| 51 |
+
node-version: 22
|
| 52 |
+
cache: npm
|
| 53 |
+
cache-dependency-path: frontend/package-lock.json
|
| 54 |
+
|
| 55 |
+
- name: Install dependencies
|
| 56 |
+
run: npm ci
|
| 57 |
+
|
| 58 |
+
- name: Run tests
|
| 59 |
+
run: npm test
|
.gitignore
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
prompts/patterns/user/
|
| 3 |
+
/notebooks/
|
| 4 |
+
data/
|
| 5 |
+
.uploads/
|
| 6 |
+
sqlite-db/
|
| 7 |
+
surreal-data/
|
| 8 |
+
docker.env
|
| 9 |
+
notebook_data/
|
| 10 |
+
# Python-specific
|
| 11 |
+
*.py[cod]
|
| 12 |
+
__pycache__/
|
| 13 |
+
*.so
|
| 14 |
+
todo.md
|
| 15 |
+
temp/
|
| 16 |
+
google-credentials.json
|
| 17 |
+
# Distribution / packaging
|
| 18 |
+
.Python
|
| 19 |
+
build/
|
| 20 |
+
develop-eggs/
|
| 21 |
+
dist/
|
| 22 |
+
downloads/
|
| 23 |
+
eggs/
|
| 24 |
+
.eggs/
|
| 25 |
+
/lib/
|
| 26 |
+
/lib64/
|
| 27 |
+
parts/
|
| 28 |
+
sdist/
|
| 29 |
+
var/
|
| 30 |
+
wheels/
|
| 31 |
+
share/python-wheels/
|
| 32 |
+
*.egg-info/
|
| 33 |
+
.installed.cfg
|
| 34 |
+
*.egg
|
| 35 |
+
|
| 36 |
+
# PyInstaller
|
| 37 |
+
*.manifest
|
| 38 |
+
*.spec
|
| 39 |
+
|
| 40 |
+
# Installer logs
|
| 41 |
+
pip-log.txt
|
| 42 |
+
pip-delete-this-directory.txt
|
| 43 |
+
|
| 44 |
+
# Unit test / coverage reports
|
| 45 |
+
htmlcov/
|
| 46 |
+
.tox/
|
| 47 |
+
.nox/
|
| 48 |
+
.coverage
|
| 49 |
+
.coverage.*
|
| 50 |
+
.cache
|
| 51 |
+
nosetests.xml
|
| 52 |
+
coverage.xml
|
| 53 |
+
*.cover
|
| 54 |
+
*.py,cover
|
| 55 |
+
.hypothesis/
|
| 56 |
+
.pytest_cache/
|
| 57 |
+
|
| 58 |
+
# Jupyter Notebook
|
| 59 |
+
.ipynb_checkpoints
|
| 60 |
+
|
| 61 |
+
# IPython
|
| 62 |
+
profile_default/
|
| 63 |
+
ipython_config.py
|
| 64 |
+
|
| 65 |
+
# Environments
|
| 66 |
+
.env
|
| 67 |
+
.venv
|
| 68 |
+
env/
|
| 69 |
+
venv/
|
| 70 |
+
ENV/
|
| 71 |
+
env.bak/
|
| 72 |
+
venv.bak/
|
| 73 |
+
|
| 74 |
+
# PyCharm
|
| 75 |
+
.idea/
|
| 76 |
+
|
| 77 |
+
# VS Code
|
| 78 |
+
.vscode/
|
| 79 |
+
|
| 80 |
+
# Spyder project settings
|
| 81 |
+
.spyderproject
|
| 82 |
+
.spyproject
|
| 83 |
+
|
| 84 |
+
# Rope project settings
|
| 85 |
+
.ropeproject
|
| 86 |
+
|
| 87 |
+
# mkdocs documentation
|
| 88 |
+
/site
|
| 89 |
+
|
| 90 |
+
# mypy
|
| 91 |
+
.mypy_cache/
|
| 92 |
+
.dmypy.json
|
| 93 |
+
dmypy.json
|
| 94 |
+
|
| 95 |
+
# Pyre type checker
|
| 96 |
+
.pyre/
|
| 97 |
+
|
| 98 |
+
# pytype static type analyzer
|
| 99 |
+
.pytype/
|
| 100 |
+
|
| 101 |
+
# Cython debug symbols
|
| 102 |
+
cython_debug/
|
| 103 |
+
|
| 104 |
+
# macOS
|
| 105 |
+
.DS_Store
|
| 106 |
+
|
| 107 |
+
# Windows
|
| 108 |
+
Thumbs.db
|
| 109 |
+
ehthumbs.db
|
| 110 |
+
desktop.ini
|
| 111 |
+
|
| 112 |
+
# Linux
|
| 113 |
+
*~
|
| 114 |
+
|
| 115 |
+
# Log files
|
| 116 |
+
*.log
|
| 117 |
+
|
| 118 |
+
# Database files
|
| 119 |
+
*.db
|
| 120 |
+
*.sqlite3
|
| 121 |
+
|
| 122 |
+
.quarentena
|
| 123 |
+
|
| 124 |
+
claude-logs/
|
| 125 |
+
.claude/sessions
|
| 126 |
+
**/claude-logs
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
docs/custom_gpt
|
| 130 |
+
doc_exports/
|
| 131 |
+
|
| 132 |
+
specs/
|
| 133 |
+
.claude
|
| 134 |
+
.sisyphus
|
| 135 |
+
|
| 136 |
+
.playwright-mcp/
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
*.local.yml
|
| 141 |
+
**/*.local.md
|
| 142 |
+
.harness/
|
| 143 |
+
|
| 144 |
+
.mcp.json
|
.python-version
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
3.12
|
.worktreeinclude
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.local
|
| 3 |
+
.env.*
|
| 4 |
+
**/.claude/settings.local.json
|
| 5 |
+
CLAUDE.local.md
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
All notable changes to this project will be documented in this file.
|
| 4 |
+
|
| 5 |
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
| 6 |
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
| 7 |
+
|
| 8 |
+
## [Unreleased]
|
| 9 |
+
|
| 10 |
+
## [1.9.0] - 2026-06-02
|
| 11 |
+
|
| 12 |
+
### Added
|
| 13 |
+
- **New audio providers**, surfacing the capabilities added in Esperanto 2.21–2.22:
|
| 14 |
+
- **Mistral Voxtral** speech-to-text (`voxtral-*-latest`) and text-to-speech (`voxtral-mini-tts`), reusing the existing Mistral credential (#827)
|
| 15 |
+
- **Deepgram** text-to-speech (Aura voice catalog) as a new provider (`DEEPGRAM_API_KEY`) (#827)
|
| 16 |
+
- **xAI** text-to-speech (#827)
|
| 17 |
+
- **Google** speech-to-text & text-to-speech, **Vertex** text-to-speech, and **ElevenLabs** speech-to-text (Scribe), completing the audio provider matrix (#828)
|
| 18 |
+
- Optional per-credential **`num_ctx`** (context window) override for Ollama models, configurable in Settings → API Keys and translated across all 13 locales (#825)
|
| 19 |
+
- `OPEN_NOTEBOOK_EMBEDDING_BATCH_SIZE` environment variable to override the embedding batch size; default remains `50`. Helps with CPU-only local embedding and stricter OpenAI-compatible endpoints (#735)
|
| 20 |
+
- `CORS_ORIGINS` environment variable to configure the API's allowed origins (comma-separated). Default remains `*` for backward compatibility; the API now logs a startup warning prompting users to set it for production deployments. Exception responses honor the configured origins when explicitly set (#585, #597, #730)
|
| 21 |
+
- `OPEN_NOTEBOOK_MIN_CHUNK_SIZE` environment variable (default: 5 tokens) to filter out degenerate tiny chunks before embedding. Set to `0` to disable.
|
| 22 |
+
|
| 23 |
+
### Changed
|
| 24 |
+
- Bumped **Esperanto 2.20.0 → 2.22.0**. Beyond the new audio providers above, this inherits several upstream fixes and behavior changes (see below).
|
| 25 |
+
|
| 26 |
+
### Inherited from Esperanto 2.21–2.22
|
| 27 |
+
- **Fixed:** OpenRouter LLM and embedding requests now send a proper JSON body (previously sent a malformed form-encoded payload).
|
| 28 |
+
- **Fixed:** OpenAI-compatible endpoints (e.g. llama.cpp) that return null embeddings now raise a clear, descriptive error instead of an opaque `TypeError`.
|
| 29 |
+
- **Fixed:** Streaming tool calls now return proper `ToolCall` objects across Anthropic, Google, Vertex, and Ollama.
|
| 30 |
+
- **Fixed:** `base_url` trailing slashes are normalized across providers, preventing double-slash URLs (and 301 redirects) for Ollama and other self-hosted endpoints.
|
| 31 |
+
- **Fixed:** Ollama "thinking" models (e.g. Qwen) now merge their reasoning content correctly.
|
| 32 |
+
- **Fixed:** Model discovery honors a custom `base_url` (LiteLLM/vLLM/OpenAI-compatible proxies).
|
| 33 |
+
- **Behavior change:** the Ollama default context window (`num_ctx`) is now **8192** (was 128000) to avoid out-of-memory errors on consumer GPUs. Raise it per-credential via the new `num_ctx` field if your hardware allows.
|
| 34 |
+
- **Behavior change:** the Google embedding default model is now `gemini-embedding-001` (the previous default, `text-embedding-004`, was removed from Google's API). If you used Google embeddings with the old default, re-create the model and re-embed your content (embedding dimensions changed).
|
| 35 |
+
- **Fixed:** Google TTS default model updated to a currently-working preview model.
|
| 36 |
+
|
| 37 |
+
### Fixed
|
| 38 |
+
- URL source embedding no longer crashes with `TypeError: float() argument must be a string or a real number, not 'NoneType'` when header-based splitters emit single-character fragments from complex HTML pages (e.g. Wikipedia, Project Gutenberg). Such chunks are now filtered before being sent to the embedding provider (#764)
|
| 39 |
+
- Language toggle now uses `t('common.german')` instead of a hardcoded "Deutsch" label, matching the pattern used by every other language entry (follow-up to #794)
|
| 40 |
+
- Speech-to-text model connection tests now transcribe a short bundled speech clip instead of silence, so a passing test returns real text instead of a blank transcription (#838)
|
| 41 |
+
|
| 42 |
+
## [1.8.5] - 2026-04-14
|
| 43 |
+
|
| 44 |
+
### Changed
|
| 45 |
+
- Embedding chunking is now token-based instead of character-based, improving chunk sizing consistency for CJK and mixed-language content (#542, #749)
|
| 46 |
+
- `OPEN_NOTEBOOK_CHUNK_SIZE` and `OPEN_NOTEBOOK_CHUNK_OVERLAP` semantics changed from characters to tokens; default reduced from 1200 characters to 400 tokens to stay safely below the 512-token ceiling of BERT-family embedders (e.g. mxbai-embed-large) after accounting for tokenizer mismatch and splitter overshoot. Existing stored embeddings are unaffected; only new ingestions use the new chunking.
|
| 47 |
+
|
| 48 |
+
### Fixed
|
| 49 |
+
- Credentials endpoint no longer crashes (500) when encryption key doesn't match stored credentials (#740)
|
| 50 |
+
- Broken credentials are now shown with a decryption warning and can still be deleted
|
| 51 |
+
- DELETE endpoint for broken credentials supports model migration (`migrate_to` parameter)
|
| 52 |
+
|
| 53 |
+
## [1.8.4] - 2026-04-09
|
| 54 |
+
|
| 55 |
+
### Security
|
| 56 |
+
- Fix Remote Code Execution (RCE) via Jinja2 Server-Side Template Injection in transformations (CVSS 9.2 Critical)
|
| 57 |
+
- Fix arbitrary file write via path traversal in file upload (CVSS 7.0 High)
|
| 58 |
+
- Fix arbitrary file read via Local File Inclusion in source creation (CVSS 8.2 High)
|
| 59 |
+
|
| 60 |
+
### Dependencies
|
| 61 |
+
- Bump ai-prompter to >=0.4.0 (uses Jinja2 SandboxedEnvironment to prevent SSTI)
|
| 62 |
+
|
| 63 |
+
## [1.8.3] - 2026-04-07
|
| 64 |
+
|
| 65 |
+
### Security
|
| 66 |
+
- Fix SurrealDB injection via unsanitized `order_by` query parameter in `GET /api/notebooks` (CVSS 8.7 High)
|
| 67 |
+
- Add allowlist validation for sorting parameters in notebooks endpoint
|
| 68 |
+
- Replace f-string query interpolation with parameterized `$variable` binding in source chat and migration queries
|
| 69 |
+
- Add defensive validation in `get_all()` base method to prevent injection via `order_by` parameter
|
| 70 |
+
|
| 71 |
+
## [1.8.2] - 2026-04-06
|
| 72 |
+
|
| 73 |
+
### Added
|
| 74 |
+
- DashScope (Qwen) and MiniMax provider support via Esperanto v2.20.0 (#725)
|
| 75 |
+
- Source list auto-refresh after adding a new source via URL, file upload, or text (#721)
|
| 76 |
+
|
| 77 |
+
### Fixed
|
| 78 |
+
- Source asset persistence — failed sources now persist their asset (URL/file path), making them identifiable and retryable (#722)
|
| 79 |
+
- Source title preservation — user-set custom titles are no longer overwritten after background processing (#722)
|
| 80 |
+
- Credential cascade delete — deleting a credential now removes linked models instead of returning a 409 error (#722)
|
| 81 |
+
- Podcast directory names — uses UUID for episode directories, fixing filesystem errors with special characters (#666)
|
| 82 |
+
- Tiktoken offline handling — API no longer crashes in air-gapped environments (#622)
|
| 83 |
+
- SurrealDB healthcheck — removed incompatible healthcheck from Docker Compose (#656)
|
| 84 |
+
- Esperanto embedding fixes — base_url/api_key config issues across multiple embedding providers (#664, #665)
|
| 85 |
+
|
| 86 |
+
### Docs
|
| 87 |
+
- Deprecated single-container Docker image in favor of Docker Compose (#723)
|
| 88 |
+
|
| 89 |
+
### Dependencies
|
| 90 |
+
- Bump esperanto to >=2.20.0
|
| 91 |
+
|
| 92 |
+
## [1.8.1] - 2026-03-10
|
| 93 |
+
|
| 94 |
+
### Added
|
| 95 |
+
- i18n support for Bengali (bn-IN) (#643)
|
| 96 |
+
- Podcast language support via podcast-creator 0.12.0 (#645)
|
| 97 |
+
- Upgrade default Azure API version for model testing and fetching (#638)
|
| 98 |
+
|
| 99 |
+
### Fixed
|
| 100 |
+
- Tiktoken network errors in offline/air-gapped Docker deployments — pre-downloads encoding at build time (#264, #622)
|
| 101 |
+
- SurrealDB getting stuck (#656)
|
| 102 |
+
|
| 103 |
+
### Dependencies
|
| 104 |
+
- Bump esperanto to 2.19.5 (#657)
|
| 105 |
+
- Bump langgraph from 1.0.6 to 1.0.10rc1 (#658)
|
| 106 |
+
- Bump authlib from 1.6.6 to 1.6.7 (#649)
|
| 107 |
+
- Bump lxml-html-clean from 0.4.3 to 0.4.4 (#646)
|
| 108 |
+
- Bump rollup from 4.55.1 to 4.59.0 (#635)
|
| 109 |
+
- Bump minimatch in frontend (#634)
|
| 110 |
+
- Bump tar from 7.5.9 to 7.5.11 (#650, #659)
|
| 111 |
+
|
| 112 |
+
## [1.7.4] - 2026-02-18
|
| 113 |
+
|
| 114 |
+
### Fixed
|
| 115 |
+
- Embedding large documents (3MB+) fails with 413 Payload Too Large (#594)
|
| 116 |
+
- `generate_embeddings()` now batches texts in groups of 50 with per-batch retry, preventing provider payload limits from being exceeded
|
| 117 |
+
- 413 errors now classified with user-friendly message in error classifier
|
| 118 |
+
- Misleading "Created 0 embedded chunks" log in `process_source_command` — embedding is fire-and-forget, so the count was always 0; now logs "embedding submitted" instead
|
| 119 |
+
|
| 120 |
+
## [1.7.3] - 2026-02-17
|
| 121 |
+
|
| 122 |
+
### Added
|
| 123 |
+
- Retry button for failed podcast episodes in the UI (#211, #218)
|
| 124 |
+
- Error details displayed on failed podcast episodes (#185, #355)
|
| 125 |
+
- `POST /podcasts/episodes/{id}/retry` API endpoint for re-submitting failed episodes
|
| 126 |
+
- `error_message` field in podcast episode API responses
|
| 127 |
+
|
| 128 |
+
### Fixed
|
| 129 |
+
- Podcast generation failures now correctly marked as "failed" instead of "completed" (#300, #335)
|
| 130 |
+
- Disabled automatic retries for podcast generation to prevent duplicate episode records (#302)
|
| 131 |
+
|
| 132 |
+
### Dependencies
|
| 133 |
+
- Bump podcast-creator to >= 0.11.2
|
| 134 |
+
- Bump esperanto to >= 2.19.4
|
| 135 |
+
|
| 136 |
+
## [1.7.2] - 2026-02-16
|
| 137 |
+
|
| 138 |
+
### Added
|
| 139 |
+
- Error classification utility that maps LLM provider errors to user-friendly messages (#506)
|
| 140 |
+
- Global exception handlers in FastAPI for all custom exception types with proper HTTP status codes
|
| 141 |
+
- `getApiErrorMessage()` frontend helper that falls back to backend messages when no i18n mapping exists
|
| 142 |
+
|
| 143 |
+
### Fixed
|
| 144 |
+
- LLM errors (invalid API key, wrong model, rate limits) now show descriptive messages instead of "An unexpected error occurred" (#590)
|
| 145 |
+
- SSE streaming error events in source chat and ask hooks were swallowed by inner JSON parse catch blocks
|
| 146 |
+
- Transformation execution errors were caught and re-wrapped as generic 500s instead of using proper status codes
|
| 147 |
+
- Fail fast when source content extraction returns empty instead of retrying (#589)
|
| 148 |
+
- Chat input and message overflow with long unbroken strings (#588)
|
| 149 |
+
- Word-wrap overflow in source cards, note editor, inline edit, note titles, and dialog content (#588)
|
| 150 |
+
- Translation proxy shadowing `name` keys (#588)
|
| 151 |
+
- OpenAI-compatible provider name handling via Esperanto update (#583)
|
| 152 |
+
|
| 153 |
+
### Changed
|
| 154 |
+
- `ValueError` replaced with `ConfigurationError` in model provisioning for proper error classification
|
| 155 |
+
- `ConfigurationError` added to command retry `stop_on` lists to avoid retrying permanent config failures
|
| 156 |
+
|
| 157 |
+
### Dependencies
|
| 158 |
+
- Bump esperanto to 2.19.3 (#583)
|
| 159 |
+
- Bump podcast-creator to 0.9.1
|
| 160 |
+
|
| 161 |
+
## [1.7.1] - 2026-02-14
|
| 162 |
+
|
| 163 |
+
### Added
|
| 164 |
+
- French (fr-FR) language support (#581)
|
| 165 |
+
- CI test workflow and improved i18n validation (#580)
|
| 166 |
+
- Expose embed `command_id` in note API responses (#545)
|
| 167 |
+
|
| 168 |
+
### Fixed
|
| 169 |
+
- ElevenLabs TTS credential passthrough via Esperanto update (#578)
|
| 170 |
+
- Handle empty/whitespace source content without retry loop (#576)
|
| 171 |
+
- Increase transformation `max_tokens` and update Esperanto dep (#568)
|
| 172 |
+
- Turn the embedding field into optional (#557)
|
| 173 |
+
|
| 174 |
+
### Docs
|
| 175 |
+
- Fix docker container names in local setup guides (#577)
|
| 176 |
+
|
| 177 |
+
### Dependencies
|
| 178 |
+
- Bump langchain-core from 1.2.7 to 1.2.11 (#564)
|
| 179 |
+
- Bump cryptography from 46.0.3 to 46.0.5 (#563)
|
| 180 |
+
|
| 181 |
+
## [1.7.0] - 2026-02-10
|
| 182 |
+
|
| 183 |
+
### Added
|
| 184 |
+
- **Credential-Based Provider Management** (#477)
|
| 185 |
+
- New Settings → API Keys page for managing AI provider credentials via the UI
|
| 186 |
+
- Support for 14 providers: OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter, Voyage AI, ElevenLabs, Ollama, Azure OpenAI, OpenAI-Compatible, and Vertex AI
|
| 187 |
+
- Secure storage of API keys in SurrealDB with field-level encryption (Fernet AES-128-CBC + HMAC-SHA256)
|
| 188 |
+
- One-click connection testing, model discovery, and model registration per credential
|
| 189 |
+
- Migration tool to import existing environment variable keys into the credential system
|
| 190 |
+
- Azure OpenAI support with service-specific endpoints (LLM, Embedding, STT, TTS)
|
| 191 |
+
- OpenAI-Compatible support with per-service URL configurations
|
| 192 |
+
- Vertex AI support with project, location, and credentials path
|
| 193 |
+
- Environment variable API keys deprecated in favor of Settings UI
|
| 194 |
+
|
| 195 |
+
- **Security Enhancements**
|
| 196 |
+
- Docker secrets support via `_FILE` suffix pattern (e.g., `OPEN_NOTEBOOK_PASSWORD_FILE`)
|
| 197 |
+
- Default encryption key derived from "0p3n-N0t3b0ok" for easy setup (change in production!)
|
| 198 |
+
- Default password "open-notebook-change-me" for out-of-box experience (change in production!)
|
| 199 |
+
- URL validation for SSRF protection - blocks private IPs and localhost (except for Ollama which runs locally)
|
| 200 |
+
- Security warnings logged when using default credentials
|
| 201 |
+
|
| 202 |
+
- HTML clipboard detection for text sources (#426)
|
| 203 |
+
- When pasting content, automatically detects HTML format (e.g., from Word, web pages)
|
| 204 |
+
- Shows info message when HTML is detected, informing user it will be converted to Markdown
|
| 205 |
+
- Preserves formatting that would be lost with plain text paste
|
| 206 |
+
- Bump content-core to 0.11.0 for HTML to Markdown conversion support
|
| 207 |
+
|
| 208 |
+
- **Improved Getting Started Experience**
|
| 209 |
+
- Simplified docker-compose.yml in repository root (single official file)
|
| 210 |
+
- Added examples/ folder with ready-made configurations:
|
| 211 |
+
- `docker-compose-ollama.yml` - Local AI with Ollama
|
| 212 |
+
- `docker-compose-speaches.yml` - Local TTS/STT with Speaches
|
| 213 |
+
- `docker-compose-full-local.yml` - 100% local setup (Ollama + Speaches)
|
| 214 |
+
- Inline quick start in README (no need to navigate to docs)
|
| 215 |
+
- Cross-references between docker-compose examples and documentation
|
| 216 |
+
- .env.example template with all configuration options
|
| 217 |
+
|
| 218 |
+
### Fixed
|
| 219 |
+
- Azure form race condition: all configuration now saved in single atomic request
|
| 220 |
+
- Migration API "error error" display: added proper MigrationResult model with message field
|
| 221 |
+
- Connection tester for Ollama providers: improved error handling and URL validation
|
| 222 |
+
- SqliteSaver async compatibility issues in chat system (#509, #525, #538)
|
| 223 |
+
- Re-embedding failures with empty content (#513, #515)
|
| 224 |
+
- Deletion cascade for notes and sources (#77)
|
| 225 |
+
- YouTube content availability issues (#494)
|
| 226 |
+
- Large document embedding errors (#489)
|
| 227 |
+
|
| 228 |
+
### Security
|
| 229 |
+
- API keys are encrypted at rest using Fernet symmetric encryption
|
| 230 |
+
- Keys are never returned to the frontend, only configuration status
|
| 231 |
+
- SSRF protection prevents internal network access via URL validation
|
| 232 |
+
|
| 233 |
+
### Docs
|
| 234 |
+
- Complete documentation update for credential-based system across 25 files
|
| 235 |
+
- All quick-start, installation, and configuration guides now use Settings UI workflow
|
| 236 |
+
- Environment variable API key instructions moved to deprecated/legacy sections
|
| 237 |
+
- Fixed broken links in installation docs
|
| 238 |
+
- Added comprehensive examples/ folder with documented docker-compose configurations
|
| 239 |
+
- Updated local-tts.md and local-stt.md with links to ready-made examples
|
| 240 |
+
|
| 241 |
+
### Internationalization
|
| 242 |
+
- Added Russian (ru-RU) language support (#524)
|
| 243 |
+
- Added Italian (it-IT) language support (#508)
|
| 244 |
+
|
| 245 |
+
## [1.6.2] - 2026-01-24
|
| 246 |
+
|
| 247 |
+
### Fixed
|
| 248 |
+
- Connection error with llama.cpp and OpenAI-compatible providers (#465)
|
| 249 |
+
- Bump Esperanto to 2.17.2 which fixes LangChain connection errors caused by garbage collection
|
| 250 |
+
|
| 251 |
+
## [1.6.1] - 2026-01-22
|
| 252 |
+
|
| 253 |
+
### Fixed
|
| 254 |
+
- "Failed to send message" error with unhelpful logs when chat model is not configured (#358)
|
| 255 |
+
- Added detailed error logging with model selection context and full traceback
|
| 256 |
+
- Improved error messages to guide users to Settings → Models
|
| 257 |
+
- Added warnings when default models are not configured
|
| 258 |
+
|
| 259 |
+
### Docs
|
| 260 |
+
- Ollama troubleshooting: Added "Model Name Configuration" section emphasizing exact model names from `ollama list`
|
| 261 |
+
- Added troubleshooting entry for "Failed to send message" error with step-by-step solutions
|
| 262 |
+
- Updated AI Chat Issues documentation with model configuration guidance
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
## [1.6.0] - 2026-01-21
|
| 266 |
+
|
| 267 |
+
### Added
|
| 268 |
+
- Content-type aware text chunking with automatic HTML, Markdown, and plain text detection (#350, #142)
|
| 269 |
+
- Unified embedding generation with mean pooling for large content that exceeds model context limits
|
| 270 |
+
- Dedicated embedding commands: `embed_note`, `embed_insight`, `embed_source`
|
| 271 |
+
- New utility modules: `chunking.py` and `embedding.py` in `open_notebook/utils/`
|
| 272 |
+
- Japanese (ja-JP) language support (#450)
|
| 273 |
+
|
| 274 |
+
### Changed
|
| 275 |
+
- Embedding is now fire-and-forget: domain models submit embedding commands asynchronously after save
|
| 276 |
+
- `rebuild_embeddings_command` now delegates to individual embed_* commands instead of inline processing
|
| 277 |
+
- Chunk size reduced to 1500 characters for better compatibility with Ollama embedding models
|
| 278 |
+
- Bump Esperanto to 2.16 for increased Ollama context window support
|
| 279 |
+
|
| 280 |
+
### Removed
|
| 281 |
+
- Legacy embedding commands: `embed_single_item_command`, `embed_chunk_command`, `vectorize_source_command`
|
| 282 |
+
- `needs_embedding()` and `get_embedding_content()` methods from domain models
|
| 283 |
+
- `split_text()` function from text_utils (replaced by `chunk_text()` in chunking module)
|
| 284 |
+
|
| 285 |
+
### Fixed
|
| 286 |
+
- Embedding failures when content exceeds model context limits (#350, #142)
|
| 287 |
+
- Empty note titles when saving from chat (clean thinking tags from prompt graph output)
|
| 288 |
+
- Orphaned embedding/insight records when deleting sources (cascade delete)
|
| 289 |
+
- Search results crash with null parent_id (defensive frontend check)
|
| 290 |
+
- Database migration 10 cleans up existing orphaned records
|
| 291 |
+
|
| 292 |
+
## [1.5.2] - 2026-01-15
|
| 293 |
+
|
| 294 |
+
### Performance
|
| 295 |
+
- Improved source listing speed by 20-30x (#436, closes #351)
|
| 296 |
+
- Added database indexes on `source` field for `source_insight` and `source_embedding` tables
|
| 297 |
+
- Use SurrealDB `FETCH` clause for command status instead of N async calls
|
| 298 |
+
|
| 299 |
+
## [1.5.1] - 2026-01-15
|
| 300 |
+
|
| 301 |
+
### Fixed
|
| 302 |
+
- Podcast dialog infinite loop error caused by excessive translation Proxy accesses in loops
|
| 303 |
+
- Podcast dialog UI freezing when typing episode name or additional instructions
|
| 304 |
+
- Removed incorrect translation keys for user-defined episode profiles (user content should not be translated)
|
| 305 |
+
|
| 306 |
+
## [1.5.0] - 2026-01-15
|
| 307 |
+
|
| 308 |
+
### Added
|
| 309 |
+
- Internationalization (i18n) support with Chinese (Simplified and Traditional) translations (#371, closes #344, #349, #360)
|
| 310 |
+
- Frontend test infrastructure with Vitest (#371)
|
| 311 |
+
- Language toggle component for switching UI language (#371)
|
| 312 |
+
- Date localization using date-fns locales (#371)
|
| 313 |
+
- Error message translation system (#371)
|
| 314 |
+
|
| 315 |
+
### Fixed
|
| 316 |
+
- Accessibility improvements: added missing `id`, `name`, and `autoComplete` attributes to form inputs (#371)
|
| 317 |
+
- Added `DialogDescription` to dialogs for Radix UI accessibility compliance (#371)
|
| 318 |
+
- Fixed "Collapsible is changing from uncontrolled to controlled" warning in SettingsForm (#371)
|
| 319 |
+
- Fixed lint command for Next.js 16 compatibility (`eslint` instead of `next lint`)
|
| 320 |
+
|
| 321 |
+
### Changed
|
| 322 |
+
- Dockerfile optimizations: better layer caching, `--no-install-recommends` for smaller images (#371)
|
| 323 |
+
- Dockerfile.single refactored into 3 separate build stages for better caching (#371)
|
| 324 |
+
|
| 325 |
+
## [1.4.0] - 2026-01-14
|
| 326 |
+
|
| 327 |
+
### Added
|
| 328 |
+
- CTA button to empty state notebook list for better onboarding (#408)
|
| 329 |
+
- Offline deployment support for Docker containers (#414)
|
| 330 |
+
|
| 331 |
+
### Fixed
|
| 332 |
+
- Large file uploads (>10MB) by upgrading to Next.js 16 (#423)
|
| 333 |
+
- Orphaned uploaded files when sources are removed (#421)
|
| 334 |
+
- Broken documentation links to ai-providers.md (#419)
|
| 335 |
+
- ZIP support indication removed from UI (#418)
|
| 336 |
+
- Duplicate Claude Code workflow runs on PRs (#417)
|
| 337 |
+
- Claude Code review workflow now runs on PRs from forks (#416)
|
| 338 |
+
|
| 339 |
+
### Changed
|
| 340 |
+
- Upgraded Next.js from 15.4.10 to 16.1.1 (#423)
|
| 341 |
+
- Upgraded React from 19.1.0 to 19.2.3 (#423)
|
| 342 |
+
- Renamed `middleware.ts` to `proxy.ts` for Next.js 16 compatibility (#423)
|
| 343 |
+
|
| 344 |
+
### Dependencies
|
| 345 |
+
- next: 15.4.10 → 16.1.1
|
| 346 |
+
- react: 19.1.0 → 19.2.3
|
| 347 |
+
- react-dom: 19.1.0 → 19.2.3
|
| 348 |
+
|
| 349 |
+
## [1.2.4] - 2025-12-14
|
| 350 |
+
|
| 351 |
+
### Added
|
| 352 |
+
- Infinite scroll for notebook sources - no more 50 source limit (#325)
|
| 353 |
+
- Markdown table rendering in chat responses, search results, and insights (#325)
|
| 354 |
+
|
| 355 |
+
### Fixed
|
| 356 |
+
- Timeout errors with Ollama and local LLMs - increased to 10 minutes (#325)
|
| 357 |
+
- "Unable to Connect to API Server" on Docker startup - frontend now waits for API health check (#325, #315)
|
| 358 |
+
- SSL issues with langchain (#274)
|
| 359 |
+
- Query key consistency for source mutations to properly refresh infinite scroll (#325)
|
| 360 |
+
- Docker compose start-all flow (#323)
|
| 361 |
+
|
| 362 |
+
### Changed
|
| 363 |
+
- Timeout configuration now uses granular httpx.Timeout (short connect, long read) (#325)
|
| 364 |
+
|
| 365 |
+
### Dependencies
|
| 366 |
+
- Updated next.js to 15.4.10
|
| 367 |
+
- Updated httpx to >=0.27.0 for SSL fix
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Open Notebook - Root CLAUDE.md
|
| 2 |
+
|
| 3 |
+
This file provides architectural guidance for contributors working on Open Notebook at the project level.
|
| 4 |
+
|
| 5 |
+
## Project Overview
|
| 6 |
+
|
| 7 |
+
**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.
|
| 8 |
+
|
| 9 |
+
**Key Values**: Privacy-first, multi-provider AI support, fully self-hosted option, open-source transparency.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Three-Tier Architecture
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
┌─────────────────────────────────────────────────────────┐
|
| 17 |
+
│ Frontend (React/Next.js) │
|
| 18 |
+
│ frontend/ @ port 3000 │
|
| 19 |
+
├─────────────────────────────────────────────────────────┤
|
| 20 |
+
│ - Notebooks, sources, notes, chat, podcasts, search UI │
|
| 21 |
+
│ - Zustand state management, TanStack Query (React Query)│
|
| 22 |
+
│ - Shadcn/ui component library with Tailwind CSS │
|
| 23 |
+
└────────────────────────┬────────────────────────────────┘
|
| 24 |
+
│ HTTP REST
|
| 25 |
+
┌────────────────────────▼────────────────────────────────┐
|
| 26 |
+
│ API (FastAPI) │
|
| 27 |
+
│ api/ @ port 5055 │
|
| 28 |
+
├─────────────────────────────────────────────────────────┤
|
| 29 |
+
│ - REST endpoints for notebooks, sources, notes, chat │
|
| 30 |
+
│ - LangGraph workflow orchestration │
|
| 31 |
+
│ - Job queue for async operations (podcasts) │
|
| 32 |
+
│ - Multi-provider AI provisioning via Esperanto │
|
| 33 |
+
└────────────────────────┬────────────────────────────────┘
|
| 34 |
+
│ SurrealQL
|
| 35 |
+
┌────────────────────────▼────────────────────────────────┐
|
| 36 |
+
│ Database (SurrealDB) │
|
| 37 |
+
│ Graph database @ port 8000 │
|
| 38 |
+
├─────────────────────────────────────────────────────────┤
|
| 39 |
+
│ - Records: Notebook, Source, Note, ChatSession, Credential│
|
| 40 |
+
│ - Relationships: source-to-notebook, note-to-source │
|
| 41 |
+
│ - Vector embeddings for semantic search │
|
| 42 |
+
└─────────────────────────────────────────────────────────┘
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
---
|
| 46 |
+
|
| 47 |
+
## Useful sources
|
| 48 |
+
|
| 49 |
+
User documentation is at @docs/
|
| 50 |
+
|
| 51 |
+
## Tech Stack
|
| 52 |
+
|
| 53 |
+
### Frontend (`frontend/`)
|
| 54 |
+
- **Framework**: Next.js 16 (React 19)
|
| 55 |
+
- **Language**: TypeScript
|
| 56 |
+
- **State Management**: Zustand
|
| 57 |
+
- **Data Fetching**: TanStack Query (React Query)
|
| 58 |
+
- **Styling**: Tailwind CSS + Shadcn/ui
|
| 59 |
+
- **Build Tool**: Webpack (via Next.js)
|
| 60 |
+
- **i18n compatible**: All front-end changes must also consider the translation keys
|
| 61 |
+
|
| 62 |
+
### API Backend (`api/` + `open_notebook/`)
|
| 63 |
+
- **Framework**: FastAPI 0.104+
|
| 64 |
+
- **Language**: Python 3.11+
|
| 65 |
+
- **Workflows**: LangGraph state machines
|
| 66 |
+
- **Database**: SurrealDB async driver
|
| 67 |
+
- **AI Providers**: Esperanto library (8+ providers: OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI)
|
| 68 |
+
- **Job Queue**: Surreal-Commands for async jobs (podcasts)
|
| 69 |
+
- **Logging**: Loguru
|
| 70 |
+
- **Validation**: Pydantic v2
|
| 71 |
+
- **Testing**: Pytest
|
| 72 |
+
|
| 73 |
+
### Database
|
| 74 |
+
- **SurrealDB**: Graph database with built-in embedding storage and vector search
|
| 75 |
+
- **Schema Migrations**: Automatic on API startup via AsyncMigrationManager
|
| 76 |
+
|
| 77 |
+
### Additional Services
|
| 78 |
+
- **Content Processing**: content-core library (file/URL extraction)
|
| 79 |
+
- **Prompts**: AI-Prompter with Jinja2 templating
|
| 80 |
+
- **Podcast Generation**: podcast-creator library
|
| 81 |
+
- **Embeddings**: Multi-provider via Esperanto
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
## Architecture Highlights
|
| 86 |
+
|
| 87 |
+
### 1. Async-First Design
|
| 88 |
+
- All database queries, graph invocations, and API calls are async (await)
|
| 89 |
+
- SurrealDB async driver with connection pooling
|
| 90 |
+
- FastAPI handles concurrent requests efficiently
|
| 91 |
+
|
| 92 |
+
### 2. LangGraph Workflows
|
| 93 |
+
- **source.py**: Content ingestion (extract → embed → save)
|
| 94 |
+
- **chat.py**: Conversational agent with message history
|
| 95 |
+
- **ask.py**: Search + synthesis (retrieve relevant sources → LLM)
|
| 96 |
+
- **transformation.py**: Custom transformations on sources
|
| 97 |
+
- All use `provision_langchain_model()` for smart model selection
|
| 98 |
+
|
| 99 |
+
### 3. Multi-Provider AI
|
| 100 |
+
- **Esperanto library**: Unified interface to 8+ AI providers
|
| 101 |
+
- **Credential system**: Individual encrypted credential records per provider; models link to credentials for direct config
|
| 102 |
+
- **ModelManager**: Factory pattern with fallback logic; uses credential config when available, env vars as fallback
|
| 103 |
+
- **Smart selection**: Detects large contexts, prefers long-context models
|
| 104 |
+
- **Override support**: Per-request model configuration
|
| 105 |
+
|
| 106 |
+
### 4. Database Schema
|
| 107 |
+
- **Automatic migrations**: AsyncMigrationManager runs on API startup
|
| 108 |
+
- **SurrealDB graph model**: Records with relationships and embeddings
|
| 109 |
+
- **Vector search**: Built-in semantic search across all content
|
| 110 |
+
- **Transactions**: Repo functions handle ACID operations
|
| 111 |
+
|
| 112 |
+
### 5. Authentication
|
| 113 |
+
- **Current**: Simple password middleware (insecure, dev-only)
|
| 114 |
+
- **Production**: Replace with OAuth/JWT (see CONFIGURATION.md)
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Important Quirks & Gotchas
|
| 119 |
+
|
| 120 |
+
### API Startup
|
| 121 |
+
- **Migrations run automatically** on startup; check logs for errors
|
| 122 |
+
- **Must start API before UI**: UI depends on API for all data
|
| 123 |
+
- **SurrealDB must be running**: API fails without database connection
|
| 124 |
+
|
| 125 |
+
### Frontend-Backend Communication
|
| 126 |
+
- **Base API URL**: Configured in `.env.local` (default: http://localhost:5055)
|
| 127 |
+
- **CORS enabled**: Configured in `api/main.py` (allow all origins in dev)
|
| 128 |
+
- **Rate limiting**: Not built-in; add at proxy layer for production
|
| 129 |
+
|
| 130 |
+
### LangGraph Workflows
|
| 131 |
+
- **Blocking operations**: Chat/podcast workflows may take minutes; no timeout
|
| 132 |
+
- **State persistence**: Uses SQLite checkpoint storage in `/data/sqlite-db/`
|
| 133 |
+
- **Model fallback**: If primary model fails, falls back to cheaper/smaller model
|
| 134 |
+
|
| 135 |
+
### Podcast Generation
|
| 136 |
+
- **Async job queue**: `podcast_service.py` submits jobs but doesn't wait
|
| 137 |
+
- **Track status**: Use `/commands/{command_id}` endpoint to poll status
|
| 138 |
+
- **TTS failures**: Fall back to silent audio if speech synthesis fails
|
| 139 |
+
|
| 140 |
+
### Content Processing
|
| 141 |
+
- **File extraction**: Uses content-core library; supports 50+ file types
|
| 142 |
+
- **URL handling**: Extracts text + metadata from web pages
|
| 143 |
+
- **Large files**: Content processing is sync; may block API briefly
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## Component References
|
| 148 |
+
|
| 149 |
+
See dedicated CLAUDE.md files for detailed guidance:
|
| 150 |
+
|
| 151 |
+
- **[frontend/CLAUDE.md](frontend/CLAUDE.md)**: React/Next.js architecture, state management, API integration
|
| 152 |
+
- **[api/CLAUDE.md](api/CLAUDE.md)**: FastAPI structure, service pattern, endpoint development
|
| 153 |
+
- **[open_notebook/CLAUDE.md](open_notebook/CLAUDE.md)**: Backend core, domain models, LangGraph workflows, AI provisioning
|
| 154 |
+
- **[open_notebook/domain/CLAUDE.md](open_notebook/domain/CLAUDE.md)**: Data models, repository pattern, search functions
|
| 155 |
+
- **[open_notebook/ai/CLAUDE.md](open_notebook/ai/CLAUDE.md)**: ModelManager, AI provider integration, Esperanto usage
|
| 156 |
+
- **[open_notebook/graphs/CLAUDE.md](open_notebook/graphs/CLAUDE.md)**: LangGraph workflow design, state machines
|
| 157 |
+
- **[open_notebook/database/CLAUDE.md](open_notebook/database/CLAUDE.md)**: SurrealDB operations, migrations, async patterns
|
| 158 |
+
|
| 159 |
+
---
|
| 160 |
+
|
| 161 |
+
## Documentation Map
|
| 162 |
+
|
| 163 |
+
- **[README.md](README.md)**: Project overview, features, quick start
|
| 164 |
+
- **[docs/index.md](docs/index.md)**: Complete user & deployment documentation
|
| 165 |
+
- **[CONFIGURATION.md](CONFIGURATION.md)**: Environment variables, model configuration
|
| 166 |
+
- **[CONTRIBUTING.md](CONTRIBUTING.md)**: Contribution guidelines
|
| 167 |
+
- **[MAINTAINER_GUIDE.md](MAINTAINER_GUIDE.md)**: Release & maintenance procedures
|
| 168 |
+
|
| 169 |
+
---
|
| 170 |
+
|
| 171 |
+
## Testing Strategy
|
| 172 |
+
|
| 173 |
+
- **Unit tests**: `tests/test_domain.py`, `test_models_api.py`
|
| 174 |
+
- **Graph tests**: `tests/test_graphs.py` (workflow integration)
|
| 175 |
+
- **Utils tests**: `tests/test_utils.py`, `tests/test_chunking.py`, `tests/test_embedding.py`
|
| 176 |
+
- **Run all**: `uv run pytest tests/`
|
| 177 |
+
- **Coverage**: Check with `pytest --cov`
|
| 178 |
+
|
| 179 |
+
---
|
| 180 |
+
|
| 181 |
+
## Common Tasks
|
| 182 |
+
|
| 183 |
+
### Add a New API Endpoint
|
| 184 |
+
1. Create router in `api/routers/feature.py`
|
| 185 |
+
2. Create service in `api/feature_service.py`
|
| 186 |
+
3. Define schemas in `api/models.py`
|
| 187 |
+
4. Register router in `api/main.py`
|
| 188 |
+
5. Test via http://localhost:5055/docs
|
| 189 |
+
|
| 190 |
+
### Add a New LangGraph Workflow
|
| 191 |
+
1. Create `open_notebook/graphs/workflow_name.py`
|
| 192 |
+
2. Define StateDict and node functions
|
| 193 |
+
3. Build graph with `.add_node()` / `.add_edge()`
|
| 194 |
+
4. Invoke in service: `graph.ainvoke({"input": ...}, config={"..."})`
|
| 195 |
+
5. Test with sample data in `tests/`
|
| 196 |
+
|
| 197 |
+
### Add Database Migration
|
| 198 |
+
1. Create `migrations/XXX_description.surql`
|
| 199 |
+
2. Write SurrealQL schema changes
|
| 200 |
+
3. Create `migrations/XXX_description_down.surql` (optional rollback)
|
| 201 |
+
4. API auto-detects on startup; migration runs if newer than recorded version
|
| 202 |
+
|
| 203 |
+
### Deploy to Production
|
| 204 |
+
1. Review [CONFIGURATION.md](CONFIGURATION.md) for security settings
|
| 205 |
+
2. Use `make docker-release` for multi-platform image
|
| 206 |
+
3. Push to Docker Hub / GitHub Container Registry
|
| 207 |
+
4. Deploy `docker compose --profile multi up`
|
| 208 |
+
5. Verify migrations via API logs
|
| 209 |
+
|
| 210 |
+
---
|
| 211 |
+
|
| 212 |
+
## Support & Community
|
| 213 |
+
|
| 214 |
+
- **Documentation**: https://open-notebook.ai
|
| 215 |
+
- **Discord**: https://discord.gg/37XJPXfz2w
|
| 216 |
+
- **Issues**: https://github.com/lfnovo/open-notebook/issues
|
| 217 |
+
- **License**: MIT (see LICENSE)
|
| 218 |
+
|
CONFIGURATION.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Configuration Guide
|
| 2 |
+
|
| 3 |
+
**📍 This file has moved!**
|
| 4 |
+
|
| 5 |
+
All configuration documentation has been consolidated into the new documentation structure.
|
| 6 |
+
|
| 7 |
+
👉 **[Read the Configuration Guide](docs/5-CONFIGURATION/index.md)**
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Quick Links
|
| 12 |
+
|
| 13 |
+
- **AI Provider Setup** → [AI Providers](docs/5-CONFIGURATION/ai-providers.md)
|
| 14 |
+
- **Environment Variables Reference** → [Environment Reference](docs/5-CONFIGURATION/environment-reference.md)
|
| 15 |
+
- **Database Configuration** → [Database Setup](docs/5-CONFIGURATION/database.md)
|
| 16 |
+
- **Server Configuration** → [Server Settings](docs/5-CONFIGURATION/server.md)
|
| 17 |
+
- **Security Setup** → [Security Configuration](docs/5-CONFIGURATION/security.md)
|
| 18 |
+
- **Reverse Proxy** → [Reverse Proxy Setup](docs/5-CONFIGURATION/reverse-proxy.md)
|
| 19 |
+
- **Advanced Tuning** → [Advanced Configuration](docs/5-CONFIGURATION/advanced.md)
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
## What You'll Find
|
| 24 |
+
|
| 25 |
+
The new configuration documentation includes:
|
| 26 |
+
|
| 27 |
+
- **Complete environment variable reference** with examples
|
| 28 |
+
- **Provider-specific setup guides** for OpenAI, Anthropic, Google, Groq, Ollama, and more
|
| 29 |
+
- **Production deployment configurations** with security best practices
|
| 30 |
+
- **Reverse proxy examples** for Nginx, Caddy, Traefik
|
| 31 |
+
- **Database tuning** for performance optimization
|
| 32 |
+
- **Troubleshooting guides** for common configuration issues
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
For all configuration details, see **[docs/5-CONFIGURATION/](docs/5-CONFIGURATION/index.md)**.
|
CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Contributing to Open Notebook
|
| 2 |
+
|
| 3 |
+
**📍 This file has moved!**
|
| 4 |
+
|
| 5 |
+
All contribution guidelines have been consolidated into the new development documentation structure.
|
| 6 |
+
|
| 7 |
+
👉 **[Read the Contributing Guide](docs/7-DEVELOPMENT/contributing.md)**
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Quick Links
|
| 12 |
+
|
| 13 |
+
- **Want to contribute code?** → [Contributing Guide](docs/7-DEVELOPMENT/contributing.md)
|
| 14 |
+
- **Want to understand the architecture?** → [Architecture Overview](docs/7-DEVELOPMENT/architecture.md)
|
| 15 |
+
- **Want to understand our design philosophy?** → [Design Principles](docs/7-DEVELOPMENT/design-principles.md)
|
| 16 |
+
- **Are you a maintainer?** → [Maintainer Guide](docs/7-DEVELOPMENT/maintainer-guide.md)
|
| 17 |
+
- **New developer?** → [Quick Start](docs/7-DEVELOPMENT/quick-start.md)
|
| 18 |
+
|
| 19 |
+
---
|
| 20 |
+
|
| 21 |
+
## The Issue-First Workflow
|
| 22 |
+
|
| 23 |
+
**TL;DR**: Create an issue first, get it assigned, THEN code.
|
| 24 |
+
|
| 25 |
+
This prevents wasted effort and ensures your work aligns with the project. [See details →](docs/7-DEVELOPMENT/contributing.md)
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
For all contribution details, see **[docs/7-DEVELOPMENT/contributing.md](docs/7-DEVELOPMENT/contributing.md)**.
|
Dockerfile
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================================
|
| 2 |
+
# Open Notebook - HuggingFace Spaces Dockerfile
|
| 3 |
+
# Based on Dockerfile.single, adapted for HuggingFace Spaces constraints:
|
| 4 |
+
# - Single container with supervisord
|
| 5 |
+
# - Nginx reverse proxy on port 7860 (HF mandatory port)
|
| 6 |
+
# - Runs as UID 1000 (HF mandatory user)
|
| 7 |
+
# - Persistent storage at /data/
|
| 8 |
+
# ==============================================================================
|
| 9 |
+
|
| 10 |
+
# Stage 1: Frontend Builder
|
| 11 |
+
FROM node:20-slim AS frontend-builder
|
| 12 |
+
WORKDIR /app/frontend
|
| 13 |
+
|
| 14 |
+
# Copy dependency files first to leverage cache
|
| 15 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 16 |
+
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
| 17 |
+
RUN npm config set registry ${NPM_REGISTRY} \
|
| 18 |
+
&& npm config set fetch-retries 5 \
|
| 19 |
+
&& npm config set fetch-retry-mintimeout 20000 \
|
| 20 |
+
&& npm config set fetch-retry-maxtimeout 120000
|
| 21 |
+
# Retry npm ci to survive transient registry ECONNRESETs
|
| 22 |
+
RUN i=0; until npm ci; do \
|
| 23 |
+
i=$((i+1)); \
|
| 24 |
+
if [ "$i" -ge 5 ]; then echo "npm ci failed after $i attempts"; exit 1; fi; \
|
| 25 |
+
echo "npm ci failed (attempt $i); retrying in 15s"; sleep 15; \
|
| 26 |
+
done
|
| 27 |
+
|
| 28 |
+
# Copy the rest of the frontend source
|
| 29 |
+
COPY frontend/ ./
|
| 30 |
+
# Build the frontend
|
| 31 |
+
RUN npm run build
|
| 32 |
+
|
| 33 |
+
# Stage 2: SurrealDB binary (pinned to v2 to match docker-compose.yml)
|
| 34 |
+
FROM surrealdb/surrealdb:v2 AS surreal-binary
|
| 35 |
+
|
| 36 |
+
# Stage 3: Backend Builder
|
| 37 |
+
FROM python:3.12-slim-bookworm AS backend-builder
|
| 38 |
+
# Install build dependencies
|
| 39 |
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
|
| 40 |
+
# Install uv
|
| 41 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 42 |
+
WORKDIR /app
|
| 43 |
+
|
| 44 |
+
# Set build optimization environment variables
|
| 45 |
+
ENV UV_HTTP_TIMEOUT=120
|
| 46 |
+
|
| 47 |
+
# Copy dependency files first
|
| 48 |
+
COPY pyproject.toml uv.lock ./
|
| 49 |
+
COPY open_notebook/__init__.py ./open_notebook/__init__.py
|
| 50 |
+
# Install dependencies
|
| 51 |
+
RUN uv sync --frozen --no-dev
|
| 52 |
+
|
| 53 |
+
# Pre-download tiktoken encoding so the app works offline (issue #264).
|
| 54 |
+
ENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache
|
| 55 |
+
RUN mkdir -p /app/tiktoken-cache && \
|
| 56 |
+
.venv/bin/python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"
|
| 57 |
+
|
| 58 |
+
# ==============================================================================
|
| 59 |
+
# Stage 4: Runtime (HuggingFace Spaces optimized)
|
| 60 |
+
# ==============================================================================
|
| 61 |
+
FROM python:3.12-slim-bookworm AS runtime
|
| 62 |
+
|
| 63 |
+
# Install runtime dependencies (including nginx for reverse proxy)
|
| 64 |
+
# All apt installs MUST happen before switching to non-root user
|
| 65 |
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
| 66 |
+
ffmpeg \
|
| 67 |
+
supervisor \
|
| 68 |
+
nginx \
|
| 69 |
+
curl \
|
| 70 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 71 |
+
&& apt-get install -y nodejs \
|
| 72 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 73 |
+
|
| 74 |
+
# Install SurrealDB binary (from pinned v2 image)
|
| 75 |
+
COPY --from=surreal-binary /surreal /usr/local/bin/surreal
|
| 76 |
+
|
| 77 |
+
# Install uv
|
| 78 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 79 |
+
|
| 80 |
+
# ==============================================================================
|
| 81 |
+
# HuggingFace Spaces: Create user with UID 1000 (mandatory)
|
| 82 |
+
# ==============================================================================
|
| 83 |
+
RUN useradd -m -u 1000 user
|
| 84 |
+
|
| 85 |
+
WORKDIR /app
|
| 86 |
+
|
| 87 |
+
# Copy backend virtualenv and source code
|
| 88 |
+
COPY --from=backend-builder /app/.venv /app/.venv
|
| 89 |
+
COPY . /app/
|
| 90 |
+
|
| 91 |
+
# Copy pre-downloaded tiktoken encoding from builder
|
| 92 |
+
COPY --from=backend-builder /app/tiktoken-cache /app/tiktoken-cache
|
| 93 |
+
|
| 94 |
+
# Copy built frontend from standalone output
|
| 95 |
+
COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/
|
| 96 |
+
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static
|
| 97 |
+
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
|
| 98 |
+
|
| 99 |
+
# Bind Next.js to all interfaces
|
| 100 |
+
ENV HOSTNAME=0.0.0.0
|
| 101 |
+
# Point the app at the pre-baked tiktoken encoding
|
| 102 |
+
ENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache
|
| 103 |
+
|
| 104 |
+
# ==============================================================================
|
| 105 |
+
# Setup directories, Nginx, and permissions for UID 1000
|
| 106 |
+
# ==============================================================================
|
| 107 |
+
|
| 108 |
+
# Copy HuggingFace-specific configuration files
|
| 109 |
+
COPY nginx.hf.conf /etc/nginx/sites-available/default
|
| 110 |
+
COPY supervisord.hf.conf /etc/supervisor/conf.d/supervisord.conf
|
| 111 |
+
COPY start.sh /app/start.sh
|
| 112 |
+
RUN chmod +x /app/start.sh
|
| 113 |
+
|
| 114 |
+
# Ensure wait-for-api script is executable
|
| 115 |
+
RUN chmod +x /app/scripts/wait-for-api.sh
|
| 116 |
+
|
| 117 |
+
# Setup directories with proper permissions for UID 1000
|
| 118 |
+
RUN mkdir -p /app/data /data /var/log/supervisor /var/log/nginx \
|
| 119 |
+
/var/lib/nginx /var/lib/nginx/body /var/lib/nginx/proxy \
|
| 120 |
+
/var/lib/nginx/fastcgi /var/lib/nginx/uwsgi /var/lib/nginx/scgi \
|
| 121 |
+
/run /tmp/nginx
|
| 122 |
+
|
| 123 |
+
# Set ownership for non-root user (UID 1000)
|
| 124 |
+
RUN chown -R 1000:1000 /app /data /var/log/supervisor /var/log/nginx \
|
| 125 |
+
/var/lib/nginx /run /tmp/nginx /etc/nginx && \
|
| 126 |
+
chown -R 1000:1000 /var/run 2>/dev/null || true
|
| 127 |
+
|
| 128 |
+
# Configure nginx sites
|
| 129 |
+
RUN rm -f /etc/nginx/sites-enabled/default && \
|
| 130 |
+
ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
| 131 |
+
|
| 132 |
+
# ==============================================================================
|
| 133 |
+
# Environment defaults for HuggingFace
|
| 134 |
+
# ==============================================================================
|
| 135 |
+
ENV SURREAL_URL=ws://localhost:8000/rpc
|
| 136 |
+
ENV SURREAL_USER=root
|
| 137 |
+
ENV SURREAL_PASSWORD=root
|
| 138 |
+
ENV SURREAL_NAMESPACE=open_notebook
|
| 139 |
+
ENV SURREAL_DATABASE=open_notebook
|
| 140 |
+
ENV OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-in-hf-secrets
|
| 141 |
+
|
| 142 |
+
# Switch to non-root user (HF Spaces requirement)
|
| 143 |
+
USER user
|
| 144 |
+
|
| 145 |
+
# Expose HuggingFace's mandatory port
|
| 146 |
+
EXPOSE 7860
|
| 147 |
+
|
| 148 |
+
# Start via our initialization script
|
| 149 |
+
CMD ["/app/start.sh"]
|
Dockerfile.hf
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ==============================================================================
|
| 2 |
+
# Open Notebook - HuggingFace Spaces Dockerfile
|
| 3 |
+
# Based on Dockerfile.single, adapted for HuggingFace Spaces constraints:
|
| 4 |
+
# - Single container with supervisord
|
| 5 |
+
# - Nginx reverse proxy on port 7860 (HF mandatory port)
|
| 6 |
+
# - Runs as UID 1000 (HF mandatory user)
|
| 7 |
+
# - Persistent storage at /data/
|
| 8 |
+
# ==============================================================================
|
| 9 |
+
|
| 10 |
+
# Stage 1: Frontend Builder
|
| 11 |
+
FROM node:20-slim AS frontend-builder
|
| 12 |
+
WORKDIR /app/frontend
|
| 13 |
+
|
| 14 |
+
# Copy dependency files first to leverage cache
|
| 15 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 16 |
+
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
| 17 |
+
RUN npm config set registry ${NPM_REGISTRY} \
|
| 18 |
+
&& npm config set fetch-retries 5 \
|
| 19 |
+
&& npm config set fetch-retry-mintimeout 20000 \
|
| 20 |
+
&& npm config set fetch-retry-maxtimeout 120000
|
| 21 |
+
# Retry npm ci to survive transient registry ECONNRESETs
|
| 22 |
+
RUN i=0; until npm ci; do \
|
| 23 |
+
i=$((i+1)); \
|
| 24 |
+
if [ "$i" -ge 5 ]; then echo "npm ci failed after $i attempts"; exit 1; fi; \
|
| 25 |
+
echo "npm ci failed (attempt $i); retrying in 15s"; sleep 15; \
|
| 26 |
+
done
|
| 27 |
+
|
| 28 |
+
# Copy the rest of the frontend source
|
| 29 |
+
COPY frontend/ ./
|
| 30 |
+
# Build the frontend
|
| 31 |
+
RUN npm run build
|
| 32 |
+
|
| 33 |
+
# Stage 2: SurrealDB binary (pinned to v2 to match docker-compose.yml)
|
| 34 |
+
FROM surrealdb/surrealdb:v2 AS surreal-binary
|
| 35 |
+
|
| 36 |
+
# Stage 3: Backend Builder
|
| 37 |
+
FROM python:3.12-slim-bookworm AS backend-builder
|
| 38 |
+
# Install build dependencies
|
| 39 |
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
|
| 40 |
+
# Install uv
|
| 41 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 42 |
+
WORKDIR /app
|
| 43 |
+
|
| 44 |
+
# Set build optimization environment variables
|
| 45 |
+
ENV UV_HTTP_TIMEOUT=120
|
| 46 |
+
|
| 47 |
+
# Copy dependency files first
|
| 48 |
+
COPY pyproject.toml uv.lock ./
|
| 49 |
+
COPY open_notebook/__init__.py ./open_notebook/__init__.py
|
| 50 |
+
# Install dependencies
|
| 51 |
+
RUN uv sync --frozen --no-dev
|
| 52 |
+
|
| 53 |
+
# Pre-download tiktoken encoding so the app works offline (issue #264).
|
| 54 |
+
ENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache
|
| 55 |
+
RUN mkdir -p /app/tiktoken-cache && \
|
| 56 |
+
.venv/bin/python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"
|
| 57 |
+
|
| 58 |
+
# ==============================================================================
|
| 59 |
+
# Stage 4: Runtime (HuggingFace Spaces optimized)
|
| 60 |
+
# ==============================================================================
|
| 61 |
+
FROM python:3.12-slim-bookworm AS runtime
|
| 62 |
+
|
| 63 |
+
# Install runtime dependencies (including nginx for reverse proxy)
|
| 64 |
+
# All apt installs MUST happen before switching to non-root user
|
| 65 |
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
| 66 |
+
ffmpeg \
|
| 67 |
+
supervisor \
|
| 68 |
+
nginx \
|
| 69 |
+
curl \
|
| 70 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 71 |
+
&& apt-get install -y nodejs \
|
| 72 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 73 |
+
|
| 74 |
+
# Install SurrealDB binary (from pinned v2 image)
|
| 75 |
+
COPY --from=surreal-binary /surreal /usr/local/bin/surreal
|
| 76 |
+
|
| 77 |
+
# Install uv
|
| 78 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 79 |
+
|
| 80 |
+
# ==============================================================================
|
| 81 |
+
# HuggingFace Spaces: Create user with UID 1000 (mandatory)
|
| 82 |
+
# ==============================================================================
|
| 83 |
+
RUN useradd -m -u 1000 user
|
| 84 |
+
|
| 85 |
+
WORKDIR /app
|
| 86 |
+
|
| 87 |
+
# Copy backend virtualenv and source code
|
| 88 |
+
COPY --from=backend-builder /app/.venv /app/.venv
|
| 89 |
+
COPY . /app/
|
| 90 |
+
|
| 91 |
+
# Copy pre-downloaded tiktoken encoding from builder
|
| 92 |
+
COPY --from=backend-builder /app/tiktoken-cache /app/tiktoken-cache
|
| 93 |
+
|
| 94 |
+
# Copy built frontend from standalone output
|
| 95 |
+
COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/
|
| 96 |
+
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static
|
| 97 |
+
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
|
| 98 |
+
|
| 99 |
+
# Bind Next.js to all interfaces
|
| 100 |
+
ENV HOSTNAME=0.0.0.0
|
| 101 |
+
# Point the app at the pre-baked tiktoken encoding
|
| 102 |
+
ENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache
|
| 103 |
+
|
| 104 |
+
# ==============================================================================
|
| 105 |
+
# Setup directories, Nginx, and permissions for UID 1000
|
| 106 |
+
# ==============================================================================
|
| 107 |
+
|
| 108 |
+
# Copy HuggingFace-specific configuration files
|
| 109 |
+
COPY nginx.hf.conf /etc/nginx/sites-available/default
|
| 110 |
+
COPY supervisord.hf.conf /etc/supervisor/conf.d/supervisord.conf
|
| 111 |
+
COPY start.sh /app/start.sh
|
| 112 |
+
RUN chmod +x /app/start.sh
|
| 113 |
+
|
| 114 |
+
# Ensure wait-for-api script is executable
|
| 115 |
+
RUN chmod +x /app/scripts/wait-for-api.sh
|
| 116 |
+
|
| 117 |
+
# Setup directories with proper permissions for UID 1000
|
| 118 |
+
RUN mkdir -p /app/data /data /var/log/supervisor /var/log/nginx \
|
| 119 |
+
/var/lib/nginx /var/lib/nginx/body /var/lib/nginx/proxy \
|
| 120 |
+
/var/lib/nginx/fastcgi /var/lib/nginx/uwsgi /var/lib/nginx/scgi \
|
| 121 |
+
/run /tmp/nginx
|
| 122 |
+
|
| 123 |
+
# Set ownership for non-root user (UID 1000)
|
| 124 |
+
RUN chown -R 1000:1000 /app /data /var/log/supervisor /var/log/nginx \
|
| 125 |
+
/var/lib/nginx /run /tmp/nginx /etc/nginx && \
|
| 126 |
+
chown -R 1000:1000 /var/run 2>/dev/null || true
|
| 127 |
+
|
| 128 |
+
# Configure nginx sites
|
| 129 |
+
RUN rm -f /etc/nginx/sites-enabled/default && \
|
| 130 |
+
ln -sf /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default
|
| 131 |
+
|
| 132 |
+
# ==============================================================================
|
| 133 |
+
# Environment defaults for HuggingFace
|
| 134 |
+
# ==============================================================================
|
| 135 |
+
ENV SURREAL_URL=ws://localhost:8000/rpc
|
| 136 |
+
ENV SURREAL_USER=root
|
| 137 |
+
ENV SURREAL_PASSWORD=root
|
| 138 |
+
ENV SURREAL_NAMESPACE=open_notebook
|
| 139 |
+
ENV SURREAL_DATABASE=open_notebook
|
| 140 |
+
ENV OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-in-hf-secrets
|
| 141 |
+
|
| 142 |
+
# Switch to non-root user (HF Spaces requirement)
|
| 143 |
+
USER user
|
| 144 |
+
|
| 145 |
+
# Expose HuggingFace's mandatory port
|
| 146 |
+
EXPOSE 7860
|
| 147 |
+
|
| 148 |
+
# Start via our initialization script
|
| 149 |
+
CMD ["/app/start.sh"]
|
Dockerfile.single
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Frontend Builder
|
| 2 |
+
FROM node:20-slim AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
|
| 5 |
+
# Copy dependency files first to leverage cache
|
| 6 |
+
COPY frontend/package.json frontend/package-lock.json ./
|
| 7 |
+
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
| 8 |
+
RUN npm config set registry ${NPM_REGISTRY} \
|
| 9 |
+
&& npm config set fetch-retries 5 \
|
| 10 |
+
&& npm config set fetch-retry-mintimeout 20000 \
|
| 11 |
+
&& npm config set fetch-retry-maxtimeout 120000
|
| 12 |
+
# Retry npm ci to survive transient registry ECONNRESETs, which are common on
|
| 13 |
+
# the QEMU-emulated arm64 leg of the multi-arch build.
|
| 14 |
+
RUN i=0; until npm ci; do \
|
| 15 |
+
i=$((i+1)); \
|
| 16 |
+
if [ "$i" -ge 5 ]; then echo "npm ci failed after $i attempts"; exit 1; fi; \
|
| 17 |
+
echo "npm ci failed (attempt $i); retrying in 15s"; sleep 15; \
|
| 18 |
+
done
|
| 19 |
+
|
| 20 |
+
# Copy the rest of the frontend source
|
| 21 |
+
COPY frontend/ ./
|
| 22 |
+
# Build the frontend
|
| 23 |
+
RUN npm run build
|
| 24 |
+
|
| 25 |
+
# Stage 2: SurrealDB binary (pinned to v2 to match docker-compose.yml)
|
| 26 |
+
FROM surrealdb/surrealdb:v2 AS surreal-binary
|
| 27 |
+
|
| 28 |
+
# Stage 4: Backend Builder
|
| 29 |
+
FROM python:3.12-slim-bookworm AS backend-builder
|
| 30 |
+
# Install build dependencies
|
| 31 |
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*
|
| 32 |
+
# Install uv
|
| 33 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 34 |
+
WORKDIR /app
|
| 35 |
+
|
| 36 |
+
# Set build optimization environment variables
|
| 37 |
+
ENV UV_HTTP_TIMEOUT=120
|
| 38 |
+
|
| 39 |
+
# Copy dependency files first
|
| 40 |
+
COPY pyproject.toml uv.lock ./
|
| 41 |
+
COPY open_notebook/__init__.py ./open_notebook/__init__.py
|
| 42 |
+
# Install dependencies
|
| 43 |
+
RUN uv sync --frozen --no-dev
|
| 44 |
+
|
| 45 |
+
# Pre-download tiktoken encoding so the app works offline (issue #264).
|
| 46 |
+
# /app/tiktoken-cache is intentionally outside /app/data/ so that volume mounts
|
| 47 |
+
# of /app/data (for user data persistence) do not hide the pre-baked encoding.
|
| 48 |
+
# config.py reads TIKTOKEN_CACHE_DIR from the environment to pick up this path.
|
| 49 |
+
ENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache
|
| 50 |
+
RUN mkdir -p /app/tiktoken-cache && \
|
| 51 |
+
.venv/bin/python -c "import tiktoken; tiktoken.get_encoding('o200k_base')"
|
| 52 |
+
|
| 53 |
+
# Stage 5: Runtime
|
| 54 |
+
FROM python:3.12-slim-bookworm AS runtime
|
| 55 |
+
|
| 56 |
+
# Install runtime dependencies
|
| 57 |
+
RUN apt-get update && apt-get upgrade -y && apt-get install -y \
|
| 58 |
+
ffmpeg \
|
| 59 |
+
supervisor \
|
| 60 |
+
curl \
|
| 61 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 62 |
+
&& apt-get install -y nodejs \
|
| 63 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 64 |
+
|
| 65 |
+
# Install SurrealDB (copied from pinned v2 image to match docker-compose.yml)
|
| 66 |
+
COPY --from=surreal-binary /surreal /usr/local/bin/surreal
|
| 67 |
+
|
| 68 |
+
# Install uv (optional but helpful for some scripts)
|
| 69 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 70 |
+
|
| 71 |
+
WORKDIR /app
|
| 72 |
+
|
| 73 |
+
# Copy backend virtualenv and source code
|
| 74 |
+
COPY --from=backend-builder /app/.venv /app/.venv
|
| 75 |
+
COPY . /app/
|
| 76 |
+
|
| 77 |
+
# Copy pre-downloaded tiktoken encoding from builder (outside /data/ — volume-mount safe)
|
| 78 |
+
COPY --from=backend-builder /app/tiktoken-cache /app/tiktoken-cache
|
| 79 |
+
|
| 80 |
+
# Copy built frontend from standalone output
|
| 81 |
+
COPY --from=frontend-builder /app/frontend/.next/standalone /app/frontend/
|
| 82 |
+
COPY --from=frontend-builder /app/frontend/.next/static /app/frontend/.next/static
|
| 83 |
+
COPY --from=frontend-builder /app/frontend/public /app/frontend/public
|
| 84 |
+
|
| 85 |
+
# Bind Next.js to all interfaces (required for Docker networking and reverse proxies)
|
| 86 |
+
ENV HOSTNAME=0.0.0.0
|
| 87 |
+
# Point the app at the pre-baked tiktoken encoding (see open_notebook/config.py)
|
| 88 |
+
ENV TIKTOKEN_CACHE_DIR=/app/tiktoken-cache
|
| 89 |
+
|
| 90 |
+
# Setup directories and permissions
|
| 91 |
+
RUN mkdir -p /app/data /mydata
|
| 92 |
+
|
| 93 |
+
# Ensure wait-for-api script is executable
|
| 94 |
+
RUN chmod +x /app/scripts/wait-for-api.sh
|
| 95 |
+
|
| 96 |
+
# Copy supervisord configuration
|
| 97 |
+
COPY supervisord.single.conf /etc/supervisor/conf.d/supervisord.conf
|
| 98 |
+
|
| 99 |
+
# Create log directories
|
| 100 |
+
RUN mkdir -p /var/log/supervisor
|
| 101 |
+
|
| 102 |
+
# Expose ports
|
| 103 |
+
EXPOSE 8502 5055
|
| 104 |
+
|
| 105 |
+
# Set startup command
|
| 106 |
+
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
Copyright (c) 2024 Luis Novo
|
| 3 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 4 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 5 |
+
in the Software without restriction, including without limitation the rights
|
| 6 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 7 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 8 |
+
furnished to do so, subject to the following conditions:
|
| 9 |
+
The above copyright notice and this permission notice shall be included in all
|
| 10 |
+
copies or substantial portions of the Software.
|
| 11 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 12 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 13 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 14 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 15 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 16 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 17 |
+
SOFTWARE.
|
MAINTAINER_GUIDE.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Maintainer Guide
|
| 2 |
+
|
| 3 |
+
**📍 This file has moved!**
|
| 4 |
+
|
| 5 |
+
All maintainer guidelines have been consolidated into the new development documentation structure.
|
| 6 |
+
|
| 7 |
+
👉 **[Read the Maintainer Guide](docs/7-DEVELOPMENT/maintainer-guide.md)**
|
| 8 |
+
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
## Quick Links
|
| 12 |
+
|
| 13 |
+
- **Maintainer Guide** → [docs/7-DEVELOPMENT/maintainer-guide.md](docs/7-DEVELOPMENT/maintainer-guide.md)
|
| 14 |
+
- **Contributing Guide** → [docs/7-DEVELOPMENT/contributing.md](docs/7-DEVELOPMENT/contributing.md)
|
| 15 |
+
- **Design Principles** → [docs/7-DEVELOPMENT/design-principles.md](docs/7-DEVELOPMENT/design-principles.md)
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
For all maintainer details, see **[docs/7-DEVELOPMENT/maintainer-guide.md](docs/7-DEVELOPMENT/maintainer-guide.md)**.
|
Makefile
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.PHONY: run frontend check ruff database lint api start-all stop-all status clean-cache worker worker-start worker-stop worker-restart
|
| 2 |
+
.PHONY: docker-buildx-prepare docker-buildx-clean docker-buildx-reset
|
| 3 |
+
.PHONY: docker-push docker-push-latest docker-release docker-build-local tag export-docs
|
| 4 |
+
|
| 5 |
+
# Get version from pyproject.toml
|
| 6 |
+
VERSION := $(shell grep -m1 version pyproject.toml | cut -d'"' -f2)
|
| 7 |
+
|
| 8 |
+
# Image names for both registries
|
| 9 |
+
DOCKERHUB_IMAGE := lfnovo/open_notebook
|
| 10 |
+
GHCR_IMAGE := ghcr.io/lfnovo/open-notebook
|
| 11 |
+
|
| 12 |
+
# Build platforms
|
| 13 |
+
PLATFORMS := linux/amd64,linux/arm64
|
| 14 |
+
|
| 15 |
+
database:
|
| 16 |
+
docker compose up -d surrealdb
|
| 17 |
+
|
| 18 |
+
run:
|
| 19 |
+
@echo "⚠️ Warning: Starting frontend only. For full functionality, use 'make start-all'"
|
| 20 |
+
cd frontend && npm run dev
|
| 21 |
+
|
| 22 |
+
frontend:
|
| 23 |
+
cd frontend && npm run dev
|
| 24 |
+
|
| 25 |
+
lint:
|
| 26 |
+
uv run python -m mypy .
|
| 27 |
+
|
| 28 |
+
ruff:
|
| 29 |
+
ruff check . --fix
|
| 30 |
+
|
| 31 |
+
# === Docker Build Setup ===
|
| 32 |
+
docker-buildx-prepare:
|
| 33 |
+
@docker buildx inspect multi-platform-builder >/dev/null 2>&1 || \
|
| 34 |
+
docker buildx create --use --name multi-platform-builder --driver docker-container
|
| 35 |
+
@docker buildx use multi-platform-builder
|
| 36 |
+
|
| 37 |
+
docker-buildx-clean:
|
| 38 |
+
@echo "🧹 Cleaning up buildx builders..."
|
| 39 |
+
@docker buildx rm multi-platform-builder 2>/dev/null || true
|
| 40 |
+
@docker ps -a | grep buildx_buildkit | awk '{print $$1}' | xargs -r docker rm -f 2>/dev/null || true
|
| 41 |
+
@echo "✅ Buildx cleanup complete!"
|
| 42 |
+
|
| 43 |
+
docker-buildx-reset: docker-buildx-clean docker-buildx-prepare
|
| 44 |
+
@echo "✅ Buildx reset complete!"
|
| 45 |
+
|
| 46 |
+
# === Docker Build Targets ===
|
| 47 |
+
|
| 48 |
+
# Build production image for local platform only (no push)
|
| 49 |
+
docker-build-local:
|
| 50 |
+
@echo "🔨 Building production image locally ($(shell uname -m))..."
|
| 51 |
+
docker build \
|
| 52 |
+
-t $(DOCKERHUB_IMAGE):$(VERSION) \
|
| 53 |
+
-t $(DOCKERHUB_IMAGE):local \
|
| 54 |
+
.
|
| 55 |
+
@echo "✅ Built $(DOCKERHUB_IMAGE):$(VERSION) and $(DOCKERHUB_IMAGE):local"
|
| 56 |
+
@echo "Run with: docker run -p 5055:5055 -p 3000:3000 $(DOCKERHUB_IMAGE):local"
|
| 57 |
+
|
| 58 |
+
# Build and push version tags ONLY (no latest) for both regular and single images
|
| 59 |
+
docker-push: docker-buildx-prepare
|
| 60 |
+
@echo "📤 Building and pushing version $(VERSION) to both registries..."
|
| 61 |
+
@echo "🔨 Building regular image..."
|
| 62 |
+
docker buildx build --pull \
|
| 63 |
+
--platform $(PLATFORMS) \
|
| 64 |
+
--progress=plain \
|
| 65 |
+
-t $(DOCKERHUB_IMAGE):$(VERSION) \
|
| 66 |
+
-t $(GHCR_IMAGE):$(VERSION) \
|
| 67 |
+
--push \
|
| 68 |
+
.
|
| 69 |
+
@echo "🔨 Building single-container image..."
|
| 70 |
+
docker buildx build --pull \
|
| 71 |
+
--platform $(PLATFORMS) \
|
| 72 |
+
--progress=plain \
|
| 73 |
+
-f Dockerfile.single \
|
| 74 |
+
-t $(DOCKERHUB_IMAGE):$(VERSION)-single \
|
| 75 |
+
-t $(GHCR_IMAGE):$(VERSION)-single \
|
| 76 |
+
--push \
|
| 77 |
+
.
|
| 78 |
+
@echo "✅ Pushed version $(VERSION) to both registries (latest NOT updated)"
|
| 79 |
+
@echo " 📦 Docker Hub:"
|
| 80 |
+
@echo " - $(DOCKERHUB_IMAGE):$(VERSION)"
|
| 81 |
+
@echo " - $(DOCKERHUB_IMAGE):$(VERSION)-single"
|
| 82 |
+
@echo " 📦 GHCR:"
|
| 83 |
+
@echo " - $(GHCR_IMAGE):$(VERSION)"
|
| 84 |
+
@echo " - $(GHCR_IMAGE):$(VERSION)-single"
|
| 85 |
+
|
| 86 |
+
# Update v1-latest tags to current version (both regular and single images)
|
| 87 |
+
docker-push-latest: docker-buildx-prepare
|
| 88 |
+
@echo "📤 Updating v1-latest tags to version $(VERSION)..."
|
| 89 |
+
@echo "🔨 Building regular image with latest tag..."
|
| 90 |
+
docker buildx build --pull \
|
| 91 |
+
--platform $(PLATFORMS) \
|
| 92 |
+
--progress=plain \
|
| 93 |
+
-t $(DOCKERHUB_IMAGE):$(VERSION) \
|
| 94 |
+
-t $(DOCKERHUB_IMAGE):v1-latest \
|
| 95 |
+
-t $(GHCR_IMAGE):$(VERSION) \
|
| 96 |
+
-t $(GHCR_IMAGE):v1-latest \
|
| 97 |
+
--push \
|
| 98 |
+
.
|
| 99 |
+
@echo "🔨 Building single-container image with latest tag..."
|
| 100 |
+
docker buildx build --pull \
|
| 101 |
+
--platform $(PLATFORMS) \
|
| 102 |
+
--progress=plain \
|
| 103 |
+
-f Dockerfile.single \
|
| 104 |
+
-t $(DOCKERHUB_IMAGE):$(VERSION)-single \
|
| 105 |
+
-t $(DOCKERHUB_IMAGE):v1-latest-single \
|
| 106 |
+
-t $(GHCR_IMAGE):$(VERSION)-single \
|
| 107 |
+
-t $(GHCR_IMAGE):v1-latest-single \
|
| 108 |
+
--push \
|
| 109 |
+
.
|
| 110 |
+
@echo "✅ Updated v1-latest to version $(VERSION)"
|
| 111 |
+
@echo " 📦 Docker Hub:"
|
| 112 |
+
@echo " - $(DOCKERHUB_IMAGE):$(VERSION) → v1-latest"
|
| 113 |
+
@echo " - $(DOCKERHUB_IMAGE):$(VERSION)-single → v1-latest-single"
|
| 114 |
+
@echo " 📦 GHCR:"
|
| 115 |
+
@echo " - $(GHCR_IMAGE):$(VERSION) → v1-latest"
|
| 116 |
+
@echo " - $(GHCR_IMAGE):$(VERSION)-single → v1-latest-single"
|
| 117 |
+
|
| 118 |
+
# Full release: push version AND update latest tags
|
| 119 |
+
docker-release: docker-push-latest
|
| 120 |
+
@echo "✅ Full release complete for version $(VERSION)"
|
| 121 |
+
|
| 122 |
+
tag:
|
| 123 |
+
@version=$$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/'); \
|
| 124 |
+
echo "Creating tag v$$version"; \
|
| 125 |
+
git tag "v$$version"; \
|
| 126 |
+
git push origin "v$$version"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
dev:
|
| 130 |
+
docker compose -f docker-compose.dev.yml up --build
|
| 131 |
+
|
| 132 |
+
full:
|
| 133 |
+
docker compose -f docker-compose.full.yml up --build
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
api:
|
| 137 |
+
uv run --env-file .env run_api.py
|
| 138 |
+
|
| 139 |
+
.PHONY: worker worker-start worker-stop worker-restart
|
| 140 |
+
|
| 141 |
+
worker: worker-start
|
| 142 |
+
|
| 143 |
+
worker-start:
|
| 144 |
+
@echo "Starting surreal-commands worker..."
|
| 145 |
+
uv run --env-file .env surreal-commands-worker --import-modules commands
|
| 146 |
+
|
| 147 |
+
worker-stop:
|
| 148 |
+
@echo "Stopping surreal-commands worker..."
|
| 149 |
+
pkill -f "surreal-commands-worker" || true
|
| 150 |
+
|
| 151 |
+
worker-restart: worker-stop
|
| 152 |
+
@sleep 2
|
| 153 |
+
@$(MAKE) worker-start
|
| 154 |
+
|
| 155 |
+
# === Service Management ===
|
| 156 |
+
start-all:
|
| 157 |
+
@echo "🚀 Starting Open Notebook (Database + API + Worker + Frontend)..."
|
| 158 |
+
@echo "📊 Starting SurrealDB..."
|
| 159 |
+
@docker compose -f docker-compose.dev.yml up -d surrealdb
|
| 160 |
+
@sleep 3
|
| 161 |
+
@echo "🔧 Starting API backend..."
|
| 162 |
+
@uv run run_api.py &
|
| 163 |
+
@sleep 3
|
| 164 |
+
@echo "⚙️ Starting background worker..."
|
| 165 |
+
@uv run --env-file .env surreal-commands-worker --import-modules commands &
|
| 166 |
+
@sleep 2
|
| 167 |
+
@echo "🌐 Starting Next.js frontend..."
|
| 168 |
+
@echo "✅ All services started!"
|
| 169 |
+
@echo "📱 Frontend: http://localhost:3000"
|
| 170 |
+
@echo "🔗 API: http://localhost:5055"
|
| 171 |
+
@echo "📚 API Docs: http://localhost:5055/docs"
|
| 172 |
+
cd frontend && npm run dev
|
| 173 |
+
|
| 174 |
+
stop-all:
|
| 175 |
+
@echo "🛑 Stopping all Open Notebook services..."
|
| 176 |
+
@pkill -f "next dev" || true
|
| 177 |
+
@pkill -f "surreal-commands-worker" || true
|
| 178 |
+
@pkill -f "run_api.py" || true
|
| 179 |
+
@pkill -f "uvicorn api.main:app" || true
|
| 180 |
+
@docker compose down
|
| 181 |
+
@echo "✅ All services stopped!"
|
| 182 |
+
|
| 183 |
+
status:
|
| 184 |
+
@echo "📊 Open Notebook Service Status:"
|
| 185 |
+
@echo "Database (SurrealDB):"
|
| 186 |
+
@docker compose ps surrealdb 2>/dev/null || echo " ❌ Not running"
|
| 187 |
+
@echo "API Backend:"
|
| 188 |
+
@pgrep -f "run_api.py\|uvicorn api.main:app" >/dev/null && echo " ✅ Running" || echo " ❌ Not running"
|
| 189 |
+
@echo "Background Worker:"
|
| 190 |
+
@pgrep -f "surreal-commands-worker" >/dev/null && echo " ✅ Running" || echo " ❌ Not running"
|
| 191 |
+
@echo "Next.js Frontend:"
|
| 192 |
+
@pgrep -f "next dev" >/dev/null && echo " ✅ Running" || echo " ❌ Not running"
|
| 193 |
+
|
| 194 |
+
# === Documentation Export ===
|
| 195 |
+
export-docs:
|
| 196 |
+
@echo "📚 Exporting documentation..."
|
| 197 |
+
@uv run python scripts/export_docs.py
|
| 198 |
+
@echo "✅ Documentation export complete!"
|
| 199 |
+
|
| 200 |
+
# === Cleanup ===
|
| 201 |
+
clean-cache:
|
| 202 |
+
@echo "🧹 Cleaning cache directories..."
|
| 203 |
+
@find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
|
| 204 |
+
@find . -name ".mypy_cache" -type d -exec rm -rf {} + 2>/dev/null || true
|
| 205 |
+
@find . -name ".ruff_cache" -type d -exec rm -rf {} + 2>/dev/null || true
|
| 206 |
+
@find . -name ".pytest_cache" -type d -exec rm -rf {} + 2>/dev/null || true
|
| 207 |
+
@find . -name "*.pyc" -type f -delete 2>/dev/null || true
|
| 208 |
+
@find . -name "*.pyo" -type f -delete 2>/dev/null || true
|
| 209 |
+
@find . -name "*.pyd" -type f -delete 2>/dev/null || true
|
| 210 |
+
@echo "✅ Cache directories cleaned!"
|
README.dev.md
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Developer Guide
|
| 2 |
+
|
| 3 |
+
This guide is for developers working on Open Notebook. For end-user documentation, see [README.md](README.md) and [docs/](docs/).
|
| 4 |
+
|
| 5 |
+
## Quick Start for Development
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
# 1. Clone and setup
|
| 9 |
+
git clone https://github.com/lfnovo/open-notebook.git
|
| 10 |
+
cd open-notebook
|
| 11 |
+
|
| 12 |
+
# 2. Copy environment files
|
| 13 |
+
cp .env.example .env
|
| 14 |
+
cp .env.example docker.env
|
| 15 |
+
|
| 16 |
+
# 3. Install dependencies
|
| 17 |
+
uv sync
|
| 18 |
+
|
| 19 |
+
# 4. Start all services (recommended for development)
|
| 20 |
+
make start-all
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
## Development Workflows
|
| 24 |
+
|
| 25 |
+
### When to Use What?
|
| 26 |
+
|
| 27 |
+
| Workflow | Use Case | Speed | Production Parity |
|
| 28 |
+
|----------|----------|-------|-------------------|
|
| 29 |
+
| **Local Services** (`make start-all`) | Day-to-day development, fastest iteration | ⚡⚡⚡ Fast | Medium |
|
| 30 |
+
| **Docker Compose** (`make dev`) | Testing containerized setup | ⚡⚡ Medium | High |
|
| 31 |
+
| **Local Docker Build** (`make docker-build-local`) | Testing Dockerfile changes | ⚡ Slow | Very High |
|
| 32 |
+
| **Multi-platform Build** (`make docker-push`) | Publishing releases | 🐌 Very Slow | Exact |
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## 1. Local Development (Recommended)
|
| 37 |
+
|
| 38 |
+
**Best for:** Daily development, hot reload, debugging
|
| 39 |
+
|
| 40 |
+
### Setup
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
# Start database
|
| 44 |
+
make database
|
| 45 |
+
|
| 46 |
+
# Start all services (DB + API + Worker + Frontend)
|
| 47 |
+
make start-all
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### What This Does
|
| 51 |
+
|
| 52 |
+
1. Starts SurrealDB in Docker (port 8000)
|
| 53 |
+
2. Starts FastAPI backend (port 5055)
|
| 54 |
+
3. Starts background worker (surreal-commands)
|
| 55 |
+
4. Starts Next.js frontend (port 3000)
|
| 56 |
+
|
| 57 |
+
### Individual Services
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# Just the database
|
| 61 |
+
make database
|
| 62 |
+
|
| 63 |
+
# Just the API
|
| 64 |
+
make api
|
| 65 |
+
|
| 66 |
+
# Just the frontend
|
| 67 |
+
make frontend
|
| 68 |
+
|
| 69 |
+
# Just the worker
|
| 70 |
+
make worker
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
### Checking Status
|
| 74 |
+
|
| 75 |
+
```bash
|
| 76 |
+
# See what's running
|
| 77 |
+
make status
|
| 78 |
+
|
| 79 |
+
# Stop everything
|
| 80 |
+
make stop-all
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
### Advantages
|
| 84 |
+
- ✅ Fastest iteration (hot reload)
|
| 85 |
+
- ✅ Easy debugging (direct process access)
|
| 86 |
+
- ✅ Low resource usage
|
| 87 |
+
- ✅ Direct log access
|
| 88 |
+
|
| 89 |
+
### Disadvantages
|
| 90 |
+
- ❌ Doesn't test Docker build
|
| 91 |
+
- ❌ Environment may differ from production
|
| 92 |
+
- ❌ Requires local Python/Node setup
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## 2. Docker Compose Development
|
| 97 |
+
|
| 98 |
+
**Best for:** Testing containerized setup, CI/CD verification
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
# Start with dev profile
|
| 102 |
+
make dev
|
| 103 |
+
|
| 104 |
+
# Or full stack
|
| 105 |
+
make full
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
### Configuration Files
|
| 109 |
+
|
| 110 |
+
- `docker-compose.dev.yml` - Development setup
|
| 111 |
+
- `docker-compose.full.yml` - Full stack setup
|
| 112 |
+
- `docker-compose.yml` - Base configuration
|
| 113 |
+
|
| 114 |
+
### Advantages
|
| 115 |
+
- ✅ Closer to production environment
|
| 116 |
+
- ✅ Isolated dependencies
|
| 117 |
+
- ✅ Easy to share exact environment
|
| 118 |
+
|
| 119 |
+
### Disadvantages
|
| 120 |
+
- ❌ Slower rebuilds
|
| 121 |
+
- ❌ More complex debugging
|
| 122 |
+
- ❌ Higher resource usage
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## 3. Testing Production Docker Images
|
| 127 |
+
|
| 128 |
+
**Best for:** Verifying Dockerfile changes before publishing
|
| 129 |
+
|
| 130 |
+
### Build Locally
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
# Build production image for your platform only
|
| 134 |
+
make docker-build-local
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
This creates two tags:
|
| 138 |
+
- `lfnovo/open_notebook:<version>` (from pyproject.toml)
|
| 139 |
+
- `lfnovo/open_notebook:local`
|
| 140 |
+
|
| 141 |
+
### Run Locally
|
| 142 |
+
|
| 143 |
+
```bash
|
| 144 |
+
docker run -p 5055:5055 -p 3000:3000 lfnovo/open_notebook:local
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
### When to Use
|
| 148 |
+
- ✅ Before pushing to registry
|
| 149 |
+
- ✅ Testing Dockerfile changes
|
| 150 |
+
- ✅ Debugging production-specific issues
|
| 151 |
+
- ✅ Verifying build process
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## 4. Publishing Docker Images
|
| 156 |
+
|
| 157 |
+
### Workflow
|
| 158 |
+
|
| 159 |
+
```bash
|
| 160 |
+
# 1. Test locally first
|
| 161 |
+
make docker-build-local
|
| 162 |
+
|
| 163 |
+
# 2. If successful, push version tag (no latest update)
|
| 164 |
+
make docker-push
|
| 165 |
+
|
| 166 |
+
# 3. Test the pushed version in staging/production
|
| 167 |
+
|
| 168 |
+
# 4. When ready, promote to latest
|
| 169 |
+
make docker-push-latest
|
| 170 |
+
```
|
| 171 |
+
|
| 172 |
+
### Available Commands
|
| 173 |
+
|
| 174 |
+
| Command | What It Does | Updates Latest? |
|
| 175 |
+
|---------|--------------|-----------------|
|
| 176 |
+
| `make docker-build-local` | Build for current platform only | No registry push |
|
| 177 |
+
| `make docker-push` | Push version tags to registries | ❌ No |
|
| 178 |
+
| `make docker-push-latest` | Push version + update v1-latest | ✅ Yes |
|
| 179 |
+
| `make docker-release` | Full release (same as docker-push-latest) | ✅ Yes |
|
| 180 |
+
|
| 181 |
+
### Publishing Details
|
| 182 |
+
|
| 183 |
+
- **Platforms:** `linux/amd64`, `linux/arm64`
|
| 184 |
+
- **Registries:** Docker Hub + GitHub Container Registry
|
| 185 |
+
- **Image Variants:** Regular + Single-container (`-single`)
|
| 186 |
+
- **Version Source:** `pyproject.toml`
|
| 187 |
+
|
| 188 |
+
### Creating Git Tags
|
| 189 |
+
|
| 190 |
+
```bash
|
| 191 |
+
# Create and push git tag matching pyproject.toml version
|
| 192 |
+
make tag
|
| 193 |
+
```
|
| 194 |
+
|
| 195 |
+
---
|
| 196 |
+
|
| 197 |
+
## Code Quality
|
| 198 |
+
|
| 199 |
+
```bash
|
| 200 |
+
# Run linter with auto-fix
|
| 201 |
+
make ruff
|
| 202 |
+
|
| 203 |
+
# Run type checking
|
| 204 |
+
make lint
|
| 205 |
+
|
| 206 |
+
# Run tests
|
| 207 |
+
uv run pytest tests/
|
| 208 |
+
|
| 209 |
+
# Clean cache directories
|
| 210 |
+
make clean-cache
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
---
|
| 214 |
+
|
| 215 |
+
## Common Development Tasks
|
| 216 |
+
|
| 217 |
+
### Adding a New Feature
|
| 218 |
+
|
| 219 |
+
1. Create feature branch
|
| 220 |
+
2. Develop using `make start-all`
|
| 221 |
+
3. Write tests
|
| 222 |
+
4. Run `make ruff` and `make lint`
|
| 223 |
+
5. Test with `make docker-build-local`
|
| 224 |
+
6. Create PR
|
| 225 |
+
|
| 226 |
+
### Fixing a Bug
|
| 227 |
+
|
| 228 |
+
1. Reproduce locally with `make start-all`
|
| 229 |
+
2. Add test case demonstrating bug
|
| 230 |
+
3. Fix the bug
|
| 231 |
+
4. Verify test passes
|
| 232 |
+
5. Check with `make docker-build-local`
|
| 233 |
+
|
| 234 |
+
### Updating Dependencies
|
| 235 |
+
|
| 236 |
+
```bash
|
| 237 |
+
# Add Python dependency
|
| 238 |
+
uv add package-name
|
| 239 |
+
|
| 240 |
+
# Update dependencies
|
| 241 |
+
uv sync
|
| 242 |
+
|
| 243 |
+
# Frontend dependencies
|
| 244 |
+
cd frontend && npm install package-name
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
### Adding a New Language (i18n)
|
| 248 |
+
|
| 249 |
+
Open Notebook supports internationalization. To add a new language:
|
| 250 |
+
|
| 251 |
+
1. **Create locale file**: Copy an existing locale as template
|
| 252 |
+
```bash
|
| 253 |
+
cp frontend/src/lib/locales/en-US/index.ts frontend/src/lib/locales/pt-BR/index.ts
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
2. **Translate all strings** in the new file. The structure includes:
|
| 257 |
+
- `common`: Shared UI elements (buttons, labels)
|
| 258 |
+
- `notebooks`, `sources`, `notes`: Feature-specific strings
|
| 259 |
+
- `chat`, `search`, `podcasts`: Module-specific strings
|
| 260 |
+
- `apiErrors`: Error message translations
|
| 261 |
+
|
| 262 |
+
3. **Register the locale** in `frontend/src/lib/locales/index.ts`:
|
| 263 |
+
```typescript
|
| 264 |
+
import { ptBR } from './pt-BR'
|
| 265 |
+
|
| 266 |
+
export const locales = {
|
| 267 |
+
'en-US': enUS,
|
| 268 |
+
'zh-CN': zhCN,
|
| 269 |
+
'zh-TW': zhTW,
|
| 270 |
+
'pt-BR': ptBR, // Add your locale
|
| 271 |
+
}
|
| 272 |
+
```
|
| 273 |
+
|
| 274 |
+
4. **Add date-fns locale** in `frontend/src/lib/utils/date-locale.ts`:
|
| 275 |
+
```typescript
|
| 276 |
+
import { zhCN, enUS, zhTW, ptBR } from 'date-fns/locale'
|
| 277 |
+
|
| 278 |
+
const LOCALE_MAP: Record<string, Locale> = {
|
| 279 |
+
'zh-CN': zhCN,
|
| 280 |
+
'zh-TW': zhTW,
|
| 281 |
+
'en-US': enUS,
|
| 282 |
+
'pt-BR': ptBR, // Add your locale
|
| 283 |
+
}
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
5. **Test**: Switch languages using the language toggle in the UI header.
|
| 287 |
+
|
| 288 |
+
### Database Migrations
|
| 289 |
+
|
| 290 |
+
Database migrations run **automatically** when the API starts.
|
| 291 |
+
|
| 292 |
+
1. Create migration file: `migrations/XXX_description.surql`
|
| 293 |
+
2. Write SurrealQL schema changes
|
| 294 |
+
3. (Optional) Create rollback: `migrations/XXX_description_down.surql`
|
| 295 |
+
4. Restart API - migration runs on startup
|
| 296 |
+
|
| 297 |
+
---
|
| 298 |
+
|
| 299 |
+
## Troubleshooting
|
| 300 |
+
|
| 301 |
+
### Services Won't Start
|
| 302 |
+
|
| 303 |
+
```bash
|
| 304 |
+
# Check status
|
| 305 |
+
make status
|
| 306 |
+
|
| 307 |
+
# Check database
|
| 308 |
+
docker compose ps surrealdb
|
| 309 |
+
|
| 310 |
+
# View logs
|
| 311 |
+
docker compose logs surrealdb
|
| 312 |
+
|
| 313 |
+
# Restart everything
|
| 314 |
+
make stop-all
|
| 315 |
+
make start-all
|
| 316 |
+
```
|
| 317 |
+
|
| 318 |
+
### Port Already in Use
|
| 319 |
+
|
| 320 |
+
```bash
|
| 321 |
+
# Find process using port
|
| 322 |
+
lsof -i :5055
|
| 323 |
+
lsof -i :3000
|
| 324 |
+
lsof -i :8000
|
| 325 |
+
|
| 326 |
+
# Kill stuck processes
|
| 327 |
+
make stop-all
|
| 328 |
+
```
|
| 329 |
+
|
| 330 |
+
### Database Connection Issues
|
| 331 |
+
|
| 332 |
+
```bash
|
| 333 |
+
# Verify SurrealDB is running
|
| 334 |
+
docker compose ps surrealdb
|
| 335 |
+
|
| 336 |
+
# Check connection settings in .env
|
| 337 |
+
cat .env | grep SURREAL
|
| 338 |
+
```
|
| 339 |
+
|
| 340 |
+
### Docker Build Fails
|
| 341 |
+
|
| 342 |
+
```bash
|
| 343 |
+
# Clean Docker cache
|
| 344 |
+
docker builder prune
|
| 345 |
+
|
| 346 |
+
# Reset buildx
|
| 347 |
+
make docker-buildx-reset
|
| 348 |
+
|
| 349 |
+
# Try local build first
|
| 350 |
+
make docker-build-local
|
| 351 |
+
```
|
| 352 |
+
|
| 353 |
+
---
|
| 354 |
+
|
| 355 |
+
## Project Structure
|
| 356 |
+
|
| 357 |
+
```
|
| 358 |
+
open-notebook/
|
| 359 |
+
├── api/ # FastAPI backend
|
| 360 |
+
├── frontend/ # Next.js React frontend
|
| 361 |
+
├── open_notebook/ # Python core library
|
| 362 |
+
│ ├── domain/ # Domain models
|
| 363 |
+
│ ├── graphs/ # LangGraph workflows
|
| 364 |
+
│ ├── ai/ # AI provider integration
|
| 365 |
+
│ └── database/ # SurrealDB operations
|
| 366 |
+
├── migrations/ # Database migrations
|
| 367 |
+
├── tests/ # Test suite
|
| 368 |
+
├── docs/ # User documentation
|
| 369 |
+
└── Makefile # Development commands
|
| 370 |
+
```
|
| 371 |
+
|
| 372 |
+
See component-specific CLAUDE.md files for detailed architecture:
|
| 373 |
+
- [frontend/CLAUDE.md](frontend/CLAUDE.md)
|
| 374 |
+
- [api/CLAUDE.md](api/CLAUDE.md)
|
| 375 |
+
- [open_notebook/CLAUDE.md](open_notebook/CLAUDE.md)
|
| 376 |
+
|
| 377 |
+
---
|
| 378 |
+
|
| 379 |
+
## Environment Variables
|
| 380 |
+
|
| 381 |
+
### Required for Local Development
|
| 382 |
+
|
| 383 |
+
```bash
|
| 384 |
+
# .env file
|
| 385 |
+
SURREAL_URL=ws://localhost:8000
|
| 386 |
+
SURREAL_USER=root
|
| 387 |
+
SURREAL_PASS=root
|
| 388 |
+
SURREAL_DB=open_notebook
|
| 389 |
+
SURREAL_NS=production
|
| 390 |
+
|
| 391 |
+
# AI Provider (at least one required)
|
| 392 |
+
OPENAI_API_KEY=sk-...
|
| 393 |
+
# OR
|
| 394 |
+
ANTHROPIC_API_KEY=sk-ant-...
|
| 395 |
+
# OR configure other providers (see docs/5-CONFIGURATION/)
|
| 396 |
+
```
|
| 397 |
+
|
| 398 |
+
See [docs/5-CONFIGURATION/](docs/5-CONFIGURATION/) for complete configuration guide.
|
| 399 |
+
|
| 400 |
+
---
|
| 401 |
+
|
| 402 |
+
## Performance Tips
|
| 403 |
+
|
| 404 |
+
### Speed Up Local Development
|
| 405 |
+
|
| 406 |
+
1. **Use `make start-all`** instead of Docker for daily work
|
| 407 |
+
2. **Keep SurrealDB running** between sessions (`make database`)
|
| 408 |
+
3. **Use `make docker-build-local`** only when testing Dockerfile changes
|
| 409 |
+
4. **Skip multi-platform builds** until ready to publish
|
| 410 |
+
|
| 411 |
+
### Reduce Resource Usage
|
| 412 |
+
|
| 413 |
+
```bash
|
| 414 |
+
# Stop unused services
|
| 415 |
+
make stop-all
|
| 416 |
+
|
| 417 |
+
# Clean up Docker
|
| 418 |
+
docker system prune -a
|
| 419 |
+
|
| 420 |
+
# Clean Python cache
|
| 421 |
+
make clean-cache
|
| 422 |
+
```
|
| 423 |
+
|
| 424 |
+
---
|
| 425 |
+
|
| 426 |
+
## TODO: Sections to Add
|
| 427 |
+
|
| 428 |
+
- [ ] Frontend development guide (hot reload, component structure)
|
| 429 |
+
- [ ] API development guide (adding endpoints, services)
|
| 430 |
+
- [ ] LangGraph workflow development
|
| 431 |
+
- [ ] Testing strategy and coverage
|
| 432 |
+
- [ ] Debugging tips (VSCode/PyCharm setup)
|
| 433 |
+
- [ ] CI/CD pipeline overview
|
| 434 |
+
- [ ] Release process checklist
|
| 435 |
+
- [ ] Common error messages and solutions
|
| 436 |
+
|
| 437 |
+
---
|
| 438 |
+
|
| 439 |
+
## Resources
|
| 440 |
+
|
| 441 |
+
- **Documentation:** https://open-notebook.ai
|
| 442 |
+
- **Discord:** https://discord.gg/37XJPXfz2w
|
| 443 |
+
- **Issues:** https://github.com/lfnovo/open-notebook/issues
|
| 444 |
+
- **Contributing:** [CONTRIBUTING.md](CONTRIBUTING.md)
|
| 445 |
+
- **Maintainer Guide:** [MAINTAINER_GUIDE.md](MAINTAINER_GUIDE.md)
|
| 446 |
+
|
| 447 |
+
---
|
| 448 |
+
|
| 449 |
+
**Last Updated:** January 2025
|
README.hf.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Open Notebook
|
| 3 |
+
emoji: 📓
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
startup_duration_timeout: 1h
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
suggested_hardware: cpu-upgrade
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# 📓 Open Notebook
|
| 15 |
+
|
| 16 |
+
An open source, privacy-focused alternative to Google's Notebook LM!
|
| 17 |
+
|
| 18 |
+
## Features
|
| 19 |
+
|
| 20 |
+
- 🔒 **Privacy First** - Self-hosted, your data stays under your control
|
| 21 |
+
- 🤖 **18+ AI Providers** - OpenAI, Anthropic, Google, Ollama, and more
|
| 22 |
+
- 📚 **Multi-modal Content** - PDFs, videos, audio, web pages
|
| 23 |
+
- 🎙️ **Podcast Generation** - Advanced multi-speaker podcasts
|
| 24 |
+
- 🔍 **Intelligent Search** - Full-text and vector search
|
| 25 |
+
- 💬 **Context-Aware Chat** - AI conversations powered by your research
|
| 26 |
+
- 🌐 **REST API** - Full programmatic access at `/docs`
|
| 27 |
+
- 🔌 **MCP Integration** - Connect with Claude Desktop, VS Code, etc.
|
| 28 |
+
|
| 29 |
+
## Quick Start
|
| 30 |
+
|
| 31 |
+
1. **Configure AI Provider**: Go to Settings → API Keys → Add your provider
|
| 32 |
+
2. **Create a Notebook**: Start organizing your research
|
| 33 |
+
3. **Add Sources**: Upload PDFs, paste URLs, or add text content
|
| 34 |
+
4. **Chat & Generate**: Ask questions, generate insights, create podcasts
|
| 35 |
+
|
| 36 |
+
## API Access
|
| 37 |
+
|
| 38 |
+
- **Swagger Docs**: [/docs](/docs)
|
| 39 |
+
- **REST API**: All endpoints at `/api/v1/...`
|
| 40 |
+
|
| 41 |
+
## MCP Integration
|
| 42 |
+
|
| 43 |
+
Connect your local AI assistants to this Space:
|
| 44 |
+
|
| 45 |
+
```json
|
| 46 |
+
{
|
| 47 |
+
"mcpServers": {
|
| 48 |
+
"open-notebook": {
|
| 49 |
+
"command": "uvx",
|
| 50 |
+
"args": ["open-notebook-mcp"],
|
| 51 |
+
"env": {
|
| 52 |
+
"OPEN_NOTEBOOK_URL": "https://YOUR-USERNAME-open-notebook.hf.space"
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Configuration
|
| 60 |
+
|
| 61 |
+
Set these in your Space **Settings → Secrets**:
|
| 62 |
+
|
| 63 |
+
| Secret | Required | Description |
|
| 64 |
+
|--------|----------|-------------|
|
| 65 |
+
| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | ✅ | Encrypts API keys in database |
|
| 66 |
+
| `BASIC_AUTH_USERNAME` | Optional | Password-protect the UI |
|
| 67 |
+
| `BASIC_AUTH_PASSWORD` | Optional | Password-protect the UI |
|
| 68 |
+
|
| 69 |
+
## Links
|
| 70 |
+
|
| 71 |
+
- [GitHub Repository](https://github.com/lfnovo/open-notebook)
|
| 72 |
+
- [Documentation](https://www.open-notebook.ai)
|
| 73 |
+
- [Discord Community](https://discord.gg/37XJPXfz2w)
|
| 74 |
+
|
| 75 |
+
> **⚠️ Persistent Storage**: Enable Persistent Storage in Space Settings to keep your data between restarts.
|
README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Open Notebook
|
| 3 |
+
emoji: 📓
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
startup_duration_timeout: 1h
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
suggested_hardware: cpu-upgrade
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# 📓 Open Notebook
|
| 15 |
+
|
| 16 |
+
An open source, privacy-focused alternative to Google's Notebook LM!
|
| 17 |
+
|
| 18 |
+
## Features
|
| 19 |
+
|
| 20 |
+
- 🔒 **Privacy First** - Self-hosted, your data stays under your control
|
| 21 |
+
- 🤖 **18+ AI Providers** - OpenAI, Anthropic, Google, Ollama, and more
|
| 22 |
+
- 📚 **Multi-modal Content** - PDFs, videos, audio, web pages
|
| 23 |
+
- 🎙️ **Podcast Generation** - Advanced multi-speaker podcasts
|
| 24 |
+
- 🔍 **Intelligent Search** - Full-text and vector search
|
| 25 |
+
- 💬 **Context-Aware Chat** - AI conversations powered by your research
|
| 26 |
+
- 🌐 **REST API** - Full programmatic access at `/docs`
|
| 27 |
+
- 🔌 **MCP Integration** - Connect with Claude Desktop, VS Code, etc.
|
| 28 |
+
|
| 29 |
+
## Quick Start
|
| 30 |
+
|
| 31 |
+
1. **Configure AI Provider**: Go to Settings → API Keys → Add your provider
|
| 32 |
+
2. **Create a Notebook**: Start organizing your research
|
| 33 |
+
3. **Add Sources**: Upload PDFs, paste URLs, or add text content
|
| 34 |
+
4. **Chat & Generate**: Ask questions, generate insights, create podcasts
|
| 35 |
+
|
| 36 |
+
## API Access
|
| 37 |
+
|
| 38 |
+
- **Swagger Docs**: [/docs](/docs)
|
| 39 |
+
- **REST API**: All endpoints at `/api/v1/...`
|
| 40 |
+
|
| 41 |
+
## MCP Integration
|
| 42 |
+
|
| 43 |
+
Connect your local AI assistants to this Space:
|
| 44 |
+
|
| 45 |
+
```json
|
| 46 |
+
{
|
| 47 |
+
"mcpServers": {
|
| 48 |
+
"open-notebook": {
|
| 49 |
+
"command": "uvx",
|
| 50 |
+
"args": ["open-notebook-mcp"],
|
| 51 |
+
"env": {
|
| 52 |
+
"OPEN_NOTEBOOK_URL": "https://YOUR-USERNAME-open-notebook.hf.space"
|
| 53 |
+
}
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
## Configuration
|
| 60 |
+
|
| 61 |
+
Set these in your Space **Settings → Secrets**:
|
| 62 |
+
|
| 63 |
+
| Secret | Required | Description |
|
| 64 |
+
|--------|----------|-------------|
|
| 65 |
+
| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | ✅ | Encrypts API keys in database |
|
| 66 |
+
| `BASIC_AUTH_USERNAME` | Optional | Password-protect the UI |
|
| 67 |
+
| `BASIC_AUTH_PASSWORD` | Optional | Password-protect the UI |
|
| 68 |
+
|
| 69 |
+
## Links
|
| 70 |
+
|
| 71 |
+
- [GitHub Repository](https://github.com/lfnovo/open-notebook)
|
| 72 |
+
- [Documentation](https://www.open-notebook.ai)
|
| 73 |
+
- [Discord Community](https://discord.gg/37XJPXfz2w)
|
| 74 |
+
|
| 75 |
+
> **⚠️ Persistent Storage**: Enable Persistent Storage in Space Settings to keep your data between restarts.
|
api/CLAUDE.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# API Module
|
| 2 |
+
|
| 3 |
+
FastAPI-based REST backend exposing services for notebooks, sources, notes, chat, podcasts, and AI model management.
|
| 4 |
+
|
| 5 |
+
## Purpose
|
| 6 |
+
|
| 7 |
+
FastAPI application serving three architectural layers: routes (HTTP endpoints), services (business logic), and models (request/response schemas). Integrates LangGraph workflows (chat, ask, source_chat), SurrealDB persistence, and AI providers via Esperanto.
|
| 8 |
+
|
| 9 |
+
## Architecture Overview
|
| 10 |
+
|
| 11 |
+
**Three layers**:
|
| 12 |
+
1. **Routes** (`routers/*`): HTTP endpoints mapping to services
|
| 13 |
+
2. **Services** (`*_service.py`): Business logic orchestrating domain models, database, graphs, AI providers
|
| 14 |
+
3. **Models** (`models.py`): Pydantic request/response schemas with validation
|
| 15 |
+
|
| 16 |
+
**Startup flow**:
|
| 17 |
+
- Load .env environment variables
|
| 18 |
+
- Initialize CORS middleware + password auth middleware
|
| 19 |
+
- Run database migrations via AsyncMigrationManager on lifespan startup
|
| 20 |
+
- Run podcast profile data migration (legacy string to model registry conversion)
|
| 21 |
+
- Register all routers
|
| 22 |
+
|
| 23 |
+
**Key services**:
|
| 24 |
+
- `chat_service.py`: Invokes chat graph with messages, context
|
| 25 |
+
- `podcast_service.py`: Orchestrates outline + transcript generation
|
| 26 |
+
- `sources_service.py`: Content ingestion, vectorization, metadata
|
| 27 |
+
- `notes_service.py`: Note creation, linking to sources/insights
|
| 28 |
+
- `transformations_service.py`: Applies transformations to content
|
| 29 |
+
- `models_service.py`: Manages AI provider/model configuration
|
| 30 |
+
- `episode_profiles_service.py`: Manages podcast speaker/episode profiles
|
| 31 |
+
|
| 32 |
+
## Component Catalog
|
| 33 |
+
|
| 34 |
+
### Main Application
|
| 35 |
+
- **main.py**: FastAPI app initialization, CORS setup, auth middleware, lifespan event, router registration
|
| 36 |
+
- **Lifespan handler**: Runs AsyncMigrationManager on startup (database schema migration)
|
| 37 |
+
- **Auth middleware**: PasswordAuthMiddleware protects endpoints (password-based access control)
|
| 38 |
+
|
| 39 |
+
### Services (Business Logic)
|
| 40 |
+
- **chat_service.py**: Invokes chat.py graph; handles message history via SqliteSaver
|
| 41 |
+
- **podcast_service.py**: Generates outline (outline.jinja), then transcript (transcript.jinja) for episodes
|
| 42 |
+
- **sources_service.py**: Ingests files/URLs (content_core), extracts text, vectorizes, saves to SurrealDB
|
| 43 |
+
- **transformations_service.py**: Applies transformations via transformation.py graph
|
| 44 |
+
- **models_service.py**: Manages ModelManager config (AI provider overrides)
|
| 45 |
+
- **episode_profiles_service.py**: CRUD for EpisodeProfile and SpeakerProfile models
|
| 46 |
+
- **insights_service.py**: Generates and retrieves source insights
|
| 47 |
+
- **notes_service.py**: Creates notes linked to sources/insights
|
| 48 |
+
|
| 49 |
+
### Models (Schemas)
|
| 50 |
+
- **models.py**: Pydantic schemas for request/response validation
|
| 51 |
+
- Request bodies: ChatRequest, CreateNoteRequest, PodcastGenerationRequest, etc.
|
| 52 |
+
- Response bodies: ChatResponse, NoteResponse, PodcastResponse, etc.
|
| 53 |
+
- Custom validators for enum fields, file paths, model references
|
| 54 |
+
|
| 55 |
+
### Routers
|
| 56 |
+
- **routers/chat.py**: POST /chat
|
| 57 |
+
- **routers/source_chat.py**: POST /source/{source_id}/chat
|
| 58 |
+
- **routers/podcasts.py**: POST /podcasts, GET /podcasts/{id}, POST /podcasts/episodes/{id}/retry, etc.
|
| 59 |
+
- **routers/notes.py**: POST /notes, GET /notes/{id}
|
| 60 |
+
- **routers/sources.py**: POST /sources, GET /sources/{id}, DELETE /sources/{id}
|
| 61 |
+
- **routers/models.py**: GET /models, POST /models/config
|
| 62 |
+
- **routers/credentials.py**: CRUD + test + discover + migrate for credential management
|
| 63 |
+
- **routers/transformations.py**: POST /transformations
|
| 64 |
+
- **routers/insights.py**: GET /sources/{source_id}/insights
|
| 65 |
+
- **routers/auth.py**: POST /auth/password (password-based auth)
|
| 66 |
+
- **routers/languages.py**: GET /languages (available podcast languages via pycountry+babel)
|
| 67 |
+
- **routers/commands.py**: GET /commands/{command_id} (job status tracking)
|
| 68 |
+
|
| 69 |
+
## Common Patterns
|
| 70 |
+
|
| 71 |
+
- **Service injection via FastAPI**: Routers import services directly; no DI framework
|
| 72 |
+
- **Async/await throughout**: All DB queries, graph invocations, AI calls are async
|
| 73 |
+
- **SurrealDB transactions**: Services use repo_query, repo_create, repo_upsert from database layer
|
| 74 |
+
- **Config override pattern**: Models/config override via models_service passed to graph.ainvoke(config=...)
|
| 75 |
+
- **Error handling**: Custom exception hierarchy (`open_notebook.exceptions`) with global FastAPI exception handlers mapping to HTTP status codes (see Error Handling section below). LangGraph nodes use `classify_error()` to convert raw LLM provider errors into typed exceptions with user-friendly messages.
|
| 76 |
+
- **Logging**: loguru logger in main.py; services expected to log key operations
|
| 77 |
+
- **Response normalization**: All responses follow standard schema (data + metadata structure)
|
| 78 |
+
|
| 79 |
+
## Key Dependencies
|
| 80 |
+
|
| 81 |
+
- `fastapi`: FastAPI app, routers, HTTPException
|
| 82 |
+
- `pydantic`: Validation models with Field, field_validator
|
| 83 |
+
- `open_notebook.graphs`: chat, ask, source_chat, source, transformation graphs
|
| 84 |
+
- `open_notebook.database`: SurrealDB repository functions (repo_query, repo_create, repo_upsert)
|
| 85 |
+
- `open_notebook.domain`: Notebook, Source, Note, SourceInsight models
|
| 86 |
+
- `open_notebook.ai.provision`: provision_langchain_model() factory
|
| 87 |
+
- `ai_prompter`: Prompter for template rendering
|
| 88 |
+
- `content_core`: extract_content() for file/URL processing
|
| 89 |
+
- `esperanto`: AI provider client library (LLM, embeddings, TTS)
|
| 90 |
+
- `surreal_commands`: Job queue for async operations (podcast generation)
|
| 91 |
+
- `loguru`: Structured logging
|
| 92 |
+
|
| 93 |
+
## Important Quirks & Gotchas
|
| 94 |
+
|
| 95 |
+
- **Migration auto-run**: Database schema migrations run on every API startup (via lifespan); no manual migration steps
|
| 96 |
+
- **PasswordAuthMiddleware is basic**: Uses simple password check; production deployments should replace with OAuth/JWT
|
| 97 |
+
- **No request rate limiting**: No built-in rate limiting; deployment must add via proxy/middleware
|
| 98 |
+
- **Service state is stateless**: Services don't cache results; each request re-queries database/AI models
|
| 99 |
+
- **Graph invocation is blocking**: chat/podcast workflows may take minutes; no timeout handling in services
|
| 100 |
+
- **Command job fire-and-forget**: podcast_service.py submits jobs but doesn't wait (async job queue pattern)
|
| 101 |
+
- **Model override scoping**: Model config override via RunnableConfig is per-request only (not persistent)
|
| 102 |
+
- **CORS open by default**: main.py CORS settings allow all origins (restrict before production)
|
| 103 |
+
- **No OpenAPI security scheme**: API docs available without auth (disable before production)
|
| 104 |
+
- **Services don't validate user permission**: All endpoints trust authentication layer; no per-notebook permission checks
|
| 105 |
+
|
| 106 |
+
## Error Handling
|
| 107 |
+
|
| 108 |
+
### Global Exception Handlers (`main.py`)
|
| 109 |
+
|
| 110 |
+
FastAPI exception handlers map custom exception types from `open_notebook.exceptions` to HTTP status codes. All error responses include CORS headers.
|
| 111 |
+
|
| 112 |
+
| Exception Class | HTTP Status | Use Case |
|
| 113 |
+
|----------------|-------------|----------|
|
| 114 |
+
| `NotFoundError` | 404 | Resource not found |
|
| 115 |
+
| `InvalidInputError` | 400 | Bad request data |
|
| 116 |
+
| `AuthenticationError` | 401 | Invalid/missing API key |
|
| 117 |
+
| `RateLimitError` | 429 | Provider rate limit exceeded |
|
| 118 |
+
| `ConfigurationError` | 422 | Wrong model name, missing config |
|
| 119 |
+
| `NetworkError` | 502 | Cannot reach AI provider |
|
| 120 |
+
| `ExternalServiceError` | 502 | Provider returned error (500/503, context length) |
|
| 121 |
+
| `OpenNotebookError` (base) | 500 | Any other application error |
|
| 122 |
+
|
| 123 |
+
### Error Classification (`open_notebook.utils.error_classifier`)
|
| 124 |
+
|
| 125 |
+
The `classify_error()` function maps raw exceptions from LLM providers/Esperanto/LangChain into the typed exceptions above with user-friendly messages. Used in all LangGraph graph nodes and SSE streaming handlers.
|
| 126 |
+
|
| 127 |
+
**Flow**: Raw exception → keyword matching → `(ExceptionClass, user_message)` → raised → caught by global handler → HTTP response with descriptive message.
|
| 128 |
+
|
| 129 |
+
### Frontend Integration
|
| 130 |
+
|
| 131 |
+
The frontend `getApiErrorMessage()` helper (`lib/utils/error-handler.ts`) tries i18n mapping first, then falls back to displaying the backend's descriptive error message directly.
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## How to Add New Endpoint
|
| 136 |
+
|
| 137 |
+
1. Create router file in `routers/` (e.g., `routers/new_feature.py`)
|
| 138 |
+
2. Import router into `main.py` and register: `app.include_router(new_feature.router, tags=["new_feature"])`
|
| 139 |
+
3. Create service in `new_feature_service.py` with business logic
|
| 140 |
+
4. Define request/response schemas in `models.py` (or create `new_feature_models.py`)
|
| 141 |
+
5. Implement router functions calling service methods
|
| 142 |
+
6. Test with `uv run uvicorn api.main:app --host 0.0.0.0 --port 5055`
|
| 143 |
+
|
| 144 |
+
## Testing Patterns
|
| 145 |
+
|
| 146 |
+
- **Interactive docs**: http://localhost:5055/docs (Swagger UI)
|
| 147 |
+
- **Direct service tests**: Import service, call methods directly with test data
|
| 148 |
+
- **Mock graphs**: Replace graph.ainvoke() with mock for testing service logic
|
| 149 |
+
- **Database: Use test database** (separate SurrealDB instance or mock repo_query)
|
| 150 |
+
|
| 151 |
+
---
|
| 152 |
+
|
| 153 |
+
## Credential Management (API Configuration UI)
|
| 154 |
+
|
| 155 |
+
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.
|
| 156 |
+
|
| 157 |
+
### Router: `routers/credentials.py`
|
| 158 |
+
|
| 159 |
+
**Endpoints**:
|
| 160 |
+
|
| 161 |
+
| Method | Endpoint | Description |
|
| 162 |
+
|--------|----------|-------------|
|
| 163 |
+
| GET | `/credentials` | List all credentials (optional `?provider=` filter) |
|
| 164 |
+
| GET | `/credentials/by-provider/{provider}` | List credentials for a provider |
|
| 165 |
+
| POST | `/credentials` | Create a new credential |
|
| 166 |
+
| GET | `/credentials/{credential_id}` | Get a specific credential |
|
| 167 |
+
| PUT | `/credentials/{credential_id}` | Update a credential |
|
| 168 |
+
| DELETE | `/credentials/{credential_id}` | Delete a credential |
|
| 169 |
+
| POST | `/credentials/{credential_id}/test` | Test connection using credential |
|
| 170 |
+
| POST | `/credentials/{credential_id}/discover` | Discover available models |
|
| 171 |
+
| POST | `/credentials/{credential_id}/register-models` | Register discovered models |
|
| 172 |
+
| POST | `/credentials/migrate-from-provider-config` | Migrate from legacy ProviderConfig |
|
| 173 |
+
|
| 174 |
+
**Supported Providers** (13 total):
|
| 175 |
+
- Simple API key: `openai`, `anthropic`, `google`, `groq`, `mistral`, `deepseek`, `xai`, `openrouter`, `voyage`, `elevenlabs`
|
| 176 |
+
- URL-based: `ollama`
|
| 177 |
+
- Multi-field: `azure`, `vertex`, `openai_compatible`
|
| 178 |
+
|
| 179 |
+
**Security Features**:
|
| 180 |
+
- NEVER returns actual API key values (only metadata)
|
| 181 |
+
- URL validation (SSRF protection) on all URL fields via `_validate_url()`
|
| 182 |
+
- Allows private IPs and localhost for self-hosted services (Ollama, LM Studio)
|
| 183 |
+
- Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` to be set for storing credentials
|
| 184 |
+
|
| 185 |
+
### Domain Model: `Credential` (`open_notebook/domain/credential.py`)
|
| 186 |
+
|
| 187 |
+
Individual credential records replacing the old `ProviderConfig` singleton. Each credential stores:
|
| 188 |
+
- Provider name, display name, modalities
|
| 189 |
+
- Encrypted API key (via Fernet)
|
| 190 |
+
- Provider-specific config (base_url, endpoint, api_version, etc.)
|
| 191 |
+
|
| 192 |
+
### Integration with Key Provider (`open_notebook/ai/key_provider.py`)
|
| 193 |
+
|
| 194 |
+
The `key_provider` module provisions DB-stored credentials into environment variables for Esperanto compatibility:
|
| 195 |
+
|
| 196 |
+
**Database-first Pattern**:
|
| 197 |
+
1. API endpoint saves keys to `Credential` records (encrypted in SurrealDB)
|
| 198 |
+
2. Before model provisioning, `provision_provider_keys(provider)` checks DB, then env vars
|
| 199 |
+
3. Keys from DB are set as environment variables for Esperanto compatibility
|
| 200 |
+
4. Existing env vars remain unchanged if no DB config exists
|
| 201 |
+
|
| 202 |
+
**Key Functions**:
|
| 203 |
+
- `get_api_key(provider)`: Get API key (DB first, env fallback)
|
| 204 |
+
- `provision_provider_keys(provider)`: Set env vars from DB for a provider
|
| 205 |
+
- `provision_all_keys()`: Load all provider keys from DB into env vars
|
| 206 |
+
|
| 207 |
+
### Authentication
|
| 208 |
+
|
| 209 |
+
No changes to authentication. The `credentials` router uses the same `PasswordAuthMiddleware` as all other endpoints. Keys are protected by the same password-based auth.
|
| 210 |
+
|
| 211 |
+
**Auth Flow** (unchanged from `api/auth.py`):
|
| 212 |
+
- `PasswordAuthMiddleware`: Global middleware checking `Authorization: Bearer {password}` header
|
| 213 |
+
- Default password: `open-notebook-change-me` (set `OPEN_NOTEBOOK_PASSWORD` in production)
|
| 214 |
+
- Docker secrets support via `OPEN_NOTEBOOK_PASSWORD_FILE`
|
| 215 |
+
|
| 216 |
+
### Connection Testing (`open_notebook/ai/connection_tester.py`)
|
| 217 |
+
|
| 218 |
+
The `/credentials/{credential_id}/test` endpoint uses minimal API calls to verify credentials:
|
| 219 |
+
- Loads Credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()`
|
| 220 |
+
- Uses cheapest/smallest models per provider (TEST_MODELS map)
|
| 221 |
+
- Returns success status and descriptive message
|
| 222 |
+
- Special handlers for ollama, openai_compatible, and azure providers
|
| 223 |
+
|
| 224 |
+
### Migration Workflows
|
| 225 |
+
|
| 226 |
+
Two migration endpoints help users transition to the credential system:
|
| 227 |
+
|
| 228 |
+
**From environment variables** (`POST /credentials/migrate-from-env`):
|
| 229 |
+
1. Checks each provider for env var presence
|
| 230 |
+
2. Creates Credential records from env var values
|
| 231 |
+
3. Returns summary: migrated, skipped, errors
|
| 232 |
+
|
| 233 |
+
**From legacy ProviderConfig** (`POST /credentials/migrate-from-provider-config`):
|
| 234 |
+
1. Reads old ProviderConfig records from database
|
| 235 |
+
2. Converts each to individual Credential records
|
| 236 |
+
3. Returns summary: migrated, skipped, errors
|
| 237 |
+
|
| 238 |
+
### Example Usage
|
| 239 |
+
|
| 240 |
+
```python
|
| 241 |
+
# Check status
|
| 242 |
+
GET /credentials/status
|
| 243 |
+
# Response: {"configured": {"openai": true, "anthropic": false}, "source": {"openai": "database", "anthropic": "none"}, "encryption_configured": true}
|
| 244 |
+
|
| 245 |
+
# Create credential
|
| 246 |
+
POST /credentials
|
| 247 |
+
{"name": "My OpenAI Key", "provider": "openai", "modalities": ["language", "embedding"], "api_key": "sk-proj-..."}
|
| 248 |
+
|
| 249 |
+
# Test connection
|
| 250 |
+
POST /credentials/{credential_id}/test
|
| 251 |
+
# Response: {"provider": "openai", "success": true, "message": "Connection successful"}
|
| 252 |
+
|
| 253 |
+
# Discover models
|
| 254 |
+
POST /credentials/{credential_id}/discover
|
| 255 |
+
# Response: {"provider": "openai", "models": [{"model_id": "gpt-4", "name": "gpt-4", ...}], "credential_id": "..."}
|
| 256 |
+
|
| 257 |
+
# Migrate from env
|
| 258 |
+
POST /credentials/migrate-from-env
|
| 259 |
+
# Response: {"message": "Migration complete. Migrated 3 providers.", "migrated": ["openai", "anthropic", "groq"], "skipped": [], "errors": []}
|
| 260 |
+
```
|
api/__init__.py
ADDED
|
File without changes
|
api/auth.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import Depends, HTTPException, Request
|
| 4 |
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 5 |
+
from loguru import logger
|
| 6 |
+
from starlette.middleware.base import BaseHTTPMiddleware
|
| 7 |
+
from starlette.responses import JSONResponse
|
| 8 |
+
|
| 9 |
+
from open_notebook.utils.encryption import get_secret_from_env
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PasswordAuthMiddleware(BaseHTTPMiddleware):
|
| 13 |
+
"""
|
| 14 |
+
Middleware to check password authentication for all API requests.
|
| 15 |
+
Always active with default password if OPEN_NOTEBOOK_PASSWORD is not set.
|
| 16 |
+
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
def __init__(self, app, excluded_paths: Optional[list] = None):
|
| 20 |
+
super().__init__(app)
|
| 21 |
+
self.password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
|
| 22 |
+
self.excluded_paths = excluded_paths or [
|
| 23 |
+
"/",
|
| 24 |
+
"/health",
|
| 25 |
+
"/docs",
|
| 26 |
+
"/openapi.json",
|
| 27 |
+
"/redoc",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
async def dispatch(self, request: Request, call_next):
|
| 31 |
+
# Skip authentication if no password is set
|
| 32 |
+
if not self.password:
|
| 33 |
+
return await call_next(request)
|
| 34 |
+
|
| 35 |
+
# Skip authentication for excluded paths
|
| 36 |
+
if request.url.path in self.excluded_paths:
|
| 37 |
+
return await call_next(request)
|
| 38 |
+
|
| 39 |
+
# Skip authentication for CORS preflight requests (OPTIONS)
|
| 40 |
+
if request.method == "OPTIONS":
|
| 41 |
+
return await call_next(request)
|
| 42 |
+
|
| 43 |
+
# Check authorization header
|
| 44 |
+
auth_header = request.headers.get("Authorization")
|
| 45 |
+
|
| 46 |
+
if not auth_header:
|
| 47 |
+
return JSONResponse(
|
| 48 |
+
status_code=401,
|
| 49 |
+
content={"detail": "Missing authorization header"},
|
| 50 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Expected format: "Bearer {password}"
|
| 54 |
+
try:
|
| 55 |
+
scheme, credentials = auth_header.split(" ", 1)
|
| 56 |
+
if scheme.lower() != "bearer":
|
| 57 |
+
raise ValueError("Invalid authentication scheme")
|
| 58 |
+
except ValueError:
|
| 59 |
+
return JSONResponse(
|
| 60 |
+
status_code=401,
|
| 61 |
+
content={"detail": "Invalid authorization header format"},
|
| 62 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
# Check password
|
| 66 |
+
if credentials != self.password:
|
| 67 |
+
return JSONResponse(
|
| 68 |
+
status_code=401,
|
| 69 |
+
content={"detail": "Invalid password"},
|
| 70 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Password is correct, proceed with the request
|
| 74 |
+
response = await call_next(request)
|
| 75 |
+
return response
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Optional: HTTPBearer security scheme for OpenAPI documentation
|
| 79 |
+
security = HTTPBearer(auto_error=False)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def check_api_password(
|
| 83 |
+
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
| 84 |
+
) -> bool:
|
| 85 |
+
"""
|
| 86 |
+
Utility function to check API password.
|
| 87 |
+
Can be used as a dependency in individual routes if needed.
|
| 88 |
+
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
|
| 89 |
+
Returns True without checking credentials if OPEN_NOTEBOOK_PASSWORD is not configured.
|
| 90 |
+
Raises 401 if credentials are missing or don't match the configured password.
|
| 91 |
+
"""
|
| 92 |
+
password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
|
| 93 |
+
|
| 94 |
+
# No password configured - skip authentication
|
| 95 |
+
if not password:
|
| 96 |
+
return True
|
| 97 |
+
|
| 98 |
+
# No credentials provided
|
| 99 |
+
if not credentials:
|
| 100 |
+
raise HTTPException(
|
| 101 |
+
status_code=401,
|
| 102 |
+
detail="Missing authorization",
|
| 103 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Check password
|
| 107 |
+
if credentials.credentials != password:
|
| 108 |
+
raise HTTPException(
|
| 109 |
+
status_code=401,
|
| 110 |
+
detail="Invalid password",
|
| 111 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return True
|
api/chat_service.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat service for API operations.
|
| 3 |
+
Provides async interface for chat functionality.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ChatService:
|
| 14 |
+
"""Service for chat-related API operations"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.base_url = os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
|
| 18 |
+
# Add authentication header if password is set
|
| 19 |
+
self.headers = {}
|
| 20 |
+
password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
|
| 21 |
+
if password:
|
| 22 |
+
self.headers["Authorization"] = f"Bearer {password}"
|
| 23 |
+
|
| 24 |
+
async def get_sessions(self, notebook_id: str) -> List[Dict[str, Any]]:
|
| 25 |
+
"""Get all chat sessions for a notebook"""
|
| 26 |
+
try:
|
| 27 |
+
async with httpx.AsyncClient() as client:
|
| 28 |
+
response = await client.get(
|
| 29 |
+
f"{self.base_url}/api/chat/sessions",
|
| 30 |
+
params={"notebook_id": notebook_id},
|
| 31 |
+
headers=self.headers,
|
| 32 |
+
)
|
| 33 |
+
response.raise_for_status()
|
| 34 |
+
return response.json()
|
| 35 |
+
except Exception as e:
|
| 36 |
+
logger.error(f"Error fetching chat sessions: {str(e)}")
|
| 37 |
+
raise
|
| 38 |
+
|
| 39 |
+
async def create_session(
|
| 40 |
+
self,
|
| 41 |
+
notebook_id: str,
|
| 42 |
+
title: Optional[str] = None,
|
| 43 |
+
model_override: Optional[str] = None,
|
| 44 |
+
) -> Dict[str, Any]:
|
| 45 |
+
"""Create a new chat session"""
|
| 46 |
+
try:
|
| 47 |
+
data: Dict[str, Any] = {"notebook_id": notebook_id}
|
| 48 |
+
if title is not None:
|
| 49 |
+
data["title"] = title
|
| 50 |
+
if model_override is not None:
|
| 51 |
+
data["model_override"] = model_override
|
| 52 |
+
|
| 53 |
+
async with httpx.AsyncClient() as client:
|
| 54 |
+
response = await client.post(
|
| 55 |
+
f"{self.base_url}/api/chat/sessions",
|
| 56 |
+
json=data,
|
| 57 |
+
headers=self.headers,
|
| 58 |
+
)
|
| 59 |
+
response.raise_for_status()
|
| 60 |
+
return response.json()
|
| 61 |
+
except Exception as e:
|
| 62 |
+
logger.error(f"Error creating chat session: {str(e)}")
|
| 63 |
+
raise
|
| 64 |
+
|
| 65 |
+
async def get_session(self, session_id: str) -> Dict[str, Any]:
|
| 66 |
+
"""Get a specific session with messages"""
|
| 67 |
+
try:
|
| 68 |
+
async with httpx.AsyncClient() as client:
|
| 69 |
+
response = await client.get(
|
| 70 |
+
f"{self.base_url}/api/chat/sessions/{session_id}",
|
| 71 |
+
headers=self.headers,
|
| 72 |
+
)
|
| 73 |
+
response.raise_for_status()
|
| 74 |
+
return response.json()
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"Error fetching session: {str(e)}")
|
| 77 |
+
raise
|
| 78 |
+
|
| 79 |
+
async def update_session(
|
| 80 |
+
self,
|
| 81 |
+
session_id: str,
|
| 82 |
+
title: Optional[str] = None,
|
| 83 |
+
model_override: Optional[str] = None,
|
| 84 |
+
) -> Dict[str, Any]:
|
| 85 |
+
"""Update session properties"""
|
| 86 |
+
try:
|
| 87 |
+
data: Dict[str, Any] = {}
|
| 88 |
+
if title is not None:
|
| 89 |
+
data["title"] = title
|
| 90 |
+
if model_override is not None:
|
| 91 |
+
data["model_override"] = model_override
|
| 92 |
+
|
| 93 |
+
if not data:
|
| 94 |
+
raise ValueError(
|
| 95 |
+
"At least one field must be provided to update a session"
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
async with httpx.AsyncClient() as client:
|
| 99 |
+
response = await client.put(
|
| 100 |
+
f"{self.base_url}/api/chat/sessions/{session_id}",
|
| 101 |
+
json=data,
|
| 102 |
+
headers=self.headers,
|
| 103 |
+
)
|
| 104 |
+
response.raise_for_status()
|
| 105 |
+
return response.json()
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Error updating session: {str(e)}")
|
| 108 |
+
raise
|
| 109 |
+
|
| 110 |
+
async def delete_session(self, session_id: str) -> Dict[str, Any]:
|
| 111 |
+
"""Delete a chat session"""
|
| 112 |
+
try:
|
| 113 |
+
async with httpx.AsyncClient() as client:
|
| 114 |
+
response = await client.delete(
|
| 115 |
+
f"{self.base_url}/api/chat/sessions/{session_id}",
|
| 116 |
+
headers=self.headers,
|
| 117 |
+
)
|
| 118 |
+
response.raise_for_status()
|
| 119 |
+
return response.json()
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.error(f"Error deleting session: {str(e)}")
|
| 122 |
+
raise
|
| 123 |
+
|
| 124 |
+
async def execute_chat(
|
| 125 |
+
self,
|
| 126 |
+
session_id: str,
|
| 127 |
+
message: str,
|
| 128 |
+
context: Dict[str, Any],
|
| 129 |
+
model_override: Optional[str] = None,
|
| 130 |
+
) -> Dict[str, Any]:
|
| 131 |
+
"""Execute a chat request"""
|
| 132 |
+
try:
|
| 133 |
+
data = {"session_id": session_id, "message": message, "context": context}
|
| 134 |
+
if model_override is not None:
|
| 135 |
+
data["model_override"] = model_override
|
| 136 |
+
|
| 137 |
+
# Short connect timeout (10s), long read timeout (10 min) for Ollama/local LLMs
|
| 138 |
+
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
|
| 139 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 140 |
+
response = await client.post(
|
| 141 |
+
f"{self.base_url}/api/chat/execute", json=data, headers=self.headers
|
| 142 |
+
)
|
| 143 |
+
response.raise_for_status()
|
| 144 |
+
return response.json()
|
| 145 |
+
except Exception as e:
|
| 146 |
+
logger.error(f"Error executing chat: {str(e)}")
|
| 147 |
+
raise
|
| 148 |
+
|
| 149 |
+
async def build_context(
|
| 150 |
+
self, notebook_id: str, context_config: Dict[str, Any]
|
| 151 |
+
) -> Dict[str, Any]:
|
| 152 |
+
"""Build context for a notebook"""
|
| 153 |
+
try:
|
| 154 |
+
data = {"notebook_id": notebook_id, "context_config": context_config}
|
| 155 |
+
|
| 156 |
+
async with httpx.AsyncClient() as client:
|
| 157 |
+
response = await client.post(
|
| 158 |
+
f"{self.base_url}/api/chat/context", json=data, headers=self.headers
|
| 159 |
+
)
|
| 160 |
+
response.raise_for_status()
|
| 161 |
+
return response.json()
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"Error building context: {str(e)}")
|
| 164 |
+
raise
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# Global instance
|
| 168 |
+
chat_service = ChatService()
|
api/client.py
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
API client for Open Notebook API.
|
| 3 |
+
This module provides a client interface to interact with the Open Notebook API.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from typing import Any, Dict, List, Optional, Union
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
from loguru import logger
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class APIClient:
|
| 14 |
+
"""Client for Open Notebook API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, base_url: Optional[str] = None):
|
| 17 |
+
self.base_url = base_url or os.getenv("API_BASE_URL", "http://127.0.0.1:5055")
|
| 18 |
+
# Timeout increased to 5 minutes (300s) to accommodate slow LLM operations
|
| 19 |
+
# (transformations, insights) on slower hardware (Ollama, LM Studio, remote APIs)
|
| 20 |
+
# Configurable via API_CLIENT_TIMEOUT environment variable (in seconds)
|
| 21 |
+
timeout_str = os.getenv("API_CLIENT_TIMEOUT", "300.0")
|
| 22 |
+
try:
|
| 23 |
+
timeout_value = float(timeout_str)
|
| 24 |
+
# Validate timeout is within reasonable bounds (30s - 3600s / 1 hour)
|
| 25 |
+
if timeout_value < 30:
|
| 26 |
+
logger.warning(
|
| 27 |
+
f"API_CLIENT_TIMEOUT={timeout_value}s is too low, using minimum of 30s"
|
| 28 |
+
)
|
| 29 |
+
timeout_value = 30.0
|
| 30 |
+
elif timeout_value > 3600:
|
| 31 |
+
logger.warning(
|
| 32 |
+
f"API_CLIENT_TIMEOUT={timeout_value}s is too high, using maximum of 3600s"
|
| 33 |
+
)
|
| 34 |
+
timeout_value = 3600.0
|
| 35 |
+
self.timeout = timeout_value
|
| 36 |
+
except ValueError:
|
| 37 |
+
logger.error(
|
| 38 |
+
f"Invalid API_CLIENT_TIMEOUT value '{timeout_str}', using default 300s"
|
| 39 |
+
)
|
| 40 |
+
self.timeout = 300.0
|
| 41 |
+
|
| 42 |
+
# Add authentication header if password is set
|
| 43 |
+
self.headers = {}
|
| 44 |
+
password = os.getenv("OPEN_NOTEBOOK_PASSWORD")
|
| 45 |
+
if password:
|
| 46 |
+
self.headers["Authorization"] = f"Bearer {password}"
|
| 47 |
+
|
| 48 |
+
def _make_request(
|
| 49 |
+
self, method: str, endpoint: str, timeout: Optional[float] = None, **kwargs
|
| 50 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 51 |
+
"""Make HTTP request to the API."""
|
| 52 |
+
url = f"{self.base_url}{endpoint}"
|
| 53 |
+
request_timeout = timeout if timeout is not None else self.timeout
|
| 54 |
+
|
| 55 |
+
# Merge headers
|
| 56 |
+
headers = kwargs.get("headers", {})
|
| 57 |
+
headers.update(self.headers)
|
| 58 |
+
kwargs["headers"] = headers
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
with httpx.Client(timeout=request_timeout) as client:
|
| 62 |
+
response = client.request(method, url, **kwargs)
|
| 63 |
+
response.raise_for_status()
|
| 64 |
+
return response.json()
|
| 65 |
+
except httpx.RequestError as e:
|
| 66 |
+
logger.error(f"Request error for {method} {url}: {str(e)}")
|
| 67 |
+
raise ConnectionError(f"Failed to connect to API: {str(e)}")
|
| 68 |
+
except httpx.HTTPStatusError as e:
|
| 69 |
+
logger.error(
|
| 70 |
+
f"HTTP error {e.response.status_code} for {method} {url}: {e.response.text}"
|
| 71 |
+
)
|
| 72 |
+
raise RuntimeError(
|
| 73 |
+
f"API request failed: {e.response.status_code} - {e.response.text}"
|
| 74 |
+
)
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.error(f"Unexpected error for {method} {url}: {str(e)}")
|
| 77 |
+
raise
|
| 78 |
+
|
| 79 |
+
# Notebooks API methods
|
| 80 |
+
def get_notebooks(
|
| 81 |
+
self, archived: Optional[bool] = None, order_by: str = "updated desc"
|
| 82 |
+
) -> List[Dict[Any, Any]]:
|
| 83 |
+
"""Get all notebooks."""
|
| 84 |
+
params: Dict[str, Any] = {"order_by": order_by}
|
| 85 |
+
if archived is not None:
|
| 86 |
+
params["archived"] = str(archived).lower()
|
| 87 |
+
|
| 88 |
+
result = self._make_request("GET", "/api/notebooks", params=params)
|
| 89 |
+
return result if isinstance(result, list) else [result]
|
| 90 |
+
|
| 91 |
+
def create_notebook(
|
| 92 |
+
self, name: str, description: str = ""
|
| 93 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 94 |
+
"""Create a new notebook."""
|
| 95 |
+
data = {"name": name, "description": description}
|
| 96 |
+
return self._make_request("POST", "/api/notebooks", json=data)
|
| 97 |
+
|
| 98 |
+
def get_notebook(
|
| 99 |
+
self, notebook_id: str
|
| 100 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 101 |
+
"""Get a specific notebook."""
|
| 102 |
+
return self._make_request("GET", f"/api/notebooks/{notebook_id}")
|
| 103 |
+
|
| 104 |
+
def update_notebook(
|
| 105 |
+
self, notebook_id: str, **updates
|
| 106 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 107 |
+
"""Update a notebook."""
|
| 108 |
+
return self._make_request("PUT", f"/api/notebooks/{notebook_id}", json=updates)
|
| 109 |
+
|
| 110 |
+
def delete_notebook(
|
| 111 |
+
self, notebook_id: str
|
| 112 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 113 |
+
"""Delete a notebook."""
|
| 114 |
+
return self._make_request("DELETE", f"/api/notebooks/{notebook_id}")
|
| 115 |
+
|
| 116 |
+
# Search API methods
|
| 117 |
+
def search(
|
| 118 |
+
self,
|
| 119 |
+
query: str,
|
| 120 |
+
search_type: str = "text",
|
| 121 |
+
limit: int = 100,
|
| 122 |
+
search_sources: bool = True,
|
| 123 |
+
search_notes: bool = True,
|
| 124 |
+
minimum_score: float = 0.2,
|
| 125 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 126 |
+
"""Search the knowledge base."""
|
| 127 |
+
data = {
|
| 128 |
+
"query": query,
|
| 129 |
+
"type": search_type,
|
| 130 |
+
"limit": limit,
|
| 131 |
+
"search_sources": search_sources,
|
| 132 |
+
"search_notes": search_notes,
|
| 133 |
+
"minimum_score": minimum_score,
|
| 134 |
+
}
|
| 135 |
+
return self._make_request("POST", "/api/search", json=data)
|
| 136 |
+
|
| 137 |
+
def ask_simple(
|
| 138 |
+
self,
|
| 139 |
+
question: str,
|
| 140 |
+
strategy_model: str,
|
| 141 |
+
answer_model: str,
|
| 142 |
+
final_answer_model: str,
|
| 143 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 144 |
+
"""Ask the knowledge base a question (simple, non-streaming)."""
|
| 145 |
+
data = {
|
| 146 |
+
"question": question,
|
| 147 |
+
"strategy_model": strategy_model,
|
| 148 |
+
"answer_model": answer_model,
|
| 149 |
+
"final_answer_model": final_answer_model,
|
| 150 |
+
}
|
| 151 |
+
# Use configured timeout for long-running ask operations
|
| 152 |
+
return self._make_request(
|
| 153 |
+
"POST", "/api/search/ask/simple", json=data, timeout=self.timeout
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
# Models API methods
|
| 157 |
+
def get_models(self, model_type: Optional[str] = None) -> List[Dict[Any, Any]]:
|
| 158 |
+
"""Get all models with optional type filtering."""
|
| 159 |
+
params = {}
|
| 160 |
+
if model_type:
|
| 161 |
+
params["type"] = model_type
|
| 162 |
+
result = self._make_request("GET", "/api/models", params=params)
|
| 163 |
+
return result if isinstance(result, list) else [result]
|
| 164 |
+
|
| 165 |
+
def create_model(
|
| 166 |
+
self, name: str, provider: str, model_type: str
|
| 167 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 168 |
+
"""Create a new model."""
|
| 169 |
+
data = {
|
| 170 |
+
"name": name,
|
| 171 |
+
"provider": provider,
|
| 172 |
+
"type": model_type,
|
| 173 |
+
}
|
| 174 |
+
return self._make_request("POST", "/api/models", json=data)
|
| 175 |
+
|
| 176 |
+
def delete_model(
|
| 177 |
+
self, model_id: str
|
| 178 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 179 |
+
"""Delete a model."""
|
| 180 |
+
return self._make_request("DELETE", f"/api/models/{model_id}")
|
| 181 |
+
|
| 182 |
+
def get_default_models(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 183 |
+
"""Get default model assignments."""
|
| 184 |
+
return self._make_request("GET", "/api/models/defaults")
|
| 185 |
+
|
| 186 |
+
def update_default_models(
|
| 187 |
+
self, **defaults
|
| 188 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 189 |
+
"""Update default model assignments."""
|
| 190 |
+
return self._make_request("PUT", "/api/models/defaults", json=defaults)
|
| 191 |
+
|
| 192 |
+
# Transformations API methods
|
| 193 |
+
def get_transformations(self) -> List[Dict[Any, Any]]:
|
| 194 |
+
"""Get all transformations."""
|
| 195 |
+
result = self._make_request("GET", "/api/transformations")
|
| 196 |
+
return result if isinstance(result, list) else [result]
|
| 197 |
+
|
| 198 |
+
def create_transformation(
|
| 199 |
+
self,
|
| 200 |
+
name: str,
|
| 201 |
+
title: str,
|
| 202 |
+
description: str,
|
| 203 |
+
prompt: str,
|
| 204 |
+
apply_default: bool = False,
|
| 205 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 206 |
+
"""Create a new transformation."""
|
| 207 |
+
data = {
|
| 208 |
+
"name": name,
|
| 209 |
+
"title": title,
|
| 210 |
+
"description": description,
|
| 211 |
+
"prompt": prompt,
|
| 212 |
+
"apply_default": apply_default,
|
| 213 |
+
}
|
| 214 |
+
return self._make_request("POST", "/api/transformations", json=data)
|
| 215 |
+
|
| 216 |
+
def get_transformation(
|
| 217 |
+
self, transformation_id: str
|
| 218 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 219 |
+
"""Get a specific transformation."""
|
| 220 |
+
return self._make_request("GET", f"/api/transformations/{transformation_id}")
|
| 221 |
+
|
| 222 |
+
def update_transformation(
|
| 223 |
+
self, transformation_id: str, **updates
|
| 224 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 225 |
+
"""Update a transformation."""
|
| 226 |
+
return self._make_request(
|
| 227 |
+
"PUT", f"/api/transformations/{transformation_id}", json=updates
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
def delete_transformation(
|
| 231 |
+
self, transformation_id: str
|
| 232 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 233 |
+
"""Delete a transformation."""
|
| 234 |
+
return self._make_request("DELETE", f"/api/transformations/{transformation_id}")
|
| 235 |
+
|
| 236 |
+
def execute_transformation(
|
| 237 |
+
self, transformation_id: str, input_text: str, model_id: str
|
| 238 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 239 |
+
"""Execute a transformation on input text."""
|
| 240 |
+
data = {
|
| 241 |
+
"transformation_id": transformation_id,
|
| 242 |
+
"input_text": input_text,
|
| 243 |
+
"model_id": model_id,
|
| 244 |
+
}
|
| 245 |
+
# Use configured timeout for transformation operations
|
| 246 |
+
return self._make_request(
|
| 247 |
+
"POST", "/api/transformations/execute", json=data, timeout=self.timeout
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Notes API methods
|
| 251 |
+
def get_notes(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
|
| 252 |
+
"""Get all notes with optional notebook filtering."""
|
| 253 |
+
params = {}
|
| 254 |
+
if notebook_id:
|
| 255 |
+
params["notebook_id"] = notebook_id
|
| 256 |
+
result = self._make_request("GET", "/api/notes", params=params)
|
| 257 |
+
return result if isinstance(result, list) else [result]
|
| 258 |
+
|
| 259 |
+
def create_note(
|
| 260 |
+
self,
|
| 261 |
+
content: str,
|
| 262 |
+
title: Optional[str] = None,
|
| 263 |
+
note_type: str = "human",
|
| 264 |
+
notebook_id: Optional[str] = None,
|
| 265 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 266 |
+
"""Create a new note."""
|
| 267 |
+
data = {
|
| 268 |
+
"content": content,
|
| 269 |
+
"note_type": note_type,
|
| 270 |
+
}
|
| 271 |
+
if title:
|
| 272 |
+
data["title"] = title
|
| 273 |
+
if notebook_id:
|
| 274 |
+
data["notebook_id"] = notebook_id
|
| 275 |
+
return self._make_request("POST", "/api/notes", json=data)
|
| 276 |
+
|
| 277 |
+
def get_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 278 |
+
"""Get a specific note."""
|
| 279 |
+
return self._make_request("GET", f"/api/notes/{note_id}")
|
| 280 |
+
|
| 281 |
+
def update_note(
|
| 282 |
+
self, note_id: str, **updates
|
| 283 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 284 |
+
"""Update a note."""
|
| 285 |
+
return self._make_request("PUT", f"/api/notes/{note_id}", json=updates)
|
| 286 |
+
|
| 287 |
+
def delete_note(self, note_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 288 |
+
"""Delete a note."""
|
| 289 |
+
return self._make_request("DELETE", f"/api/notes/{note_id}")
|
| 290 |
+
|
| 291 |
+
# Embedding API methods
|
| 292 |
+
def embed_content(
|
| 293 |
+
self, item_id: str, item_type: str, async_processing: bool = False
|
| 294 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 295 |
+
"""Embed content for vector search."""
|
| 296 |
+
data = {
|
| 297 |
+
"item_id": item_id,
|
| 298 |
+
"item_type": item_type,
|
| 299 |
+
"async_processing": async_processing,
|
| 300 |
+
}
|
| 301 |
+
# Use configured timeout for embedding operations
|
| 302 |
+
return self._make_request("POST", "/api/embed", json=data, timeout=self.timeout)
|
| 303 |
+
|
| 304 |
+
def rebuild_embeddings(
|
| 305 |
+
self,
|
| 306 |
+
mode: str = "existing",
|
| 307 |
+
include_sources: bool = True,
|
| 308 |
+
include_notes: bool = True,
|
| 309 |
+
include_insights: bool = True,
|
| 310 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 311 |
+
"""Rebuild embeddings in bulk.
|
| 312 |
+
|
| 313 |
+
Note: This operation can take a long time for large databases.
|
| 314 |
+
Consider increasing API_CLIENT_TIMEOUT to 600-900s for bulk rebuilds.
|
| 315 |
+
"""
|
| 316 |
+
data = {
|
| 317 |
+
"mode": mode,
|
| 318 |
+
"include_sources": include_sources,
|
| 319 |
+
"include_notes": include_notes,
|
| 320 |
+
"include_insights": include_insights,
|
| 321 |
+
}
|
| 322 |
+
# Use double the configured timeout for bulk rebuild operations (or configured value if already high)
|
| 323 |
+
rebuild_timeout = max(self.timeout, min(self.timeout * 2, 3600.0))
|
| 324 |
+
return self._make_request(
|
| 325 |
+
"POST", "/api/embeddings/rebuild", json=data, timeout=rebuild_timeout
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
def get_rebuild_status(
|
| 329 |
+
self, command_id: str
|
| 330 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 331 |
+
"""Get status of a rebuild operation."""
|
| 332 |
+
return self._make_request("GET", f"/api/embeddings/rebuild/{command_id}/status")
|
| 333 |
+
|
| 334 |
+
# Settings API methods
|
| 335 |
+
def get_settings(self) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 336 |
+
"""Get all application settings."""
|
| 337 |
+
return self._make_request("GET", "/api/settings")
|
| 338 |
+
|
| 339 |
+
def update_settings(
|
| 340 |
+
self, **settings
|
| 341 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 342 |
+
"""Update application settings."""
|
| 343 |
+
return self._make_request("PUT", "/api/settings", json=settings)
|
| 344 |
+
|
| 345 |
+
# Context API methods
|
| 346 |
+
def get_notebook_context(
|
| 347 |
+
self, notebook_id: str, context_config: Optional[Dict] = None
|
| 348 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 349 |
+
"""Get context for a notebook."""
|
| 350 |
+
data: Dict[str, Any] = {"notebook_id": notebook_id}
|
| 351 |
+
if context_config:
|
| 352 |
+
data["context_config"] = context_config
|
| 353 |
+
result = self._make_request(
|
| 354 |
+
"POST", f"/api/notebooks/{notebook_id}/context", json=data
|
| 355 |
+
)
|
| 356 |
+
return result if isinstance(result, dict) else {}
|
| 357 |
+
|
| 358 |
+
# Sources API methods
|
| 359 |
+
def get_sources(self, notebook_id: Optional[str] = None) -> List[Dict[Any, Any]]:
|
| 360 |
+
"""Get all sources with optional notebook filtering."""
|
| 361 |
+
params = {}
|
| 362 |
+
if notebook_id:
|
| 363 |
+
params["notebook_id"] = notebook_id
|
| 364 |
+
result = self._make_request("GET", "/api/sources", params=params)
|
| 365 |
+
return result if isinstance(result, list) else [result]
|
| 366 |
+
|
| 367 |
+
def create_source(
|
| 368 |
+
self,
|
| 369 |
+
notebook_id: Optional[str] = None,
|
| 370 |
+
notebooks: Optional[List[str]] = None,
|
| 371 |
+
source_type: str = "text",
|
| 372 |
+
url: Optional[str] = None,
|
| 373 |
+
file_path: Optional[str] = None,
|
| 374 |
+
content: Optional[str] = None,
|
| 375 |
+
title: Optional[str] = None,
|
| 376 |
+
transformations: Optional[List[str]] = None,
|
| 377 |
+
embed: bool = False,
|
| 378 |
+
delete_source: bool = False,
|
| 379 |
+
async_processing: bool = False,
|
| 380 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 381 |
+
"""Create a new source."""
|
| 382 |
+
data = {
|
| 383 |
+
"type": source_type,
|
| 384 |
+
"embed": embed,
|
| 385 |
+
"delete_source": delete_source,
|
| 386 |
+
"async_processing": async_processing,
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
# Handle backward compatibility for notebook_id vs notebooks
|
| 390 |
+
if notebooks:
|
| 391 |
+
data["notebooks"] = notebooks
|
| 392 |
+
elif notebook_id:
|
| 393 |
+
data["notebook_id"] = notebook_id
|
| 394 |
+
else:
|
| 395 |
+
raise ValueError("Either notebook_id or notebooks must be provided")
|
| 396 |
+
|
| 397 |
+
if url:
|
| 398 |
+
data["url"] = url
|
| 399 |
+
if file_path:
|
| 400 |
+
data["file_path"] = file_path
|
| 401 |
+
if content:
|
| 402 |
+
data["content"] = content
|
| 403 |
+
if title:
|
| 404 |
+
data["title"] = title
|
| 405 |
+
if transformations:
|
| 406 |
+
data["transformations"] = transformations
|
| 407 |
+
|
| 408 |
+
# Use configured timeout for source creation (especially PDF processing with OCR)
|
| 409 |
+
return self._make_request(
|
| 410 |
+
"POST", "/api/sources/json", json=data, timeout=self.timeout
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
def get_source(self, source_id: str) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 414 |
+
"""Get a specific source."""
|
| 415 |
+
return self._make_request("GET", f"/api/sources/{source_id}")
|
| 416 |
+
|
| 417 |
+
def get_source_status(
|
| 418 |
+
self, source_id: str
|
| 419 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 420 |
+
"""Get processing status for a source."""
|
| 421 |
+
return self._make_request("GET", f"/api/sources/{source_id}/status")
|
| 422 |
+
|
| 423 |
+
def update_source(
|
| 424 |
+
self, source_id: str, **updates
|
| 425 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 426 |
+
"""Update a source."""
|
| 427 |
+
return self._make_request("PUT", f"/api/sources/{source_id}", json=updates)
|
| 428 |
+
|
| 429 |
+
def delete_source(
|
| 430 |
+
self, source_id: str
|
| 431 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 432 |
+
"""Delete a source."""
|
| 433 |
+
return self._make_request("DELETE", f"/api/sources/{source_id}")
|
| 434 |
+
|
| 435 |
+
# Insights API methods
|
| 436 |
+
def get_source_insights(self, source_id: str) -> List[Dict[Any, Any]]:
|
| 437 |
+
"""Get all insights for a specific source."""
|
| 438 |
+
result = self._make_request("GET", f"/api/sources/{source_id}/insights")
|
| 439 |
+
return result if isinstance(result, list) else [result]
|
| 440 |
+
|
| 441 |
+
def get_insight(
|
| 442 |
+
self, insight_id: str
|
| 443 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 444 |
+
"""Get a specific insight."""
|
| 445 |
+
return self._make_request("GET", f"/api/insights/{insight_id}")
|
| 446 |
+
|
| 447 |
+
def delete_insight(
|
| 448 |
+
self, insight_id: str
|
| 449 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 450 |
+
"""Delete a specific insight."""
|
| 451 |
+
return self._make_request("DELETE", f"/api/insights/{insight_id}")
|
| 452 |
+
|
| 453 |
+
def save_insight_as_note(
|
| 454 |
+
self, insight_id: str, notebook_id: Optional[str] = None
|
| 455 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 456 |
+
"""Convert an insight to a note."""
|
| 457 |
+
data = {}
|
| 458 |
+
if notebook_id:
|
| 459 |
+
data["notebook_id"] = notebook_id
|
| 460 |
+
return self._make_request(
|
| 461 |
+
"POST", f"/api/insights/{insight_id}/save-as-note", json=data
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
def create_source_insight(
|
| 465 |
+
self, source_id: str, transformation_id: str, model_id: Optional[str] = None
|
| 466 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 467 |
+
"""Create a new insight for a source by running a transformation."""
|
| 468 |
+
data = {"transformation_id": transformation_id}
|
| 469 |
+
if model_id:
|
| 470 |
+
data["model_id"] = model_id
|
| 471 |
+
return self._make_request(
|
| 472 |
+
"POST", f"/api/sources/{source_id}/insights", json=data
|
| 473 |
+
)
|
| 474 |
+
|
| 475 |
+
# Episode Profiles API methods
|
| 476 |
+
def get_episode_profiles(self) -> List[Dict[Any, Any]]:
|
| 477 |
+
"""Get all episode profiles."""
|
| 478 |
+
result = self._make_request("GET", "/api/episode-profiles")
|
| 479 |
+
return result if isinstance(result, list) else [result]
|
| 480 |
+
|
| 481 |
+
def get_episode_profile(
|
| 482 |
+
self, profile_name: str
|
| 483 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 484 |
+
"""Get a specific episode profile by name."""
|
| 485 |
+
return self._make_request("GET", f"/api/episode-profiles/{profile_name}")
|
| 486 |
+
|
| 487 |
+
def create_episode_profile(
|
| 488 |
+
self,
|
| 489 |
+
name: str,
|
| 490 |
+
description: str = "",
|
| 491 |
+
speaker_config: str = "",
|
| 492 |
+
outline_provider: str = "",
|
| 493 |
+
outline_model: str = "",
|
| 494 |
+
transcript_provider: str = "",
|
| 495 |
+
transcript_model: str = "",
|
| 496 |
+
default_briefing: str = "",
|
| 497 |
+
num_segments: int = 5,
|
| 498 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 499 |
+
"""Create a new episode profile."""
|
| 500 |
+
data = {
|
| 501 |
+
"name": name,
|
| 502 |
+
"description": description,
|
| 503 |
+
"speaker_config": speaker_config,
|
| 504 |
+
"outline_provider": outline_provider,
|
| 505 |
+
"outline_model": outline_model,
|
| 506 |
+
"transcript_provider": transcript_provider,
|
| 507 |
+
"transcript_model": transcript_model,
|
| 508 |
+
"default_briefing": default_briefing,
|
| 509 |
+
"num_segments": num_segments,
|
| 510 |
+
}
|
| 511 |
+
return self._make_request("POST", "/api/episode-profiles", json=data)
|
| 512 |
+
|
| 513 |
+
def update_episode_profile(
|
| 514 |
+
self, profile_id: str, **updates
|
| 515 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 516 |
+
"""Update an episode profile."""
|
| 517 |
+
return self._make_request(
|
| 518 |
+
"PUT", f"/api/episode-profiles/{profile_id}", json=updates
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
def delete_episode_profile(
|
| 522 |
+
self, profile_id: str
|
| 523 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 524 |
+
"""Delete an episode profile."""
|
| 525 |
+
return self._make_request("DELETE", f"/api/episode-profiles/{profile_id}")
|
| 526 |
+
|
| 527 |
+
|
| 528 |
+
# Global client instance
|
| 529 |
+
api_client = APIClient()
|
api/command_service.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional
|
| 2 |
+
|
| 3 |
+
from loguru import logger
|
| 4 |
+
from surreal_commands import get_command_status, submit_command
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class CommandService:
|
| 8 |
+
"""Generic service layer for command operations"""
|
| 9 |
+
|
| 10 |
+
@staticmethod
|
| 11 |
+
async def submit_command_job(
|
| 12 |
+
module_name: str, # Actually app_name for surreal-commands
|
| 13 |
+
command_name: str,
|
| 14 |
+
command_args: Dict[str, Any],
|
| 15 |
+
context: Optional[Dict[str, Any]] = None,
|
| 16 |
+
) -> str:
|
| 17 |
+
"""Submit a generic command job for background processing"""
|
| 18 |
+
try:
|
| 19 |
+
# Ensure command modules are imported before submitting
|
| 20 |
+
# This is needed because submit_command validates against local registry
|
| 21 |
+
try:
|
| 22 |
+
import commands.podcast_commands # noqa: F401
|
| 23 |
+
except ImportError as import_err:
|
| 24 |
+
logger.error(f"Failed to import command modules: {import_err}")
|
| 25 |
+
raise ValueError("Command modules not available")
|
| 26 |
+
|
| 27 |
+
# surreal-commands expects: submit_command(app_name, command_name, args)
|
| 28 |
+
cmd_id = submit_command(
|
| 29 |
+
module_name, # This is actually the app name (e.g., "open_notebook")
|
| 30 |
+
command_name, # Command name (e.g., "process_text")
|
| 31 |
+
command_args, # Input data
|
| 32 |
+
)
|
| 33 |
+
# Convert RecordID to string if needed
|
| 34 |
+
if not cmd_id:
|
| 35 |
+
raise ValueError("Failed to get cmd_id from submit_command")
|
| 36 |
+
cmd_id_str = str(cmd_id)
|
| 37 |
+
logger.info(
|
| 38 |
+
f"Submitted command job: {cmd_id_str} for {module_name}.{command_name}"
|
| 39 |
+
)
|
| 40 |
+
return cmd_id_str
|
| 41 |
+
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Failed to submit command job: {e}")
|
| 44 |
+
raise
|
| 45 |
+
|
| 46 |
+
@staticmethod
|
| 47 |
+
async def get_command_status(job_id: str) -> Dict[str, Any]:
|
| 48 |
+
"""Get status of any command job"""
|
| 49 |
+
try:
|
| 50 |
+
status = await get_command_status(job_id)
|
| 51 |
+
return {
|
| 52 |
+
"job_id": job_id,
|
| 53 |
+
"status": status.status if status else "unknown",
|
| 54 |
+
"result": status.result if status else None,
|
| 55 |
+
"error_message": getattr(status, "error_message", None)
|
| 56 |
+
if status
|
| 57 |
+
else None,
|
| 58 |
+
"created": str(status.created)
|
| 59 |
+
if status and hasattr(status, "created") and status.created
|
| 60 |
+
else None,
|
| 61 |
+
"updated": str(status.updated)
|
| 62 |
+
if status and hasattr(status, "updated") and status.updated
|
| 63 |
+
else None,
|
| 64 |
+
"progress": getattr(status, "progress", None) if status else None,
|
| 65 |
+
}
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Failed to get command status: {e}")
|
| 68 |
+
raise
|
| 69 |
+
|
| 70 |
+
@staticmethod
|
| 71 |
+
async def list_command_jobs(
|
| 72 |
+
module_filter: Optional[str] = None,
|
| 73 |
+
command_filter: Optional[str] = None,
|
| 74 |
+
status_filter: Optional[str] = None,
|
| 75 |
+
limit: int = 50,
|
| 76 |
+
) -> List[Dict[str, Any]]:
|
| 77 |
+
"""List command jobs with optional filtering"""
|
| 78 |
+
# This will be implemented with proper SurrealDB queries
|
| 79 |
+
# For now, return empty list as this is foundation phase
|
| 80 |
+
return []
|
| 81 |
+
|
| 82 |
+
@staticmethod
|
| 83 |
+
async def cancel_command_job(job_id: str) -> bool:
|
| 84 |
+
"""Cancel a running command job"""
|
| 85 |
+
try:
|
| 86 |
+
# Implementation depends on surreal-commands cancellation support
|
| 87 |
+
# For now, just log the attempt
|
| 88 |
+
logger.info(f"Attempting to cancel job: {job_id}")
|
| 89 |
+
return True
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Failed to cancel command job: {e}")
|
| 92 |
+
raise
|
api/context_service.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Context service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Any, Dict, List, Optional, Union
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ContextService:
|
| 13 |
+
"""Service layer for context operations using API."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
logger.info("Using API for context operations")
|
| 17 |
+
|
| 18 |
+
def get_notebook_context(
|
| 19 |
+
self, notebook_id: str, context_config: Optional[Dict] = None
|
| 20 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 21 |
+
"""Get context for a notebook."""
|
| 22 |
+
result = api_client.get_notebook_context(
|
| 23 |
+
notebook_id=notebook_id, context_config=context_config
|
| 24 |
+
)
|
| 25 |
+
return result
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# Global service instance
|
| 29 |
+
context_service = ContextService()
|
api/credentials_service.py
ADDED
|
@@ -0,0 +1,915 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Credentials Service
|
| 3 |
+
|
| 4 |
+
Business logic for managing AI provider credentials.
|
| 5 |
+
Extracted from the credentials router to follow the service layer pattern.
|
| 6 |
+
|
| 7 |
+
All functions raise ValueError for business errors (router converts to HTTPException).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import ipaddress
|
| 11 |
+
import os
|
| 12 |
+
import socket
|
| 13 |
+
from typing import Dict, List, Optional
|
| 14 |
+
from urllib.parse import urlparse
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
from loguru import logger
|
| 18 |
+
from pydantic import SecretStr
|
| 19 |
+
|
| 20 |
+
from api.models import CredentialResponse
|
| 21 |
+
from open_notebook.ai.model_discovery import classify_model_type
|
| 22 |
+
from open_notebook.domain.credential import Credential
|
| 23 |
+
from open_notebook.utils.encryption import get_secret_from_env
|
| 24 |
+
|
| 25 |
+
# =============================================================================
|
| 26 |
+
# Constants
|
| 27 |
+
# =============================================================================
|
| 28 |
+
|
| 29 |
+
# Provider environment variable configuration.
|
| 30 |
+
# - "required": ALL listed env vars must be set for the provider to be considered configured.
|
| 31 |
+
# - "required_any": at least ONE of the listed env vars must be set.
|
| 32 |
+
# - "optional": additional env vars used during migration but not required.
|
| 33 |
+
PROVIDER_ENV_CONFIG: Dict[str, dict] = {
|
| 34 |
+
"openai": {"required": ["OPENAI_API_KEY"]},
|
| 35 |
+
"anthropic": {"required": ["ANTHROPIC_API_KEY"]},
|
| 36 |
+
"google": {"required_any": ["GOOGLE_API_KEY", "GEMINI_API_KEY"]},
|
| 37 |
+
"groq": {"required": ["GROQ_API_KEY"]},
|
| 38 |
+
"mistral": {"required": ["MISTRAL_API_KEY"]},
|
| 39 |
+
"deepseek": {"required": ["DEEPSEEK_API_KEY"]},
|
| 40 |
+
"xai": {"required": ["XAI_API_KEY"]},
|
| 41 |
+
"openrouter": {"required": ["OPENROUTER_API_KEY"]},
|
| 42 |
+
"voyage": {"required": ["VOYAGE_API_KEY"]},
|
| 43 |
+
"elevenlabs": {"required": ["ELEVENLABS_API_KEY"]},
|
| 44 |
+
"deepgram": {"required": ["DEEPGRAM_API_KEY"]},
|
| 45 |
+
"ollama": {"required": ["OLLAMA_API_BASE"]},
|
| 46 |
+
"vertex": {
|
| 47 |
+
"required": ["VERTEX_PROJECT", "VERTEX_LOCATION"],
|
| 48 |
+
"optional": ["GOOGLE_APPLICATION_CREDENTIALS"],
|
| 49 |
+
},
|
| 50 |
+
"azure": {
|
| 51 |
+
"required": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION"],
|
| 52 |
+
"optional": [
|
| 53 |
+
"AZURE_OPENAI_ENDPOINT_LLM",
|
| 54 |
+
"AZURE_OPENAI_ENDPOINT_EMBEDDING",
|
| 55 |
+
"AZURE_OPENAI_ENDPOINT_STT",
|
| 56 |
+
"AZURE_OPENAI_ENDPOINT_TTS",
|
| 57 |
+
],
|
| 58 |
+
},
|
| 59 |
+
"openai_compatible": {
|
| 60 |
+
"required_any": ["OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"],
|
| 61 |
+
},
|
| 62 |
+
"dashscope": {"required": ["DASHSCOPE_API_KEY"]},
|
| 63 |
+
"minimax": {"required": ["MINIMAX_API_KEY"]},
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
PROVIDER_MODALITIES: Dict[str, List[str]] = {
|
| 67 |
+
"openai": ["language", "embedding", "speech_to_text", "text_to_speech"],
|
| 68 |
+
"anthropic": ["language"],
|
| 69 |
+
"google": ["language", "embedding", "speech_to_text", "text_to_speech"],
|
| 70 |
+
"groq": ["language", "speech_to_text"],
|
| 71 |
+
"mistral": ["language", "embedding", "speech_to_text", "text_to_speech"],
|
| 72 |
+
"deepseek": ["language"],
|
| 73 |
+
"xai": ["language", "text_to_speech"],
|
| 74 |
+
"openrouter": ["language", "embedding"],
|
| 75 |
+
"voyage": ["embedding"],
|
| 76 |
+
"elevenlabs": ["text_to_speech", "speech_to_text"],
|
| 77 |
+
"deepgram": ["text_to_speech"],
|
| 78 |
+
"ollama": ["language", "embedding"],
|
| 79 |
+
"vertex": ["language", "embedding", "text_to_speech"],
|
| 80 |
+
"azure": ["language", "embedding", "speech_to_text", "text_to_speech"],
|
| 81 |
+
"openai_compatible": ["language", "embedding", "speech_to_text", "text_to_speech"],
|
| 82 |
+
"dashscope": ["language"],
|
| 83 |
+
"minimax": ["language"],
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# =============================================================================
|
| 88 |
+
# URL Validation (SSRF protection)
|
| 89 |
+
# =============================================================================
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def validate_url(url: str, provider: str) -> None:
|
| 93 |
+
"""
|
| 94 |
+
Validate URL format for API endpoints.
|
| 95 |
+
|
| 96 |
+
This is a self-hosted application, so we allow:
|
| 97 |
+
- Private IPs (10.x, 172.16-31.x, 192.168.x) for self-hosted services
|
| 98 |
+
- Localhost for local services (Ollama, LM Studio, etc.)
|
| 99 |
+
|
| 100 |
+
We only block:
|
| 101 |
+
- Invalid schemes (must be http or https)
|
| 102 |
+
- Malformed URLs
|
| 103 |
+
- Link-local addresses (169.254.x.x) - used for cloud metadata endpoints
|
| 104 |
+
- Hostnames that resolve to link-local addresses
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
url: The URL to validate
|
| 108 |
+
provider: The provider name (for logging/context)
|
| 109 |
+
|
| 110 |
+
Raises:
|
| 111 |
+
ValueError: If the URL is invalid
|
| 112 |
+
"""
|
| 113 |
+
if not url or not url.strip():
|
| 114 |
+
return # Empty URLs handled elsewhere
|
| 115 |
+
|
| 116 |
+
try:
|
| 117 |
+
parsed = urlparse(url.strip())
|
| 118 |
+
|
| 119 |
+
# Validate scheme - only http/https allowed
|
| 120 |
+
if parsed.scheme not in ("http", "https"):
|
| 121 |
+
raise ValueError(
|
| 122 |
+
f"Invalid URL scheme: '{parsed.scheme}'. Only http and https are allowed."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Extract hostname
|
| 126 |
+
hostname = parsed.hostname
|
| 127 |
+
if not hostname:
|
| 128 |
+
raise ValueError("Invalid URL: hostname could not be determined.")
|
| 129 |
+
|
| 130 |
+
# Try to parse as IP address to check for dangerous addresses
|
| 131 |
+
try:
|
| 132 |
+
ip = ipaddress.ip_address(hostname)
|
| 133 |
+
|
| 134 |
+
# Block link-local addresses (169.254.x.x) - used for cloud metadata
|
| 135 |
+
# These are dangerous as they can expose cloud instance credentials
|
| 136 |
+
if ip.is_link_local:
|
| 137 |
+
raise ValueError(
|
| 138 |
+
"Link-local addresses (169.254.x.x) are not allowed for security reasons. "
|
| 139 |
+
"These addresses are used for cloud metadata endpoints."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# Block IPv4-mapped IPv6 addresses pointing to link-local
|
| 143 |
+
# e.g. ::ffff:169.254.169.254 bypasses IPv6 is_link_local check
|
| 144 |
+
if hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped and ip.ipv4_mapped.is_link_local:
|
| 145 |
+
raise ValueError(
|
| 146 |
+
"Link-local addresses (169.254.x.x) are not allowed for security reasons. "
|
| 147 |
+
"These addresses are used for cloud metadata endpoints."
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
except ValueError as ve:
|
| 151 |
+
# Re-raise our own ValueErrors
|
| 152 |
+
if "Link-local" in str(ve) or "Invalid URL" in str(ve):
|
| 153 |
+
raise
|
| 154 |
+
# Not an IP address, it's a hostname - need to resolve and check
|
| 155 |
+
try:
|
| 156 |
+
# Resolve hostname to IP address
|
| 157 |
+
resolved_ips = socket.getaddrinfo(hostname, None)
|
| 158 |
+
for family, _, _, _, sockaddr in resolved_ips:
|
| 159 |
+
ip_addr = sockaddr[0]
|
| 160 |
+
try:
|
| 161 |
+
parsed_ip = ipaddress.ip_address(ip_addr)
|
| 162 |
+
if parsed_ip.is_link_local:
|
| 163 |
+
raise ValueError(
|
| 164 |
+
f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
|
| 165 |
+
"These addresses are used for cloud metadata endpoints."
|
| 166 |
+
)
|
| 167 |
+
# Block IPv4-mapped IPv6 addresses pointing to link-local
|
| 168 |
+
if (
|
| 169 |
+
hasattr(parsed_ip, "ipv4_mapped")
|
| 170 |
+
and parsed_ip.ipv4_mapped
|
| 171 |
+
and parsed_ip.ipv4_mapped.is_link_local
|
| 172 |
+
):
|
| 173 |
+
raise ValueError(
|
| 174 |
+
f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
|
| 175 |
+
"These addresses are used for cloud metadata endpoints."
|
| 176 |
+
)
|
| 177 |
+
except ValueError as inner_ve:
|
| 178 |
+
if "link-local" in str(inner_ve).lower() or "Link-local" in str(inner_ve):
|
| 179 |
+
raise
|
| 180 |
+
# Skip non-IP addresses (e.g., IPv6 zones)
|
| 181 |
+
continue
|
| 182 |
+
except socket.gaierror:
|
| 183 |
+
# Could not resolve hostname - allow it since the URL may be
|
| 184 |
+
# valid in the deployment environment (e.g., Azure endpoints,
|
| 185 |
+
# internal DNS names). We only block link-local addresses.
|
| 186 |
+
pass
|
| 187 |
+
|
| 188 |
+
except ValueError:
|
| 189 |
+
raise
|
| 190 |
+
except Exception:
|
| 191 |
+
raise ValueError("Invalid URL format. Check server logs for details.")
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# =============================================================================
|
| 195 |
+
# Helpers
|
| 196 |
+
# =============================================================================
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def require_encryption_key() -> None:
|
| 200 |
+
"""Raise ValueError if encryption key is not configured."""
|
| 201 |
+
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
|
| 202 |
+
raise ValueError(
|
| 203 |
+
"Encryption key not configured. "
|
| 204 |
+
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to enable storing API keys."
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def credential_to_response(cred: Credential, model_count: int = 0) -> CredentialResponse:
|
| 209 |
+
"""Convert a Credential domain object to API response."""
|
| 210 |
+
return CredentialResponse(
|
| 211 |
+
id=cred.id or "",
|
| 212 |
+
name=cred.name,
|
| 213 |
+
provider=cred.provider,
|
| 214 |
+
modalities=cred.modalities,
|
| 215 |
+
base_url=cred.base_url,
|
| 216 |
+
endpoint=cred.endpoint,
|
| 217 |
+
api_version=cred.api_version,
|
| 218 |
+
endpoint_llm=cred.endpoint_llm,
|
| 219 |
+
endpoint_embedding=cred.endpoint_embedding,
|
| 220 |
+
endpoint_stt=cred.endpoint_stt,
|
| 221 |
+
endpoint_tts=cred.endpoint_tts,
|
| 222 |
+
project=cred.project,
|
| 223 |
+
location=cred.location,
|
| 224 |
+
credentials_path=cred.credentials_path,
|
| 225 |
+
num_ctx=cred.num_ctx,
|
| 226 |
+
has_api_key=cred.api_key is not None,
|
| 227 |
+
created=str(cred.created) if cred.created else "",
|
| 228 |
+
updated=str(cred.updated) if cred.updated else "",
|
| 229 |
+
model_count=model_count,
|
| 230 |
+
decryption_error=cred.decryption_error,
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def check_env_configured(provider: str) -> bool:
|
| 235 |
+
"""Check if a provider has sufficient env vars configured for migration."""
|
| 236 |
+
config = PROVIDER_ENV_CONFIG.get(provider)
|
| 237 |
+
if not config:
|
| 238 |
+
return False
|
| 239 |
+
|
| 240 |
+
if "required_any" in config:
|
| 241 |
+
return any(bool(os.environ.get(v, "").strip()) for v in config["required_any"])
|
| 242 |
+
elif "required" in config:
|
| 243 |
+
return all(bool(os.environ.get(v, "").strip()) for v in config["required"])
|
| 244 |
+
return False
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def get_default_modalities(provider: str) -> List[str]:
|
| 248 |
+
"""Get default modalities for a provider."""
|
| 249 |
+
return PROVIDER_MODALITIES.get(provider.lower(), ["language"])
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
def create_credential_from_env(provider: str) -> Credential:
|
| 253 |
+
"""Create a Credential from environment variables for a given provider."""
|
| 254 |
+
modalities = get_default_modalities(provider)
|
| 255 |
+
name = "Default (Migrated from env)"
|
| 256 |
+
|
| 257 |
+
if provider == "ollama":
|
| 258 |
+
return Credential(
|
| 259 |
+
name=name,
|
| 260 |
+
provider=provider,
|
| 261 |
+
modalities=modalities,
|
| 262 |
+
base_url=os.environ.get("OLLAMA_API_BASE"),
|
| 263 |
+
)
|
| 264 |
+
elif provider == "vertex":
|
| 265 |
+
return Credential(
|
| 266 |
+
name=name,
|
| 267 |
+
provider=provider,
|
| 268 |
+
modalities=modalities,
|
| 269 |
+
project=os.environ.get("VERTEX_PROJECT"),
|
| 270 |
+
location=os.environ.get("VERTEX_LOCATION"),
|
| 271 |
+
credentials_path=os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"),
|
| 272 |
+
)
|
| 273 |
+
elif provider == "azure":
|
| 274 |
+
return Credential(
|
| 275 |
+
name=name,
|
| 276 |
+
provider=provider,
|
| 277 |
+
modalities=modalities,
|
| 278 |
+
api_key=SecretStr(os.environ["AZURE_OPENAI_API_KEY"]),
|
| 279 |
+
endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
|
| 280 |
+
api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
|
| 281 |
+
endpoint_llm=os.environ.get("AZURE_OPENAI_ENDPOINT_LLM"),
|
| 282 |
+
endpoint_embedding=os.environ.get("AZURE_OPENAI_ENDPOINT_EMBEDDING"),
|
| 283 |
+
endpoint_stt=os.environ.get("AZURE_OPENAI_ENDPOINT_STT"),
|
| 284 |
+
endpoint_tts=os.environ.get("AZURE_OPENAI_ENDPOINT_TTS"),
|
| 285 |
+
)
|
| 286 |
+
elif provider == "openai_compatible":
|
| 287 |
+
api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY")
|
| 288 |
+
return Credential(
|
| 289 |
+
name=name,
|
| 290 |
+
provider=provider,
|
| 291 |
+
modalities=modalities,
|
| 292 |
+
api_key=SecretStr(api_key) if api_key else None,
|
| 293 |
+
base_url=os.environ.get("OPENAI_COMPATIBLE_BASE_URL"),
|
| 294 |
+
)
|
| 295 |
+
elif provider == "google":
|
| 296 |
+
# Support both GOOGLE_API_KEY and GEMINI_API_KEY (fallback)
|
| 297 |
+
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
|
| 298 |
+
return Credential(
|
| 299 |
+
name=name,
|
| 300 |
+
provider=provider,
|
| 301 |
+
modalities=modalities,
|
| 302 |
+
api_key=SecretStr(api_key) if api_key else None,
|
| 303 |
+
)
|
| 304 |
+
else:
|
| 305 |
+
# Simple API key providers
|
| 306 |
+
config = PROVIDER_ENV_CONFIG.get(provider, {})
|
| 307 |
+
required = config.get("required", [])
|
| 308 |
+
env_var = required[0] if required else None
|
| 309 |
+
api_key = os.environ.get(env_var) if env_var else None
|
| 310 |
+
return Credential(
|
| 311 |
+
name=name,
|
| 312 |
+
provider=provider,
|
| 313 |
+
modalities=modalities,
|
| 314 |
+
api_key=SecretStr(api_key) if api_key else None,
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
# =============================================================================
|
| 319 |
+
# Service Functions
|
| 320 |
+
# =============================================================================
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
async def get_provider_status() -> dict:
|
| 324 |
+
"""
|
| 325 |
+
Get configuration status: encryption key status, and per-provider
|
| 326 |
+
configured/source information.
|
| 327 |
+
"""
|
| 328 |
+
encryption_configured = bool(get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"))
|
| 329 |
+
|
| 330 |
+
configured: Dict[str, bool] = {}
|
| 331 |
+
source: Dict[str, str] = {}
|
| 332 |
+
|
| 333 |
+
for provider in PROVIDER_ENV_CONFIG:
|
| 334 |
+
env_configured = check_env_configured(provider)
|
| 335 |
+
try:
|
| 336 |
+
db_credentials = await Credential.get_by_provider(provider)
|
| 337 |
+
db_configured = len(db_credentials) > 0
|
| 338 |
+
except Exception:
|
| 339 |
+
db_configured = False
|
| 340 |
+
|
| 341 |
+
configured[provider] = db_configured or env_configured
|
| 342 |
+
|
| 343 |
+
if db_configured:
|
| 344 |
+
source[provider] = "database"
|
| 345 |
+
elif env_configured:
|
| 346 |
+
source[provider] = "environment"
|
| 347 |
+
else:
|
| 348 |
+
source[provider] = "none"
|
| 349 |
+
|
| 350 |
+
return {
|
| 351 |
+
"configured": configured,
|
| 352 |
+
"source": source,
|
| 353 |
+
"encryption_configured": encryption_configured,
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def get_env_status() -> Dict[str, bool]:
|
| 358 |
+
"""Check what's configured via environment variables."""
|
| 359 |
+
env_status: Dict[str, bool] = {}
|
| 360 |
+
for provider in PROVIDER_ENV_CONFIG:
|
| 361 |
+
env_status[provider] = check_env_configured(provider)
|
| 362 |
+
return env_status
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
async def test_credential(credential_id: str) -> dict:
|
| 366 |
+
"""
|
| 367 |
+
Test connection using a credential's configuration.
|
| 368 |
+
|
| 369 |
+
Returns dict with provider, success, message keys.
|
| 370 |
+
"""
|
| 371 |
+
provider = "unknown"
|
| 372 |
+
try:
|
| 373 |
+
cred = await Credential.get(credential_id)
|
| 374 |
+
config = cred.to_esperanto_config()
|
| 375 |
+
|
| 376 |
+
from open_notebook.ai.connection_tester import (
|
| 377 |
+
_test_azure_connection,
|
| 378 |
+
_test_ollama_connection,
|
| 379 |
+
_test_openai_compatible_connection,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
provider = cred.provider.lower()
|
| 383 |
+
|
| 384 |
+
# Handle special providers
|
| 385 |
+
if provider == "ollama":
|
| 386 |
+
base_url = config.get("base_url", "http://localhost:11434")
|
| 387 |
+
success, message = await _test_ollama_connection(base_url)
|
| 388 |
+
return {"provider": provider, "success": success, "message": message}
|
| 389 |
+
|
| 390 |
+
if provider == "openai_compatible":
|
| 391 |
+
base_url = config.get("base_url")
|
| 392 |
+
api_key = config.get("api_key")
|
| 393 |
+
if not base_url:
|
| 394 |
+
return {
|
| 395 |
+
"provider": provider,
|
| 396 |
+
"success": False,
|
| 397 |
+
"message": "No base URL configured",
|
| 398 |
+
}
|
| 399 |
+
success, message = await _test_openai_compatible_connection(
|
| 400 |
+
base_url, api_key
|
| 401 |
+
)
|
| 402 |
+
return {"provider": provider, "success": success, "message": message}
|
| 403 |
+
|
| 404 |
+
if provider == "azure":
|
| 405 |
+
success, message = await _test_azure_connection(
|
| 406 |
+
endpoint=config.get("endpoint"),
|
| 407 |
+
api_key=config.get("api_key"),
|
| 408 |
+
api_version=config.get("api_version"),
|
| 409 |
+
)
|
| 410 |
+
return {"provider": provider, "success": success, "message": message}
|
| 411 |
+
|
| 412 |
+
# Standard provider: use Esperanto to create and test
|
| 413 |
+
from esperanto.factory import AIFactory
|
| 414 |
+
|
| 415 |
+
from open_notebook.ai.connection_tester import TEST_MODELS
|
| 416 |
+
|
| 417 |
+
if provider not in TEST_MODELS:
|
| 418 |
+
return {
|
| 419 |
+
"provider": provider,
|
| 420 |
+
"success": False,
|
| 421 |
+
"message": f"Unknown provider: {provider}",
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
test_model, test_type = TEST_MODELS[provider]
|
| 425 |
+
if not test_model:
|
| 426 |
+
return {
|
| 427 |
+
"provider": provider,
|
| 428 |
+
"success": False,
|
| 429 |
+
"message": f"No test model configured for {provider}",
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
if test_type == "language":
|
| 433 |
+
model = AIFactory.create_language(
|
| 434 |
+
model_name=test_model, provider=provider, config=config
|
| 435 |
+
)
|
| 436 |
+
lc_model = model.to_langchain()
|
| 437 |
+
await lc_model.ainvoke("Hi")
|
| 438 |
+
return {"provider": provider, "success": True, "message": "Connection successful"}
|
| 439 |
+
|
| 440 |
+
elif test_type == "embedding":
|
| 441 |
+
model = AIFactory.create_embedding(
|
| 442 |
+
model_name=test_model, provider=provider, config=config
|
| 443 |
+
)
|
| 444 |
+
await model.aembed(["test"])
|
| 445 |
+
return {"provider": provider, "success": True, "message": "Connection successful"}
|
| 446 |
+
|
| 447 |
+
elif test_type == "text_to_speech":
|
| 448 |
+
AIFactory.create_text_to_speech(model_name=test_model, provider=provider, config=config)
|
| 449 |
+
return {
|
| 450 |
+
"provider": provider,
|
| 451 |
+
"success": True,
|
| 452 |
+
"message": "Connection successful (key format valid)",
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
return {
|
| 456 |
+
"provider": provider,
|
| 457 |
+
"success": False,
|
| 458 |
+
"message": f"Unsupported test type: {test_type}",
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
except Exception as e:
|
| 462 |
+
error_msg = str(e)
|
| 463 |
+
if "401" in error_msg or "unauthorized" in error_msg.lower():
|
| 464 |
+
return {"provider": provider, "success": False, "message": "Invalid API key"}
|
| 465 |
+
elif "403" in error_msg or "forbidden" in error_msg.lower():
|
| 466 |
+
return {"provider": provider, "success": False, "message": "API key lacks required permissions"}
|
| 467 |
+
elif "rate" in error_msg.lower() and "limit" in error_msg.lower():
|
| 468 |
+
return {"provider": provider, "success": True, "message": "Rate limited - but connection works"}
|
| 469 |
+
elif "not found" in error_msg.lower() and "model" in error_msg.lower():
|
| 470 |
+
return {"provider": provider, "success": True, "message": "API key valid (test model not available)"}
|
| 471 |
+
else:
|
| 472 |
+
logger.debug(f"Test connection error for credential {credential_id}: {e}")
|
| 473 |
+
truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg
|
| 474 |
+
return {"provider": provider, "success": False, "message": f"Error: {truncated}"}
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
async def discover_with_config(provider: str, config: dict) -> List[dict]:
|
| 478 |
+
"""
|
| 479 |
+
Discover models using explicit config instead of env vars.
|
| 480 |
+
|
| 481 |
+
Returns model names only — no type classification.
|
| 482 |
+
The user chooses the model type when registering.
|
| 483 |
+
"""
|
| 484 |
+
api_key = config.get("api_key")
|
| 485 |
+
base_url = config.get("base_url")
|
| 486 |
+
|
| 487 |
+
def models_endpoint(url: str) -> str:
|
| 488 |
+
trimmed = url.rstrip("/")
|
| 489 |
+
if trimmed.endswith("/models"):
|
| 490 |
+
return trimmed
|
| 491 |
+
return f"{trimmed}/models"
|
| 492 |
+
|
| 493 |
+
# Static model lists for providers without a listing API
|
| 494 |
+
STATIC_MODELS: Dict[str, List[str]] = {
|
| 495 |
+
"anthropic": [
|
| 496 |
+
"claude-opus-4-20250514",
|
| 497 |
+
"claude-sonnet-4-20250514",
|
| 498 |
+
"claude-3-5-sonnet-20241022",
|
| 499 |
+
"claude-3-5-haiku-20241022",
|
| 500 |
+
"claude-3-opus-20240229",
|
| 501 |
+
"claude-3-sonnet-20240229",
|
| 502 |
+
"claude-3-haiku-20240307",
|
| 503 |
+
],
|
| 504 |
+
"voyage": [
|
| 505 |
+
"voyage-3", "voyage-3-lite", "voyage-code-3",
|
| 506 |
+
"voyage-finance-2", "voyage-law-2", "voyage-multilingual-2",
|
| 507 |
+
],
|
| 508 |
+
"elevenlabs": [
|
| 509 |
+
"eleven_multilingual_v2", "eleven_turbo_v2_5",
|
| 510 |
+
"eleven_turbo_v2", "eleven_monolingual_v1",
|
| 511 |
+
"scribe_v1", # speech-to-text
|
| 512 |
+
],
|
| 513 |
+
"deepgram": [
|
| 514 |
+
"aura-2-thalia-en", "aura-2-andromeda-en", "aura-2-helena-en",
|
| 515 |
+
"aura-2-apollo-en", "aura-2-arcas-en", "aura-2-asteria-en",
|
| 516 |
+
"aura-2-athena-en", "aura-2-hera-en", "aura-2-hermes-en",
|
| 517 |
+
"aura-2-atlas-en",
|
| 518 |
+
],
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
if provider in STATIC_MODELS:
|
| 522 |
+
if not api_key and provider != "ollama":
|
| 523 |
+
return []
|
| 524 |
+
return [
|
| 525 |
+
{"name": m, "provider": provider}
|
| 526 |
+
for m in STATIC_MODELS[provider]
|
| 527 |
+
]
|
| 528 |
+
|
| 529 |
+
# API-based discovery URLs (OpenAI-style /models endpoints)
|
| 530 |
+
url_map = {
|
| 531 |
+
"openai": "https://api.openai.com/v1/models",
|
| 532 |
+
"groq": "https://api.groq.com/openai/v1/models",
|
| 533 |
+
"mistral": "https://api.mistral.ai/v1/models",
|
| 534 |
+
"deepseek": "https://api.deepseek.com/models",
|
| 535 |
+
"xai": "https://api.x.ai/v1/models",
|
| 536 |
+
"openrouter": "https://openrouter.ai/api/v1/models",
|
| 537 |
+
"dashscope": "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
|
| 538 |
+
"minimax": "https://api.minimax.io/v1/models",
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
if provider == "ollama":
|
| 542 |
+
ollama_url = base_url or "http://localhost:11434"
|
| 543 |
+
try:
|
| 544 |
+
async with httpx.AsyncClient() as client:
|
| 545 |
+
response = await client.get(f"{ollama_url}/api/tags", timeout=10.0)
|
| 546 |
+
response.raise_for_status()
|
| 547 |
+
data = response.json()
|
| 548 |
+
return [
|
| 549 |
+
{
|
| 550 |
+
"name": m.get("name", ""),
|
| 551 |
+
"provider": "ollama",
|
| 552 |
+
"model_type": classify_model_type(m.get("name", ""), "ollama"),
|
| 553 |
+
}
|
| 554 |
+
for m in data.get("models", [])
|
| 555 |
+
if m.get("name")
|
| 556 |
+
]
|
| 557 |
+
except Exception as e:
|
| 558 |
+
logger.warning(f"Failed to discover Ollama models: {e}")
|
| 559 |
+
return []
|
| 560 |
+
|
| 561 |
+
if provider == "openai_compatible":
|
| 562 |
+
if not base_url:
|
| 563 |
+
return []
|
| 564 |
+
try:
|
| 565 |
+
headers = {}
|
| 566 |
+
if api_key:
|
| 567 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 568 |
+
async with httpx.AsyncClient() as client:
|
| 569 |
+
response = await client.get(
|
| 570 |
+
models_endpoint(base_url),
|
| 571 |
+
headers=headers,
|
| 572 |
+
timeout=30.0,
|
| 573 |
+
)
|
| 574 |
+
response.raise_for_status()
|
| 575 |
+
data = response.json()
|
| 576 |
+
return [
|
| 577 |
+
{"name": m.get("id", ""), "provider": "openai_compatible"}
|
| 578 |
+
for m in data.get("data", [])
|
| 579 |
+
if m.get("id")
|
| 580 |
+
]
|
| 581 |
+
except Exception as e:
|
| 582 |
+
logger.warning(f"Failed to discover openai_compatible models: {e}")
|
| 583 |
+
return []
|
| 584 |
+
|
| 585 |
+
if provider == "azure":
|
| 586 |
+
endpoint = config.get("endpoint")
|
| 587 |
+
api_version = config.get("api_version", "2024-10-21")
|
| 588 |
+
if not endpoint or not api_key:
|
| 589 |
+
return []
|
| 590 |
+
try:
|
| 591 |
+
url = f"{endpoint.rstrip('/')}/openai/models?api-version={api_version}"
|
| 592 |
+
headers = {"api-key": api_key}
|
| 593 |
+
async with httpx.AsyncClient() as client:
|
| 594 |
+
response = await client.get(url, headers=headers, timeout=30.0)
|
| 595 |
+
response.raise_for_status()
|
| 596 |
+
data = response.json()
|
| 597 |
+
return [
|
| 598 |
+
{"name": m.get("id", ""), "provider": "azure"}
|
| 599 |
+
for m in data.get("data", [])
|
| 600 |
+
if m.get("id")
|
| 601 |
+
]
|
| 602 |
+
except Exception as e:
|
| 603 |
+
logger.warning(f"Failed to discover Azure models: {e}")
|
| 604 |
+
return []
|
| 605 |
+
|
| 606 |
+
if provider == "vertex":
|
| 607 |
+
# Vertex AI requires service-account OAuth2 for model listing.
|
| 608 |
+
# Return a curated static list of well-known Vertex models instead.
|
| 609 |
+
VERTEX_MODELS = [
|
| 610 |
+
"gemini-2.0-flash",
|
| 611 |
+
"gemini-2.0-flash-lite",
|
| 612 |
+
"gemini-1.5-pro",
|
| 613 |
+
"gemini-1.5-flash",
|
| 614 |
+
"text-embedding-005",
|
| 615 |
+
]
|
| 616 |
+
return [{"name": m, "provider": "vertex"} for m in VERTEX_MODELS]
|
| 617 |
+
|
| 618 |
+
if provider == "google":
|
| 619 |
+
try:
|
| 620 |
+
headers = {"X-Goog-Api-Key": api_key} if api_key else {}
|
| 621 |
+
async with httpx.AsyncClient() as client:
|
| 622 |
+
response = await client.get(
|
| 623 |
+
"https://generativelanguage.googleapis.com/v1/models",
|
| 624 |
+
headers=headers,
|
| 625 |
+
timeout=30.0,
|
| 626 |
+
)
|
| 627 |
+
response.raise_for_status()
|
| 628 |
+
data = response.json()
|
| 629 |
+
return [
|
| 630 |
+
{
|
| 631 |
+
"name": model.get("name", "").replace("models/", ""),
|
| 632 |
+
"provider": "google",
|
| 633 |
+
"description": model.get("displayName"),
|
| 634 |
+
}
|
| 635 |
+
for model in data.get("models", [])
|
| 636 |
+
if model.get("name")
|
| 637 |
+
]
|
| 638 |
+
except Exception as e:
|
| 639 |
+
logger.warning(f"Failed to discover Google models: {e}")
|
| 640 |
+
return []
|
| 641 |
+
|
| 642 |
+
# Standard OpenAI-style API discovery
|
| 643 |
+
discovery_url = url_map.get(provider)
|
| 644 |
+
if provider == "openai" and base_url:
|
| 645 |
+
discovery_url = models_endpoint(base_url)
|
| 646 |
+
if not discovery_url or not api_key:
|
| 647 |
+
return []
|
| 648 |
+
|
| 649 |
+
try:
|
| 650 |
+
async with httpx.AsyncClient() as client:
|
| 651 |
+
response = await client.get(
|
| 652 |
+
discovery_url,
|
| 653 |
+
headers={"Authorization": f"Bearer {api_key}"},
|
| 654 |
+
timeout=30.0,
|
| 655 |
+
)
|
| 656 |
+
response.raise_for_status()
|
| 657 |
+
data = response.json()
|
| 658 |
+
|
| 659 |
+
return [
|
| 660 |
+
{
|
| 661 |
+
"name": m.get("id", ""),
|
| 662 |
+
"provider": provider,
|
| 663 |
+
"description": m.get("name"),
|
| 664 |
+
}
|
| 665 |
+
for m in data.get("data", [])
|
| 666 |
+
if m.get("id")
|
| 667 |
+
]
|
| 668 |
+
except Exception as e:
|
| 669 |
+
logger.warning(f"Failed to discover {provider} models: {e}")
|
| 670 |
+
return []
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
async def register_models(credential_id: str, models_data: list) -> dict:
|
| 674 |
+
"""
|
| 675 |
+
Register discovered models and link them to a credential.
|
| 676 |
+
|
| 677 |
+
Args:
|
| 678 |
+
credential_id: The credential ID to link models to
|
| 679 |
+
models_data: List of dicts with name, provider, model_type
|
| 680 |
+
|
| 681 |
+
Returns:
|
| 682 |
+
dict with created and existing counts
|
| 683 |
+
"""
|
| 684 |
+
cred = await Credential.get(credential_id)
|
| 685 |
+
|
| 686 |
+
from open_notebook.ai.models import Model
|
| 687 |
+
from open_notebook.database.repository import repo_query
|
| 688 |
+
|
| 689 |
+
# Batch fetch existing models for this provider
|
| 690 |
+
existing_models = await repo_query(
|
| 691 |
+
"SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model "
|
| 692 |
+
"WHERE string::lowercase(provider) = $provider",
|
| 693 |
+
{"provider": cred.provider.lower()},
|
| 694 |
+
)
|
| 695 |
+
existing_keys = {(m["name"], m["type"]) for m in existing_models}
|
| 696 |
+
|
| 697 |
+
created = 0
|
| 698 |
+
existing = 0
|
| 699 |
+
|
| 700 |
+
for model_data in models_data:
|
| 701 |
+
key = (model_data.name.lower(), model_data.model_type.lower())
|
| 702 |
+
if key in existing_keys:
|
| 703 |
+
existing += 1
|
| 704 |
+
continue
|
| 705 |
+
|
| 706 |
+
new_model = Model(
|
| 707 |
+
name=model_data.name,
|
| 708 |
+
provider=model_data.provider or cred.provider,
|
| 709 |
+
type=model_data.model_type,
|
| 710 |
+
credential=cred.id,
|
| 711 |
+
)
|
| 712 |
+
await new_model.save()
|
| 713 |
+
created += 1
|
| 714 |
+
|
| 715 |
+
return {"created": created, "existing": existing}
|
| 716 |
+
|
| 717 |
+
|
| 718 |
+
async def migrate_from_provider_config() -> dict:
|
| 719 |
+
"""
|
| 720 |
+
Migrate existing ProviderConfig data to individual credential records.
|
| 721 |
+
|
| 722 |
+
Returns dict with message, migrated, skipped, errors.
|
| 723 |
+
"""
|
| 724 |
+
logger.info("=== Starting ProviderConfig migration ===")
|
| 725 |
+
|
| 726 |
+
require_encryption_key()
|
| 727 |
+
logger.info("Encryption key verified")
|
| 728 |
+
|
| 729 |
+
from open_notebook.domain.provider_config import ProviderConfig
|
| 730 |
+
|
| 731 |
+
config = await ProviderConfig.get_instance()
|
| 732 |
+
logger.info(
|
| 733 |
+
f"Found ProviderConfig with {len(config.credentials)} provider(s): "
|
| 734 |
+
f"{', '.join(config.credentials.keys())}"
|
| 735 |
+
)
|
| 736 |
+
|
| 737 |
+
migrated = []
|
| 738 |
+
skipped = []
|
| 739 |
+
errors = []
|
| 740 |
+
|
| 741 |
+
for provider, credentials_list in config.credentials.items():
|
| 742 |
+
for old_cred in credentials_list:
|
| 743 |
+
try:
|
| 744 |
+
# Check if a credential already exists for this provider with same name
|
| 745 |
+
existing = await Credential.get_by_provider(provider)
|
| 746 |
+
names = [c.name for c in existing]
|
| 747 |
+
if old_cred.name in names:
|
| 748 |
+
logger.info(
|
| 749 |
+
f"[{provider}/{old_cred.name}] Already exists in DB, skipping"
|
| 750 |
+
)
|
| 751 |
+
skipped.append(f"{provider}/{old_cred.name}")
|
| 752 |
+
continue
|
| 753 |
+
|
| 754 |
+
# Determine modalities from the provider type
|
| 755 |
+
modalities = get_default_modalities(provider)
|
| 756 |
+
|
| 757 |
+
logger.info(f"[{provider}/{old_cred.name}] Creating credential")
|
| 758 |
+
new_cred = Credential(
|
| 759 |
+
name=old_cred.name,
|
| 760 |
+
provider=provider,
|
| 761 |
+
modalities=modalities,
|
| 762 |
+
api_key=old_cred.api_key,
|
| 763 |
+
base_url=old_cred.base_url,
|
| 764 |
+
endpoint=old_cred.endpoint,
|
| 765 |
+
api_version=old_cred.api_version,
|
| 766 |
+
endpoint_llm=old_cred.endpoint_llm,
|
| 767 |
+
endpoint_embedding=old_cred.endpoint_embedding,
|
| 768 |
+
endpoint_stt=old_cred.endpoint_stt,
|
| 769 |
+
endpoint_tts=old_cred.endpoint_tts,
|
| 770 |
+
project=old_cred.project,
|
| 771 |
+
location=old_cred.location,
|
| 772 |
+
credentials_path=old_cred.credentials_path,
|
| 773 |
+
)
|
| 774 |
+
await new_cred.save()
|
| 775 |
+
logger.info(
|
| 776 |
+
f"[{provider}/{old_cred.name}] Credential saved (id={new_cred.id})"
|
| 777 |
+
)
|
| 778 |
+
|
| 779 |
+
# Link existing models for this provider to the new credential
|
| 780 |
+
from open_notebook.ai.models import Model
|
| 781 |
+
from open_notebook.database.repository import repo_query
|
| 782 |
+
|
| 783 |
+
provider_models = await repo_query(
|
| 784 |
+
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
|
| 785 |
+
{"provider": provider.lower()},
|
| 786 |
+
)
|
| 787 |
+
if provider_models:
|
| 788 |
+
logger.info(
|
| 789 |
+
f"[{provider}/{old_cred.name}] Linking {len(provider_models)} "
|
| 790 |
+
f"unassigned model(s)"
|
| 791 |
+
)
|
| 792 |
+
for model_data in provider_models:
|
| 793 |
+
model = Model(**model_data)
|
| 794 |
+
model.credential = new_cred.id
|
| 795 |
+
await model.save()
|
| 796 |
+
|
| 797 |
+
migrated.append(f"{provider}/{old_cred.name}")
|
| 798 |
+
|
| 799 |
+
except Exception as e:
|
| 800 |
+
logger.error(
|
| 801 |
+
f"[{provider}/{old_cred.name}] Migration FAILED: "
|
| 802 |
+
f"{type(e).__name__}: {e}",
|
| 803 |
+
exc_info=True,
|
| 804 |
+
)
|
| 805 |
+
errors.append(f"{provider}/{old_cred.name}: {e}")
|
| 806 |
+
|
| 807 |
+
logger.info(
|
| 808 |
+
f"=== ProviderConfig migration complete === "
|
| 809 |
+
f"migrated={len(migrated)} skipped={len(skipped)} errors={len(errors)}"
|
| 810 |
+
)
|
| 811 |
+
if migrated:
|
| 812 |
+
logger.info(f" Migrated: {', '.join(migrated)}")
|
| 813 |
+
if skipped:
|
| 814 |
+
logger.info(f" Skipped: {', '.join(skipped)}")
|
| 815 |
+
if errors:
|
| 816 |
+
logger.error(f" Errors: {'; '.join(errors)}")
|
| 817 |
+
|
| 818 |
+
return {
|
| 819 |
+
"message": f"Migration complete. Migrated {len(migrated)} credentials.",
|
| 820 |
+
"migrated": migrated,
|
| 821 |
+
"skipped": skipped,
|
| 822 |
+
"errors": errors,
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
|
| 826 |
+
async def migrate_from_env() -> dict:
|
| 827 |
+
"""
|
| 828 |
+
Migrate API keys from environment variables to credential records.
|
| 829 |
+
|
| 830 |
+
Returns dict with message, migrated, skipped, not_configured, errors.
|
| 831 |
+
"""
|
| 832 |
+
logger.info("=== Starting environment variable migration ===")
|
| 833 |
+
logger.info(
|
| 834 |
+
f"Checking {len(PROVIDER_ENV_CONFIG)} providers: "
|
| 835 |
+
f"{', '.join(PROVIDER_ENV_CONFIG.keys())}"
|
| 836 |
+
)
|
| 837 |
+
|
| 838 |
+
require_encryption_key()
|
| 839 |
+
logger.info("Encryption key verified")
|
| 840 |
+
|
| 841 |
+
from open_notebook.ai.models import Model
|
| 842 |
+
from open_notebook.database.repository import repo_query
|
| 843 |
+
|
| 844 |
+
migrated = []
|
| 845 |
+
skipped = []
|
| 846 |
+
not_configured = []
|
| 847 |
+
errors = []
|
| 848 |
+
|
| 849 |
+
for provider in PROVIDER_ENV_CONFIG:
|
| 850 |
+
try:
|
| 851 |
+
if not check_env_configured(provider):
|
| 852 |
+
logger.debug(f"[{provider}] No env vars configured, skipping")
|
| 853 |
+
not_configured.append(provider)
|
| 854 |
+
continue
|
| 855 |
+
|
| 856 |
+
logger.info(f"[{provider}] Env vars detected, checking for existing credentials")
|
| 857 |
+
|
| 858 |
+
existing = await Credential.get_by_provider(provider)
|
| 859 |
+
if existing:
|
| 860 |
+
logger.info(
|
| 861 |
+
f"[{provider}] Already has {len(existing)} credential(s) in DB, skipping"
|
| 862 |
+
)
|
| 863 |
+
skipped.append(provider)
|
| 864 |
+
continue
|
| 865 |
+
|
| 866 |
+
logger.info(f"[{provider}] Creating credential from env vars")
|
| 867 |
+
cred = create_credential_from_env(provider)
|
| 868 |
+
await cred.save()
|
| 869 |
+
logger.info(f"[{provider}] Credential saved successfully (id={cred.id})")
|
| 870 |
+
|
| 871 |
+
# Link unassigned models to this credential
|
| 872 |
+
provider_models = await repo_query(
|
| 873 |
+
"SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
|
| 874 |
+
{"provider": provider.lower()},
|
| 875 |
+
)
|
| 876 |
+
if provider_models:
|
| 877 |
+
logger.info(
|
| 878 |
+
f"[{provider}] Linking {len(provider_models)} unassigned model(s) "
|
| 879 |
+
f"to credential {cred.id}"
|
| 880 |
+
)
|
| 881 |
+
for model_data in provider_models:
|
| 882 |
+
model = Model(**model_data)
|
| 883 |
+
model.credential = cred.id
|
| 884 |
+
await model.save()
|
| 885 |
+
else:
|
| 886 |
+
logger.info(f"[{provider}] No unassigned models to link")
|
| 887 |
+
|
| 888 |
+
migrated.append(provider)
|
| 889 |
+
|
| 890 |
+
except Exception as e:
|
| 891 |
+
logger.error(
|
| 892 |
+
f"[{provider}] Migration FAILED: {type(e).__name__}: {e}",
|
| 893 |
+
exc_info=True,
|
| 894 |
+
)
|
| 895 |
+
errors.append(f"{provider}: {e}")
|
| 896 |
+
|
| 897 |
+
logger.info(
|
| 898 |
+
f"=== Environment variable migration complete === "
|
| 899 |
+
f"migrated={len(migrated)} skipped={len(skipped)} "
|
| 900 |
+
f"not_configured={len(not_configured)} errors={len(errors)}"
|
| 901 |
+
)
|
| 902 |
+
if migrated:
|
| 903 |
+
logger.info(f" Migrated: {', '.join(migrated)}")
|
| 904 |
+
if skipped:
|
| 905 |
+
logger.info(f" Skipped (already in DB): {', '.join(skipped)}")
|
| 906 |
+
if errors:
|
| 907 |
+
logger.error(f" Errors: {'; '.join(errors)}")
|
| 908 |
+
|
| 909 |
+
return {
|
| 910 |
+
"message": f"Migration complete. Migrated {len(migrated)} providers.",
|
| 911 |
+
"migrated": migrated,
|
| 912 |
+
"skipped": skipped,
|
| 913 |
+
"not_configured": not_configured,
|
| 914 |
+
"errors": errors,
|
| 915 |
+
}
|
api/embedding_service.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Embedding service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import Any, Dict, List, Union
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class EmbeddingService:
|
| 13 |
+
"""Service layer for embedding operations using API."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
logger.info("Using API for embedding operations")
|
| 17 |
+
|
| 18 |
+
def embed_content(
|
| 19 |
+
self, item_id: str, item_type: str
|
| 20 |
+
) -> Union[Dict[Any, Any], List[Dict[Any, Any]]]:
|
| 21 |
+
"""Embed content for vector search."""
|
| 22 |
+
result = api_client.embed_content(item_id=item_id, item_type=item_type)
|
| 23 |
+
return result
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Global service instance
|
| 27 |
+
embedding_service = EmbeddingService()
|
api/episode_profiles_service.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Episode profiles service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
from open_notebook.podcasts.models import EpisodeProfile
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class EpisodeProfilesService:
|
| 14 |
+
"""Service layer for episode profiles operations using API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Using API for episode profiles operations")
|
| 18 |
+
|
| 19 |
+
def get_all_episode_profiles(self) -> List[EpisodeProfile]:
|
| 20 |
+
"""Get all episode profiles."""
|
| 21 |
+
profiles_data = api_client.get_episode_profiles()
|
| 22 |
+
# Convert API response to EpisodeProfile objects
|
| 23 |
+
profiles = []
|
| 24 |
+
for profile_data in profiles_data:
|
| 25 |
+
profile = EpisodeProfile(
|
| 26 |
+
name=profile_data["name"],
|
| 27 |
+
description=profile_data.get("description", ""),
|
| 28 |
+
speaker_config=profile_data["speaker_config"],
|
| 29 |
+
outline_provider=profile_data["outline_provider"],
|
| 30 |
+
outline_model=profile_data["outline_model"],
|
| 31 |
+
transcript_provider=profile_data["transcript_provider"],
|
| 32 |
+
transcript_model=profile_data["transcript_model"],
|
| 33 |
+
default_briefing=profile_data["default_briefing"],
|
| 34 |
+
num_segments=profile_data["num_segments"],
|
| 35 |
+
)
|
| 36 |
+
profile.id = profile_data["id"]
|
| 37 |
+
profiles.append(profile)
|
| 38 |
+
return profiles
|
| 39 |
+
|
| 40 |
+
def get_episode_profile(self, profile_name: str) -> EpisodeProfile:
|
| 41 |
+
"""Get a specific episode profile by name."""
|
| 42 |
+
profile_response = api_client.get_episode_profile(profile_name)
|
| 43 |
+
profile_data = (
|
| 44 |
+
profile_response
|
| 45 |
+
if isinstance(profile_response, dict)
|
| 46 |
+
else profile_response[0]
|
| 47 |
+
)
|
| 48 |
+
profile = EpisodeProfile(
|
| 49 |
+
name=profile_data["name"],
|
| 50 |
+
description=profile_data.get("description", ""),
|
| 51 |
+
speaker_config=profile_data["speaker_config"],
|
| 52 |
+
outline_provider=profile_data["outline_provider"],
|
| 53 |
+
outline_model=profile_data["outline_model"],
|
| 54 |
+
transcript_provider=profile_data["transcript_provider"],
|
| 55 |
+
transcript_model=profile_data["transcript_model"],
|
| 56 |
+
default_briefing=profile_data["default_briefing"],
|
| 57 |
+
num_segments=profile_data["num_segments"],
|
| 58 |
+
)
|
| 59 |
+
profile.id = profile_data["id"]
|
| 60 |
+
return profile
|
| 61 |
+
|
| 62 |
+
def create_episode_profile(
|
| 63 |
+
self,
|
| 64 |
+
name: str,
|
| 65 |
+
description: str = "",
|
| 66 |
+
speaker_config: str = "",
|
| 67 |
+
outline_provider: str = "",
|
| 68 |
+
outline_model: str = "",
|
| 69 |
+
transcript_provider: str = "",
|
| 70 |
+
transcript_model: str = "",
|
| 71 |
+
default_briefing: str = "",
|
| 72 |
+
num_segments: int = 5,
|
| 73 |
+
) -> EpisodeProfile:
|
| 74 |
+
"""Create a new episode profile."""
|
| 75 |
+
profile_response = api_client.create_episode_profile(
|
| 76 |
+
name=name,
|
| 77 |
+
description=description,
|
| 78 |
+
speaker_config=speaker_config,
|
| 79 |
+
outline_provider=outline_provider,
|
| 80 |
+
outline_model=outline_model,
|
| 81 |
+
transcript_provider=transcript_provider,
|
| 82 |
+
transcript_model=transcript_model,
|
| 83 |
+
default_briefing=default_briefing,
|
| 84 |
+
num_segments=num_segments,
|
| 85 |
+
)
|
| 86 |
+
profile_data = (
|
| 87 |
+
profile_response
|
| 88 |
+
if isinstance(profile_response, dict)
|
| 89 |
+
else profile_response[0]
|
| 90 |
+
)
|
| 91 |
+
profile = EpisodeProfile(
|
| 92 |
+
name=profile_data["name"],
|
| 93 |
+
description=profile_data.get("description", ""),
|
| 94 |
+
speaker_config=profile_data["speaker_config"],
|
| 95 |
+
outline_provider=profile_data["outline_provider"],
|
| 96 |
+
outline_model=profile_data["outline_model"],
|
| 97 |
+
transcript_provider=profile_data["transcript_provider"],
|
| 98 |
+
transcript_model=profile_data["transcript_model"],
|
| 99 |
+
default_briefing=profile_data["default_briefing"],
|
| 100 |
+
num_segments=profile_data["num_segments"],
|
| 101 |
+
)
|
| 102 |
+
profile.id = profile_data["id"]
|
| 103 |
+
return profile
|
| 104 |
+
|
| 105 |
+
def delete_episode_profile(self, profile_id: str) -> bool:
|
| 106 |
+
"""Delete an episode profile."""
|
| 107 |
+
api_client.delete_episode_profile(profile_id)
|
| 108 |
+
return True
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Global service instance
|
| 112 |
+
episode_profiles_service = EpisodeProfilesService()
|
api/insights_service.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Insights service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
from open_notebook.domain.notebook import Note, SourceInsight
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class InsightsService:
|
| 14 |
+
"""Service layer for insights operations using API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Using API for insights operations")
|
| 18 |
+
|
| 19 |
+
def get_source_insights(self, source_id: str) -> List[SourceInsight]:
|
| 20 |
+
"""Get all insights for a specific source."""
|
| 21 |
+
insights_data = api_client.get_source_insights(source_id)
|
| 22 |
+
# Convert API response to SourceInsight objects
|
| 23 |
+
insights = []
|
| 24 |
+
for insight_data in insights_data:
|
| 25 |
+
insight = SourceInsight(
|
| 26 |
+
insight_type=insight_data["insight_type"],
|
| 27 |
+
content=insight_data["content"],
|
| 28 |
+
)
|
| 29 |
+
insight.id = insight_data["id"]
|
| 30 |
+
insight.created = insight_data["created"]
|
| 31 |
+
insight.updated = insight_data["updated"]
|
| 32 |
+
insights.append(insight)
|
| 33 |
+
return insights
|
| 34 |
+
|
| 35 |
+
def get_insight(self, insight_id: str) -> SourceInsight:
|
| 36 |
+
"""Get a specific insight."""
|
| 37 |
+
insight_response = api_client.get_insight(insight_id)
|
| 38 |
+
insight_data = (
|
| 39 |
+
insight_response
|
| 40 |
+
if isinstance(insight_response, dict)
|
| 41 |
+
else insight_response[0]
|
| 42 |
+
)
|
| 43 |
+
insight = SourceInsight(
|
| 44 |
+
insight_type=insight_data["insight_type"],
|
| 45 |
+
content=insight_data["content"],
|
| 46 |
+
)
|
| 47 |
+
insight.id = insight_data["id"]
|
| 48 |
+
insight.created = insight_data["created"]
|
| 49 |
+
insight.updated = insight_data["updated"]
|
| 50 |
+
# Note: source_id from API response is not stored; use await insight.get_source() if needed
|
| 51 |
+
return insight
|
| 52 |
+
|
| 53 |
+
def delete_insight(self, insight_id: str) -> bool:
|
| 54 |
+
"""Delete a specific insight."""
|
| 55 |
+
api_client.delete_insight(insight_id)
|
| 56 |
+
return True
|
| 57 |
+
|
| 58 |
+
def save_insight_as_note(
|
| 59 |
+
self, insight_id: str, notebook_id: Optional[str] = None
|
| 60 |
+
) -> Note:
|
| 61 |
+
"""Convert an insight to a note."""
|
| 62 |
+
note_response = api_client.save_insight_as_note(insight_id, notebook_id)
|
| 63 |
+
note_data = (
|
| 64 |
+
note_response if isinstance(note_response, dict) else note_response[0]
|
| 65 |
+
)
|
| 66 |
+
note = Note(
|
| 67 |
+
title=note_data["title"],
|
| 68 |
+
content=note_data["content"],
|
| 69 |
+
note_type=note_data["note_type"],
|
| 70 |
+
)
|
| 71 |
+
note.id = note_data["id"]
|
| 72 |
+
note.created = note_data["created"]
|
| 73 |
+
note.updated = note_data["updated"]
|
| 74 |
+
return note
|
| 75 |
+
|
| 76 |
+
def create_source_insight(
|
| 77 |
+
self, source_id: str, transformation_id: str, model_id: Optional[str] = None
|
| 78 |
+
) -> SourceInsight:
|
| 79 |
+
"""Create a new insight for a source by running a transformation."""
|
| 80 |
+
insight_response = api_client.create_source_insight(
|
| 81 |
+
source_id, transformation_id, model_id
|
| 82 |
+
)
|
| 83 |
+
insight_data = (
|
| 84 |
+
insight_response
|
| 85 |
+
if isinstance(insight_response, dict)
|
| 86 |
+
else insight_response[0]
|
| 87 |
+
)
|
| 88 |
+
insight = SourceInsight(
|
| 89 |
+
insight_type=insight_data["insight_type"],
|
| 90 |
+
content=insight_data["content"],
|
| 91 |
+
)
|
| 92 |
+
insight.id = insight_data["id"]
|
| 93 |
+
insight.created = insight_data["created"]
|
| 94 |
+
insight.updated = insight_data["updated"]
|
| 95 |
+
# Note: source_id from API response is not stored; use await insight.get_source() if needed
|
| 96 |
+
return insight
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# Global service instance
|
| 100 |
+
insights_service = InsightsService()
|
api/main.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Load environment variables
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
|
| 9 |
+
from fastapi import FastAPI, Request
|
| 10 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 11 |
+
from fastapi.responses import JSONResponse
|
| 12 |
+
from loguru import logger
|
| 13 |
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
| 14 |
+
|
| 15 |
+
from api.auth import PasswordAuthMiddleware
|
| 16 |
+
from api.routers import (
|
| 17 |
+
auth,
|
| 18 |
+
chat,
|
| 19 |
+
config,
|
| 20 |
+
context,
|
| 21 |
+
credentials,
|
| 22 |
+
embedding,
|
| 23 |
+
embedding_rebuild,
|
| 24 |
+
episode_profiles,
|
| 25 |
+
insights,
|
| 26 |
+
languages,
|
| 27 |
+
models,
|
| 28 |
+
notebooks,
|
| 29 |
+
notes,
|
| 30 |
+
podcasts,
|
| 31 |
+
search,
|
| 32 |
+
settings,
|
| 33 |
+
source_chat,
|
| 34 |
+
sources,
|
| 35 |
+
speaker_profiles,
|
| 36 |
+
transformations,
|
| 37 |
+
)
|
| 38 |
+
from api.routers import commands as commands_router
|
| 39 |
+
from open_notebook.database.async_migrate import AsyncMigrationManager
|
| 40 |
+
from open_notebook.exceptions import (
|
| 41 |
+
AuthenticationError,
|
| 42 |
+
ConfigurationError,
|
| 43 |
+
ExternalServiceError,
|
| 44 |
+
InvalidInputError,
|
| 45 |
+
NetworkError,
|
| 46 |
+
NotFoundError,
|
| 47 |
+
OpenNotebookError,
|
| 48 |
+
RateLimitError,
|
| 49 |
+
)
|
| 50 |
+
from open_notebook.utils.encryption import get_secret_from_env
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _parse_cors_origins(raw: str) -> list[str]:
|
| 54 |
+
"""Parse CORS_ORIGINS env value into a list of origins."""
|
| 55 |
+
value = raw.strip()
|
| 56 |
+
if value == "*":
|
| 57 |
+
return ["*"]
|
| 58 |
+
return [origin.strip() for origin in value.split(",") if origin.strip()]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# Parsed once at module load; CORS_ORIGINS changes require a restart.
|
| 62 |
+
_cors_origins_raw = os.getenv("CORS_ORIGINS")
|
| 63 |
+
CORS_ALLOWED_ORIGINS = _parse_cors_origins(_cors_origins_raw or "*")
|
| 64 |
+
CORS_IS_DEFAULT_WILDCARD = _cors_origins_raw is None
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _cors_headers(request: Request) -> dict[str, str]:
|
| 68 |
+
"""
|
| 69 |
+
Build CORS headers for error responses.
|
| 70 |
+
|
| 71 |
+
Mirrors Starlette CORSMiddleware behavior: reflects the request Origin
|
| 72 |
+
when the origin is allowed (or when wildcard is configured, since
|
| 73 |
+
browsers reject `Access-Control-Allow-Origin: *` combined with
|
| 74 |
+
credentials). Omits `Access-Control-Allow-Origin` for disallowed
|
| 75 |
+
origins so the browser blocks the error body from leaking cross-origin.
|
| 76 |
+
"""
|
| 77 |
+
origin = request.headers.get("origin")
|
| 78 |
+
headers: dict[str, str] = {
|
| 79 |
+
"Access-Control-Allow-Credentials": "true",
|
| 80 |
+
"Access-Control-Allow-Methods": "*",
|
| 81 |
+
"Access-Control-Allow-Headers": "*",
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
if origin and ("*" in CORS_ALLOWED_ORIGINS or origin in CORS_ALLOWED_ORIGINS):
|
| 85 |
+
headers["Access-Control-Allow-Origin"] = origin
|
| 86 |
+
headers["Vary"] = "Origin"
|
| 87 |
+
|
| 88 |
+
return headers
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# Import commands to register them in the API process
|
| 92 |
+
try:
|
| 93 |
+
logger.info("Commands imported in API process")
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Failed to import commands in API process: {e}")
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
@asynccontextmanager
|
| 99 |
+
async def lifespan(app: FastAPI):
|
| 100 |
+
"""
|
| 101 |
+
Lifespan event handler for the FastAPI application.
|
| 102 |
+
Runs database migrations automatically on startup.
|
| 103 |
+
"""
|
| 104 |
+
# Startup: Security checks
|
| 105 |
+
logger.info("Starting API initialization...")
|
| 106 |
+
|
| 107 |
+
# Security check: Encryption key
|
| 108 |
+
if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
|
| 109 |
+
logger.warning(
|
| 110 |
+
"OPEN_NOTEBOOK_ENCRYPTION_KEY not set. "
|
| 111 |
+
"API key encryption will fail until this is configured. "
|
| 112 |
+
"Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string."
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
# Run database migrations
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
migration_manager = AsyncMigrationManager()
|
| 119 |
+
current_version = await migration_manager.get_current_version()
|
| 120 |
+
logger.info(f"Current database version: {current_version}")
|
| 121 |
+
|
| 122 |
+
if await migration_manager.needs_migration():
|
| 123 |
+
logger.warning("Database migrations are pending. Running migrations...")
|
| 124 |
+
await migration_manager.run_migration_up()
|
| 125 |
+
new_version = await migration_manager.get_current_version()
|
| 126 |
+
logger.success(
|
| 127 |
+
f"Migrations completed successfully. Database is now at version {new_version}"
|
| 128 |
+
)
|
| 129 |
+
else:
|
| 130 |
+
logger.info(
|
| 131 |
+
"Database is already at the latest version. No migrations needed."
|
| 132 |
+
)
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"CRITICAL: Database migration failed: {str(e)}")
|
| 135 |
+
logger.exception(e)
|
| 136 |
+
# Fail fast - don't start the API with an outdated database schema
|
| 137 |
+
raise RuntimeError(f"Failed to run database migrations: {str(e)}") from e
|
| 138 |
+
|
| 139 |
+
# Run podcast profile data migration (legacy strings -> Model registry)
|
| 140 |
+
try:
|
| 141 |
+
from open_notebook.podcasts.migration import migrate_podcast_profiles
|
| 142 |
+
|
| 143 |
+
await migrate_podcast_profiles()
|
| 144 |
+
except Exception as e:
|
| 145 |
+
logger.warning(f"Podcast profile migration encountered errors: {e}")
|
| 146 |
+
# Non-fatal: profiles can be migrated manually via UI
|
| 147 |
+
|
| 148 |
+
logger.success("API initialization completed successfully")
|
| 149 |
+
|
| 150 |
+
# Yield control to the application
|
| 151 |
+
yield
|
| 152 |
+
|
| 153 |
+
# Shutdown: cleanup if needed
|
| 154 |
+
logger.info("API shutdown complete")
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
app = FastAPI(
|
| 158 |
+
title="Open Notebook API",
|
| 159 |
+
description="API for Open Notebook - Research Assistant",
|
| 160 |
+
lifespan=lifespan,
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if CORS_IS_DEFAULT_WILDCARD:
|
| 164 |
+
logger.warning(
|
| 165 |
+
"CORS_ORIGINS is not set — API accepts cross-origin requests from any "
|
| 166 |
+
"origin (default: '*'). For production deployments, set CORS_ORIGINS to "
|
| 167 |
+
"your frontend origin(s), e.g. "
|
| 168 |
+
"CORS_ORIGINS=https://notebook.example.com"
|
| 169 |
+
)
|
| 170 |
+
else:
|
| 171 |
+
logger.info(f"CORS allowed origins: {CORS_ALLOWED_ORIGINS}")
|
| 172 |
+
|
| 173 |
+
# Add password authentication middleware first
|
| 174 |
+
# Exclude /api/auth/status and /api/config from authentication
|
| 175 |
+
app.add_middleware(
|
| 176 |
+
PasswordAuthMiddleware,
|
| 177 |
+
excluded_paths=[
|
| 178 |
+
"/",
|
| 179 |
+
"/health",
|
| 180 |
+
"/docs",
|
| 181 |
+
"/openapi.json",
|
| 182 |
+
"/redoc",
|
| 183 |
+
"/api/auth/status",
|
| 184 |
+
"/api/config",
|
| 185 |
+
],
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# Add CORS middleware last (so it processes first)
|
| 189 |
+
app.add_middleware(
|
| 190 |
+
CORSMiddleware,
|
| 191 |
+
allow_origins=CORS_ALLOWED_ORIGINS,
|
| 192 |
+
allow_credentials=True,
|
| 193 |
+
allow_methods=["*"],
|
| 194 |
+
allow_headers=["*"],
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
# Custom exception handler to ensure CORS headers are included in error responses
|
| 199 |
+
# This helps when errors occur before the CORS middleware can process them
|
| 200 |
+
@app.exception_handler(StarletteHTTPException)
|
| 201 |
+
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
| 202 |
+
"""
|
| 203 |
+
Custom exception handler that ensures CORS headers are included in error responses.
|
| 204 |
+
This is particularly important for 413 (Payload Too Large) errors during file uploads.
|
| 205 |
+
|
| 206 |
+
Note: If a reverse proxy (nginx, traefik) returns 413 before the request reaches
|
| 207 |
+
FastAPI, this handler won't be called. In that case, configure your reverse proxy
|
| 208 |
+
to add CORS headers to error responses.
|
| 209 |
+
"""
|
| 210 |
+
return JSONResponse(
|
| 211 |
+
status_code=exc.status_code,
|
| 212 |
+
content={"detail": exc.detail},
|
| 213 |
+
headers={**(exc.headers or {}), **_cors_headers(request)},
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@app.exception_handler(NotFoundError)
|
| 218 |
+
async def not_found_error_handler(request: Request, exc: NotFoundError):
|
| 219 |
+
return JSONResponse(
|
| 220 |
+
status_code=404,
|
| 221 |
+
content={"detail": str(exc)},
|
| 222 |
+
headers=_cors_headers(request),
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.exception_handler(InvalidInputError)
|
| 227 |
+
async def invalid_input_error_handler(request: Request, exc: InvalidInputError):
|
| 228 |
+
return JSONResponse(
|
| 229 |
+
status_code=400,
|
| 230 |
+
content={"detail": str(exc)},
|
| 231 |
+
headers=_cors_headers(request),
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
@app.exception_handler(AuthenticationError)
|
| 236 |
+
async def authentication_error_handler(request: Request, exc: AuthenticationError):
|
| 237 |
+
return JSONResponse(
|
| 238 |
+
status_code=401,
|
| 239 |
+
content={"detail": str(exc)},
|
| 240 |
+
headers=_cors_headers(request),
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
@app.exception_handler(RateLimitError)
|
| 245 |
+
async def rate_limit_error_handler(request: Request, exc: RateLimitError):
|
| 246 |
+
return JSONResponse(
|
| 247 |
+
status_code=429,
|
| 248 |
+
content={"detail": str(exc)},
|
| 249 |
+
headers=_cors_headers(request),
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
@app.exception_handler(ConfigurationError)
|
| 254 |
+
async def configuration_error_handler(request: Request, exc: ConfigurationError):
|
| 255 |
+
return JSONResponse(
|
| 256 |
+
status_code=422,
|
| 257 |
+
content={"detail": str(exc)},
|
| 258 |
+
headers=_cors_headers(request),
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
@app.exception_handler(NetworkError)
|
| 263 |
+
async def network_error_handler(request: Request, exc: NetworkError):
|
| 264 |
+
return JSONResponse(
|
| 265 |
+
status_code=502,
|
| 266 |
+
content={"detail": str(exc)},
|
| 267 |
+
headers=_cors_headers(request),
|
| 268 |
+
)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
@app.exception_handler(ExternalServiceError)
|
| 272 |
+
async def external_service_error_handler(request: Request, exc: ExternalServiceError):
|
| 273 |
+
return JSONResponse(
|
| 274 |
+
status_code=502,
|
| 275 |
+
content={"detail": str(exc)},
|
| 276 |
+
headers=_cors_headers(request),
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
@app.exception_handler(OpenNotebookError)
|
| 281 |
+
async def open_notebook_error_handler(request: Request, exc: OpenNotebookError):
|
| 282 |
+
return JSONResponse(
|
| 283 |
+
status_code=500,
|
| 284 |
+
content={"detail": str(exc)},
|
| 285 |
+
headers=_cors_headers(request),
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# Include routers
|
| 290 |
+
app.include_router(auth.router, prefix="/api", tags=["auth"])
|
| 291 |
+
app.include_router(config.router, prefix="/api", tags=["config"])
|
| 292 |
+
app.include_router(notebooks.router, prefix="/api", tags=["notebooks"])
|
| 293 |
+
app.include_router(search.router, prefix="/api", tags=["search"])
|
| 294 |
+
app.include_router(models.router, prefix="/api", tags=["models"])
|
| 295 |
+
app.include_router(transformations.router, prefix="/api", tags=["transformations"])
|
| 296 |
+
app.include_router(notes.router, prefix="/api", tags=["notes"])
|
| 297 |
+
app.include_router(embedding.router, prefix="/api", tags=["embedding"])
|
| 298 |
+
app.include_router(
|
| 299 |
+
embedding_rebuild.router, prefix="/api/embeddings", tags=["embeddings"]
|
| 300 |
+
)
|
| 301 |
+
app.include_router(settings.router, prefix="/api", tags=["settings"])
|
| 302 |
+
app.include_router(context.router, prefix="/api", tags=["context"])
|
| 303 |
+
app.include_router(sources.router, prefix="/api", tags=["sources"])
|
| 304 |
+
app.include_router(insights.router, prefix="/api", tags=["insights"])
|
| 305 |
+
app.include_router(commands_router.router, prefix="/api", tags=["commands"])
|
| 306 |
+
app.include_router(podcasts.router, prefix="/api", tags=["podcasts"])
|
| 307 |
+
app.include_router(episode_profiles.router, prefix="/api", tags=["episode-profiles"])
|
| 308 |
+
app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profiles"])
|
| 309 |
+
app.include_router(chat.router, prefix="/api", tags=["chat"])
|
| 310 |
+
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
|
| 311 |
+
app.include_router(credentials.router, prefix="/api", tags=["credentials"])
|
| 312 |
+
app.include_router(languages.router, prefix="/api", tags=["languages"])
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
@app.get("/")
|
| 316 |
+
async def root():
|
| 317 |
+
return {"message": "Open Notebook API is running"}
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
@app.get("/health")
|
| 321 |
+
async def health():
|
| 322 |
+
return {"status": "healthy"}
|
api/models.py
ADDED
|
@@ -0,0 +1,693 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
# Notebook models
|
| 7 |
+
class NotebookCreate(BaseModel):
|
| 8 |
+
name: str = Field(..., description="Name of the notebook")
|
| 9 |
+
description: str = Field(default="", description="Description of the notebook")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class NotebookUpdate(BaseModel):
|
| 13 |
+
name: Optional[str] = Field(None, description="Name of the notebook")
|
| 14 |
+
description: Optional[str] = Field(None, description="Description of the notebook")
|
| 15 |
+
archived: Optional[bool] = Field(
|
| 16 |
+
None, description="Whether the notebook is archived"
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class NotebookResponse(BaseModel):
|
| 21 |
+
id: str
|
| 22 |
+
name: str
|
| 23 |
+
description: str
|
| 24 |
+
archived: bool
|
| 25 |
+
created: str
|
| 26 |
+
updated: str
|
| 27 |
+
source_count: int
|
| 28 |
+
note_count: int
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Search models
|
| 32 |
+
class SearchRequest(BaseModel):
|
| 33 |
+
query: str = Field(..., description="Search query")
|
| 34 |
+
type: Literal["text", "vector"] = Field("text", description="Search type")
|
| 35 |
+
limit: int = Field(100, description="Maximum number of results", le=1000)
|
| 36 |
+
search_sources: bool = Field(True, description="Include sources in search")
|
| 37 |
+
search_notes: bool = Field(True, description="Include notes in search")
|
| 38 |
+
minimum_score: float = Field(
|
| 39 |
+
0.2, description="Minimum score for vector search", ge=0, le=1
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class SearchResponse(BaseModel):
|
| 44 |
+
results: List[Dict[str, Any]] = Field(..., description="Search results")
|
| 45 |
+
total_count: int = Field(..., description="Total number of results")
|
| 46 |
+
search_type: str = Field(..., description="Type of search performed")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class AskRequest(BaseModel):
|
| 50 |
+
question: str = Field(..., description="Question to ask the knowledge base")
|
| 51 |
+
strategy_model: str = Field(..., description="Model ID for query strategy")
|
| 52 |
+
answer_model: str = Field(..., description="Model ID for individual answers")
|
| 53 |
+
final_answer_model: str = Field(..., description="Model ID for final answer")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class AskResponse(BaseModel):
|
| 57 |
+
answer: str = Field(..., description="Final answer from the knowledge base")
|
| 58 |
+
question: str = Field(..., description="Original question")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# Models API models
|
| 62 |
+
class ModelCreate(BaseModel):
|
| 63 |
+
name: str = Field(..., description="Model name (e.g., gpt-5-mini, claude, gemini)")
|
| 64 |
+
provider: str = Field(
|
| 65 |
+
..., description="Provider name (e.g., openai, anthropic, gemini)"
|
| 66 |
+
)
|
| 67 |
+
type: str = Field(
|
| 68 |
+
...,
|
| 69 |
+
description="Model type (language, embedding, text_to_speech, speech_to_text)",
|
| 70 |
+
)
|
| 71 |
+
credential: Optional[str] = Field(
|
| 72 |
+
None, description="Credential ID to link this model to"
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class ModelResponse(BaseModel):
|
| 77 |
+
id: str
|
| 78 |
+
name: str
|
| 79 |
+
provider: str
|
| 80 |
+
type: str
|
| 81 |
+
credential: Optional[str] = None
|
| 82 |
+
created: str
|
| 83 |
+
updated: str
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class DefaultModelsResponse(BaseModel):
|
| 87 |
+
default_chat_model: Optional[str] = None
|
| 88 |
+
default_transformation_model: Optional[str] = None
|
| 89 |
+
large_context_model: Optional[str] = None
|
| 90 |
+
default_text_to_speech_model: Optional[str] = None
|
| 91 |
+
default_speech_to_text_model: Optional[str] = None
|
| 92 |
+
default_embedding_model: Optional[str] = None
|
| 93 |
+
default_tools_model: Optional[str] = None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class ProviderAvailabilityResponse(BaseModel):
|
| 97 |
+
available: List[str] = Field(..., description="List of available providers")
|
| 98 |
+
unavailable: List[str] = Field(..., description="List of unavailable providers")
|
| 99 |
+
supported_types: Dict[str, List[str]] = Field(
|
| 100 |
+
..., description="Provider to supported model types mapping"
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# Transformations API models
|
| 105 |
+
class TransformationCreate(BaseModel):
|
| 106 |
+
name: str = Field(..., description="Transformation name")
|
| 107 |
+
title: str = Field(..., description="Display title for the transformation")
|
| 108 |
+
description: str = Field(
|
| 109 |
+
..., description="Description of what this transformation does"
|
| 110 |
+
)
|
| 111 |
+
prompt: str = Field(..., description="The transformation prompt")
|
| 112 |
+
apply_default: bool = Field(
|
| 113 |
+
False, description="Whether to apply this transformation by default"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
class TransformationUpdate(BaseModel):
|
| 118 |
+
name: Optional[str] = Field(None, description="Transformation name")
|
| 119 |
+
title: Optional[str] = Field(
|
| 120 |
+
None, description="Display title for the transformation"
|
| 121 |
+
)
|
| 122 |
+
description: Optional[str] = Field(
|
| 123 |
+
None, description="Description of what this transformation does"
|
| 124 |
+
)
|
| 125 |
+
prompt: Optional[str] = Field(None, description="The transformation prompt")
|
| 126 |
+
apply_default: Optional[bool] = Field(
|
| 127 |
+
None, description="Whether to apply this transformation by default"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class TransformationResponse(BaseModel):
|
| 132 |
+
id: str
|
| 133 |
+
name: str
|
| 134 |
+
title: str
|
| 135 |
+
description: str
|
| 136 |
+
prompt: str
|
| 137 |
+
apply_default: bool
|
| 138 |
+
created: str
|
| 139 |
+
updated: str
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
class TransformationExecuteRequest(BaseModel):
|
| 143 |
+
model_config = ConfigDict(protected_namespaces=())
|
| 144 |
+
|
| 145 |
+
transformation_id: str = Field(
|
| 146 |
+
..., description="ID of the transformation to execute"
|
| 147 |
+
)
|
| 148 |
+
input_text: str = Field(..., description="Text to transform")
|
| 149 |
+
model_id: str = Field(..., description="Model ID to use for the transformation")
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class TransformationExecuteResponse(BaseModel):
|
| 153 |
+
model_config = ConfigDict(protected_namespaces=())
|
| 154 |
+
|
| 155 |
+
output: str = Field(..., description="Transformed text")
|
| 156 |
+
transformation_id: str = Field(..., description="ID of the transformation used")
|
| 157 |
+
model_id: str = Field(..., description="Model ID used")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# Default Prompt API models
|
| 161 |
+
class DefaultPromptResponse(BaseModel):
|
| 162 |
+
transformation_instructions: str = Field(
|
| 163 |
+
..., description="Default transformation instructions"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class DefaultPromptUpdate(BaseModel):
|
| 168 |
+
transformation_instructions: str = Field(
|
| 169 |
+
..., description="Default transformation instructions"
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# Notes API models
|
| 174 |
+
class NoteCreate(BaseModel):
|
| 175 |
+
title: Optional[str] = Field(None, description="Note title")
|
| 176 |
+
content: str = Field(..., description="Note content")
|
| 177 |
+
note_type: Optional[str] = Field("human", description="Type of note (human, ai)")
|
| 178 |
+
notebook_id: Optional[str] = Field(
|
| 179 |
+
None, description="Notebook ID to add the note to"
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class NoteUpdate(BaseModel):
|
| 184 |
+
title: Optional[str] = Field(None, description="Note title")
|
| 185 |
+
content: Optional[str] = Field(None, description="Note content")
|
| 186 |
+
note_type: Optional[str] = Field(None, description="Type of note (human, ai)")
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class NoteResponse(BaseModel):
|
| 190 |
+
id: str
|
| 191 |
+
title: Optional[str]
|
| 192 |
+
content: Optional[str]
|
| 193 |
+
note_type: Optional[str]
|
| 194 |
+
created: str
|
| 195 |
+
updated: str
|
| 196 |
+
command_id: Optional[str] = None
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
# Embedding API models
|
| 200 |
+
class EmbedRequest(BaseModel):
|
| 201 |
+
item_id: str = Field(..., description="ID of the item to embed")
|
| 202 |
+
item_type: str = Field(..., description="Type of item (source, note)")
|
| 203 |
+
async_processing: bool = Field(
|
| 204 |
+
False, description="Process asynchronously in background"
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class EmbedResponse(BaseModel):
|
| 209 |
+
success: bool = Field(..., description="Whether embedding was successful")
|
| 210 |
+
message: str = Field(..., description="Result message")
|
| 211 |
+
item_id: str = Field(..., description="ID of the item that was embedded")
|
| 212 |
+
item_type: str = Field(..., description="Type of item that was embedded")
|
| 213 |
+
command_id: Optional[str] = Field(
|
| 214 |
+
None, description="Command ID for async processing"
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# Rebuild request/response models
|
| 219 |
+
class RebuildRequest(BaseModel):
|
| 220 |
+
mode: Literal["existing", "all"] = Field(
|
| 221 |
+
...,
|
| 222 |
+
description="Rebuild mode: 'existing' only re-embeds items with embeddings, 'all' embeds everything",
|
| 223 |
+
)
|
| 224 |
+
include_sources: bool = Field(True, description="Include sources in rebuild")
|
| 225 |
+
include_notes: bool = Field(True, description="Include notes in rebuild")
|
| 226 |
+
include_insights: bool = Field(True, description="Include insights in rebuild")
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class RebuildResponse(BaseModel):
|
| 230 |
+
command_id: str = Field(..., description="Command ID to track progress")
|
| 231 |
+
total_items: int = Field(..., description="Estimated number of items to process")
|
| 232 |
+
message: str = Field(..., description="Status message")
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class RebuildProgress(BaseModel):
|
| 236 |
+
processed: int = Field(..., description="Number of items processed")
|
| 237 |
+
total: int = Field(..., description="Total items to process")
|
| 238 |
+
percentage: float = Field(..., description="Progress percentage")
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
class RebuildStats(BaseModel):
|
| 242 |
+
sources: int = Field(0, description="Sources processed")
|
| 243 |
+
notes: int = Field(0, description="Notes processed")
|
| 244 |
+
insights: int = Field(0, description="Insights processed")
|
| 245 |
+
failed: int = Field(0, description="Failed items")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class RebuildStatusResponse(BaseModel):
|
| 249 |
+
command_id: str = Field(..., description="Command ID")
|
| 250 |
+
status: str = Field(..., description="Status: queued, running, completed, failed")
|
| 251 |
+
progress: Optional[RebuildProgress] = None
|
| 252 |
+
stats: Optional[RebuildStats] = None
|
| 253 |
+
started_at: Optional[str] = None
|
| 254 |
+
completed_at: Optional[str] = None
|
| 255 |
+
error_message: Optional[str] = None
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
# Settings API models
|
| 259 |
+
class SettingsResponse(BaseModel):
|
| 260 |
+
default_content_processing_engine_doc: Optional[str] = None
|
| 261 |
+
default_content_processing_engine_url: Optional[str] = None
|
| 262 |
+
default_embedding_option: Optional[str] = None
|
| 263 |
+
auto_delete_files: Optional[str] = None
|
| 264 |
+
youtube_preferred_languages: Optional[List[str]] = None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class SettingsUpdate(BaseModel):
|
| 268 |
+
default_content_processing_engine_doc: Optional[str] = None
|
| 269 |
+
default_content_processing_engine_url: Optional[str] = None
|
| 270 |
+
default_embedding_option: Optional[str] = None
|
| 271 |
+
auto_delete_files: Optional[str] = None
|
| 272 |
+
youtube_preferred_languages: Optional[List[str]] = None
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Sources API models
|
| 276 |
+
class AssetModel(BaseModel):
|
| 277 |
+
file_path: Optional[str] = None
|
| 278 |
+
url: Optional[str] = None
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class SourceCreate(BaseModel):
|
| 282 |
+
# Backward compatibility: support old single notebook_id
|
| 283 |
+
notebook_id: Optional[str] = Field(
|
| 284 |
+
None, description="Notebook ID to add the source to (deprecated, use notebooks)"
|
| 285 |
+
)
|
| 286 |
+
# New multi-notebook support
|
| 287 |
+
notebooks: Optional[List[str]] = Field(
|
| 288 |
+
None, description="List of notebook IDs to add the source to"
|
| 289 |
+
)
|
| 290 |
+
# Required fields
|
| 291 |
+
type: str = Field(..., description="Source type: link, upload, or text")
|
| 292 |
+
url: Optional[str] = Field(None, description="URL for link type")
|
| 293 |
+
file_path: Optional[str] = Field(None, description="File path for upload type")
|
| 294 |
+
content: Optional[str] = Field(None, description="Text content for text type")
|
| 295 |
+
title: Optional[str] = Field(None, description="Source title")
|
| 296 |
+
transformations: Optional[List[str]] = Field(
|
| 297 |
+
default_factory=list, description="Transformation IDs to apply"
|
| 298 |
+
)
|
| 299 |
+
embed: bool = Field(False, description="Whether to embed content for vector search")
|
| 300 |
+
delete_source: bool = Field(
|
| 301 |
+
False, description="Whether to delete uploaded file after processing"
|
| 302 |
+
)
|
| 303 |
+
# New async processing support
|
| 304 |
+
async_processing: bool = Field(
|
| 305 |
+
False, description="Whether to process source asynchronously"
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
@model_validator(mode="after")
|
| 309 |
+
def validate_notebook_fields(self):
|
| 310 |
+
# Ensure only one of notebook_id or notebooks is provided
|
| 311 |
+
if self.notebook_id is not None and self.notebooks is not None:
|
| 312 |
+
raise ValueError(
|
| 313 |
+
"Cannot specify both 'notebook_id' and 'notebooks'. Use 'notebooks' for multi-notebook support."
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
# Convert single notebook_id to notebooks array for internal processing
|
| 317 |
+
if self.notebook_id is not None:
|
| 318 |
+
self.notebooks = [self.notebook_id]
|
| 319 |
+
# Keep notebook_id for backward compatibility in response
|
| 320 |
+
|
| 321 |
+
# Set empty array if no notebooks specified (allow sources without notebooks)
|
| 322 |
+
if self.notebooks is None:
|
| 323 |
+
self.notebooks = []
|
| 324 |
+
|
| 325 |
+
return self
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class SourceUpdate(BaseModel):
|
| 329 |
+
title: Optional[str] = Field(None, description="Source title")
|
| 330 |
+
topics: Optional[List[str]] = Field(None, description="Source topics")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
class SourceResponse(BaseModel):
|
| 334 |
+
id: str
|
| 335 |
+
title: Optional[str]
|
| 336 |
+
topics: Optional[List[str]]
|
| 337 |
+
asset: Optional[AssetModel]
|
| 338 |
+
full_text: Optional[str]
|
| 339 |
+
embedded: bool
|
| 340 |
+
embedded_chunks: int
|
| 341 |
+
file_available: Optional[bool] = None
|
| 342 |
+
created: str
|
| 343 |
+
updated: str
|
| 344 |
+
# New fields for async processing
|
| 345 |
+
command_id: Optional[str] = None
|
| 346 |
+
status: Optional[str] = None
|
| 347 |
+
processing_info: Optional[Dict] = None
|
| 348 |
+
# Notebook associations
|
| 349 |
+
notebooks: Optional[List[str]] = None
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class SourceListResponse(BaseModel):
|
| 353 |
+
id: str
|
| 354 |
+
title: Optional[str]
|
| 355 |
+
topics: Optional[List[str]]
|
| 356 |
+
asset: Optional[AssetModel]
|
| 357 |
+
embedded: bool # Boolean flag indicating if source has embeddings
|
| 358 |
+
embedded_chunks: int # Number of embedded chunks
|
| 359 |
+
insights_count: int
|
| 360 |
+
created: str
|
| 361 |
+
updated: str
|
| 362 |
+
file_available: Optional[bool] = None
|
| 363 |
+
# Status fields for async processing
|
| 364 |
+
command_id: Optional[str] = None
|
| 365 |
+
status: Optional[str] = None
|
| 366 |
+
processing_info: Optional[Dict[str, Any]] = None
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# Context API models
|
| 370 |
+
class ContextConfig(BaseModel):
|
| 371 |
+
sources: Dict[str, str] = Field(
|
| 372 |
+
default_factory=dict, description="Source inclusion config {source_id: level}"
|
| 373 |
+
)
|
| 374 |
+
notes: Dict[str, str] = Field(
|
| 375 |
+
default_factory=dict, description="Note inclusion config {note_id: level}"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
class ContextRequest(BaseModel):
|
| 380 |
+
notebook_id: str = Field(..., description="Notebook ID to get context for")
|
| 381 |
+
context_config: Optional[ContextConfig] = Field(
|
| 382 |
+
None, description="Context configuration"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
class ContextResponse(BaseModel):
|
| 387 |
+
notebook_id: str
|
| 388 |
+
sources: List[Dict[str, Any]] = Field(..., description="Source context data")
|
| 389 |
+
notes: List[Dict[str, Any]] = Field(..., description="Note context data")
|
| 390 |
+
total_tokens: Optional[int] = Field(None, description="Estimated token count")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# Insights API models
|
| 394 |
+
class SourceInsightResponse(BaseModel):
|
| 395 |
+
id: str
|
| 396 |
+
source_id: str
|
| 397 |
+
insight_type: str
|
| 398 |
+
content: str
|
| 399 |
+
created: str
|
| 400 |
+
updated: str
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
class InsightCreationResponse(BaseModel):
|
| 404 |
+
"""Response for async insight creation."""
|
| 405 |
+
|
| 406 |
+
status: Literal["pending"] = "pending"
|
| 407 |
+
message: str = "Insight generation started"
|
| 408 |
+
source_id: str
|
| 409 |
+
transformation_id: str
|
| 410 |
+
command_id: Optional[str] = None
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
class SaveAsNoteRequest(BaseModel):
|
| 414 |
+
notebook_id: Optional[str] = Field(None, description="Notebook ID to add note to")
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
class CreateSourceInsightRequest(BaseModel):
|
| 418 |
+
model_config = ConfigDict(protected_namespaces=())
|
| 419 |
+
|
| 420 |
+
transformation_id: str = Field(..., description="ID of transformation to apply")
|
| 421 |
+
model_id: Optional[str] = Field(
|
| 422 |
+
None, description="Model ID (uses default if not provided)"
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# Source status response
|
| 427 |
+
class SourceStatusResponse(BaseModel):
|
| 428 |
+
status: Optional[str] = Field(None, description="Processing status")
|
| 429 |
+
message: str = Field(..., description="Descriptive message about the status")
|
| 430 |
+
processing_info: Optional[Dict[str, Any]] = Field(
|
| 431 |
+
None, description="Detailed processing information"
|
| 432 |
+
)
|
| 433 |
+
command_id: Optional[str] = Field(None, description="Command ID if available")
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# Error response
|
| 437 |
+
class ErrorResponse(BaseModel):
|
| 438 |
+
error: str
|
| 439 |
+
message: str
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# API Key Configuration models
|
| 443 |
+
class SetApiKeyRequest(BaseModel):
|
| 444 |
+
"""Request to set an API key for a provider."""
|
| 445 |
+
|
| 446 |
+
api_key: Optional[str] = Field(None, description="API key for the provider")
|
| 447 |
+
base_url: Optional[str] = Field(
|
| 448 |
+
None, description="Base URL for URL-based providers (Ollama, OpenAI-compatible)"
|
| 449 |
+
)
|
| 450 |
+
endpoint: Optional[str] = Field(
|
| 451 |
+
None, description="Endpoint URL for Azure OpenAI"
|
| 452 |
+
)
|
| 453 |
+
api_version: Optional[str] = Field(
|
| 454 |
+
None, description="API version for Azure OpenAI"
|
| 455 |
+
)
|
| 456 |
+
endpoint_llm: Optional[str] = Field(
|
| 457 |
+
None, description="Service-specific endpoint for LLM (Azure)"
|
| 458 |
+
)
|
| 459 |
+
endpoint_embedding: Optional[str] = Field(
|
| 460 |
+
None, description="Service-specific endpoint for embedding (Azure)"
|
| 461 |
+
)
|
| 462 |
+
endpoint_stt: Optional[str] = Field(
|
| 463 |
+
None, description="Service-specific endpoint for STT (Azure)"
|
| 464 |
+
)
|
| 465 |
+
endpoint_tts: Optional[str] = Field(
|
| 466 |
+
None, description="Service-specific endpoint for TTS (Azure)"
|
| 467 |
+
)
|
| 468 |
+
service_type: Optional[Literal["llm", "embedding", "stt", "tts"]] = Field(
|
| 469 |
+
None,
|
| 470 |
+
description="Service type for OpenAI-compatible providers (llm, embedding, stt, tts)",
|
| 471 |
+
)
|
| 472 |
+
# Vertex AI specific fields
|
| 473 |
+
vertex_project: Optional[str] = Field(
|
| 474 |
+
None, description="Google Cloud Project ID for Vertex AI"
|
| 475 |
+
)
|
| 476 |
+
vertex_location: Optional[str] = Field(
|
| 477 |
+
None, description="Google Cloud Region for Vertex AI (e.g., us-central1)"
|
| 478 |
+
)
|
| 479 |
+
vertex_credentials_path: Optional[str] = Field(
|
| 480 |
+
None, description="Path to Google Cloud service account JSON file"
|
| 481 |
+
)
|
| 482 |
+
|
| 483 |
+
@field_validator(
|
| 484 |
+
"api_key",
|
| 485 |
+
"base_url",
|
| 486 |
+
"endpoint",
|
| 487 |
+
"api_version",
|
| 488 |
+
"endpoint_llm",
|
| 489 |
+
"endpoint_embedding",
|
| 490 |
+
"endpoint_stt",
|
| 491 |
+
"endpoint_tts",
|
| 492 |
+
"vertex_project",
|
| 493 |
+
"vertex_location",
|
| 494 |
+
"vertex_credentials_path",
|
| 495 |
+
mode="before",
|
| 496 |
+
)
|
| 497 |
+
@classmethod
|
| 498 |
+
def validate_not_empty_string(cls, v: Optional[str]) -> Optional[str]:
|
| 499 |
+
"""Reject empty strings - convert to None or raise error."""
|
| 500 |
+
if v is not None:
|
| 501 |
+
stripped = v.strip()
|
| 502 |
+
if not stripped:
|
| 503 |
+
return None # Treat empty/whitespace-only as None
|
| 504 |
+
return stripped
|
| 505 |
+
return v
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
class ApiKeyStatusResponse(BaseModel):
|
| 509 |
+
"""Response showing which providers are configured and their source."""
|
| 510 |
+
|
| 511 |
+
configured: Dict[str, bool] = Field(
|
| 512 |
+
..., description="Map of provider name to whether it is configured"
|
| 513 |
+
)
|
| 514 |
+
source: Dict[str, Literal["database", "environment", "none"]] = Field(
|
| 515 |
+
...,
|
| 516 |
+
description="Map of provider name to configuration source (database, environment, or none)",
|
| 517 |
+
)
|
| 518 |
+
encryption_configured: bool = Field(
|
| 519 |
+
...,
|
| 520 |
+
description="Whether OPEN_NOTEBOOK_ENCRYPTION_KEY is set (required to store keys in database)",
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
class TestConnectionResponse(BaseModel):
|
| 525 |
+
"""Response from testing a provider connection."""
|
| 526 |
+
|
| 527 |
+
provider: str = Field(..., description="Provider name that was tested")
|
| 528 |
+
success: bool = Field(..., description="Whether connection test succeeded")
|
| 529 |
+
message: str = Field(..., description="Result message with details")
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
class MigrateFromEnvRequest(BaseModel):
|
| 533 |
+
"""Request to migrate API keys from environment variables to database."""
|
| 534 |
+
|
| 535 |
+
force: bool = Field(
|
| 536 |
+
False, description="Force overwrite existing database configurations"
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
class MigrationResult(BaseModel):
|
| 541 |
+
"""Response from migrating API keys from environment to database."""
|
| 542 |
+
|
| 543 |
+
message: str = Field(..., description="Summary message")
|
| 544 |
+
migrated: List[str] = Field(
|
| 545 |
+
default_factory=list, description="Providers successfully migrated"
|
| 546 |
+
)
|
| 547 |
+
skipped: List[str] = Field(
|
| 548 |
+
default_factory=list, description="Providers skipped (already in DB)"
|
| 549 |
+
)
|
| 550 |
+
errors: List[str] = Field(
|
| 551 |
+
default_factory=list, description="Migration errors by provider"
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
# Notebook delete cascade models
|
| 556 |
+
# Credential models
|
| 557 |
+
class CreateCredentialRequest(BaseModel):
|
| 558 |
+
"""Request to create a new credential."""
|
| 559 |
+
|
| 560 |
+
name: str = Field(..., description="Credential name")
|
| 561 |
+
provider: str = Field(..., description="Provider name (openai, anthropic, etc.)")
|
| 562 |
+
modalities: List[str] = Field(
|
| 563 |
+
default_factory=list,
|
| 564 |
+
description="Supported modalities (language, embedding, text_to_speech, speech_to_text)",
|
| 565 |
+
)
|
| 566 |
+
api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
|
| 567 |
+
base_url: Optional[str] = Field(None, description="Base URL")
|
| 568 |
+
endpoint: Optional[str] = Field(None, description="Endpoint URL (Azure)")
|
| 569 |
+
api_version: Optional[str] = Field(None, description="API version (Azure)")
|
| 570 |
+
endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
|
| 571 |
+
endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
|
| 572 |
+
endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
|
| 573 |
+
endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
|
| 574 |
+
project: Optional[str] = Field(None, description="Project ID (Vertex)")
|
| 575 |
+
location: Optional[str] = Field(None, description="Location (Vertex)")
|
| 576 |
+
credentials_path: Optional[str] = Field(
|
| 577 |
+
None, description="Credentials file path (Vertex)"
|
| 578 |
+
)
|
| 579 |
+
num_ctx: Optional[int] = Field(
|
| 580 |
+
None, description="Context window size (Ollama only; defaults to 8192)"
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
|
| 584 |
+
class UpdateCredentialRequest(BaseModel):
|
| 585 |
+
"""Request to update an existing credential."""
|
| 586 |
+
|
| 587 |
+
name: Optional[str] = Field(None, description="Credential name")
|
| 588 |
+
modalities: Optional[List[str]] = Field(None, description="Supported modalities")
|
| 589 |
+
api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
|
| 590 |
+
base_url: Optional[str] = Field(None, description="Base URL")
|
| 591 |
+
endpoint: Optional[str] = Field(None, description="Endpoint URL")
|
| 592 |
+
api_version: Optional[str] = Field(None, description="API version")
|
| 593 |
+
endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
|
| 594 |
+
endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
|
| 595 |
+
endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
|
| 596 |
+
endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
|
| 597 |
+
project: Optional[str] = Field(None, description="Project ID")
|
| 598 |
+
location: Optional[str] = Field(None, description="Location")
|
| 599 |
+
credentials_path: Optional[str] = Field(None, description="Credentials path")
|
| 600 |
+
num_ctx: Optional[int] = Field(
|
| 601 |
+
None, description="Context window size (Ollama only; defaults to 8192)"
|
| 602 |
+
)
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
class CredentialResponse(BaseModel):
|
| 606 |
+
"""Response for a credential (never includes api_key)."""
|
| 607 |
+
|
| 608 |
+
id: str
|
| 609 |
+
name: str
|
| 610 |
+
provider: str
|
| 611 |
+
modalities: List[str]
|
| 612 |
+
base_url: Optional[str] = None
|
| 613 |
+
endpoint: Optional[str] = None
|
| 614 |
+
api_version: Optional[str] = None
|
| 615 |
+
endpoint_llm: Optional[str] = None
|
| 616 |
+
endpoint_embedding: Optional[str] = None
|
| 617 |
+
endpoint_stt: Optional[str] = None
|
| 618 |
+
endpoint_tts: Optional[str] = None
|
| 619 |
+
project: Optional[str] = None
|
| 620 |
+
location: Optional[str] = None
|
| 621 |
+
credentials_path: Optional[str] = None
|
| 622 |
+
num_ctx: Optional[int] = None
|
| 623 |
+
has_api_key: bool = False
|
| 624 |
+
created: str
|
| 625 |
+
updated: str
|
| 626 |
+
model_count: int = 0
|
| 627 |
+
decryption_error: Optional[str] = None
|
| 628 |
+
|
| 629 |
+
|
| 630 |
+
class CredentialDeleteResponse(BaseModel):
|
| 631 |
+
"""Response for credential deletion."""
|
| 632 |
+
|
| 633 |
+
message: str
|
| 634 |
+
deleted_models: int = 0
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
class DiscoveredModelResponse(BaseModel):
|
| 638 |
+
"""A model discovered from a provider."""
|
| 639 |
+
|
| 640 |
+
name: str
|
| 641 |
+
provider: str
|
| 642 |
+
model_type: Optional[str] = None
|
| 643 |
+
description: Optional[str] = None
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
class DiscoverModelsResponse(BaseModel):
|
| 647 |
+
"""Response from model discovery."""
|
| 648 |
+
|
| 649 |
+
credential_id: str
|
| 650 |
+
provider: str
|
| 651 |
+
discovered: List[DiscoveredModelResponse]
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
class RegisterModelData(BaseModel):
|
| 655 |
+
"""A model to register with user-specified type."""
|
| 656 |
+
|
| 657 |
+
name: str
|
| 658 |
+
provider: str
|
| 659 |
+
model_type: str # Required: user specifies the type
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
class RegisterModelsRequest(BaseModel):
|
| 663 |
+
"""Request to register discovered models."""
|
| 664 |
+
|
| 665 |
+
models: List[RegisterModelData]
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
class RegisterModelsResponse(BaseModel):
|
| 669 |
+
"""Response from model registration."""
|
| 670 |
+
|
| 671 |
+
created: int
|
| 672 |
+
existing: int
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
class NotebookDeletePreview(BaseModel):
|
| 676 |
+
notebook_id: str = Field(..., description="ID of the notebook")
|
| 677 |
+
notebook_name: str = Field(..., description="Name of the notebook")
|
| 678 |
+
note_count: int = Field(..., description="Number of notes that will be deleted")
|
| 679 |
+
exclusive_source_count: int = Field(
|
| 680 |
+
..., description="Number of sources only in this notebook"
|
| 681 |
+
)
|
| 682 |
+
shared_source_count: int = Field(
|
| 683 |
+
..., description="Number of sources shared with other notebooks"
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
|
| 687 |
+
class NotebookDeleteResponse(BaseModel):
|
| 688 |
+
message: str = Field(..., description="Success message")
|
| 689 |
+
deleted_notes: int = Field(..., description="Number of notes deleted")
|
| 690 |
+
deleted_sources: int = Field(..., description="Number of exclusive sources deleted")
|
| 691 |
+
unlinked_sources: int = Field(
|
| 692 |
+
..., description="Number of sources unlinked from notebook"
|
| 693 |
+
)
|
api/models_service.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Models service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
from open_notebook.ai.models import DefaultModels, Model
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ModelsService:
|
| 14 |
+
"""Service layer for models operations using API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Using API for models operations")
|
| 18 |
+
|
| 19 |
+
def get_all_models(self, model_type: Optional[str] = None) -> List[Model]:
|
| 20 |
+
"""Get all models with optional type filtering."""
|
| 21 |
+
models_data = api_client.get_models(model_type=model_type)
|
| 22 |
+
# Convert API response to Model objects
|
| 23 |
+
models = []
|
| 24 |
+
for model_data in models_data:
|
| 25 |
+
model = Model(
|
| 26 |
+
name=model_data["name"],
|
| 27 |
+
provider=model_data["provider"],
|
| 28 |
+
type=model_data["type"],
|
| 29 |
+
)
|
| 30 |
+
model.id = model_data["id"]
|
| 31 |
+
model.created = model_data["created"]
|
| 32 |
+
model.updated = model_data["updated"]
|
| 33 |
+
models.append(model)
|
| 34 |
+
return models
|
| 35 |
+
|
| 36 |
+
def create_model(self, name: str, provider: str, model_type: str) -> Model:
|
| 37 |
+
"""Create a new model."""
|
| 38 |
+
response = api_client.create_model(name, provider, model_type)
|
| 39 |
+
model_data = response if isinstance(response, dict) else response[0]
|
| 40 |
+
model = Model(
|
| 41 |
+
name=model_data["name"],
|
| 42 |
+
provider=model_data["provider"],
|
| 43 |
+
type=model_data["type"],
|
| 44 |
+
)
|
| 45 |
+
model.id = model_data["id"]
|
| 46 |
+
model.created = model_data["created"]
|
| 47 |
+
model.updated = model_data["updated"]
|
| 48 |
+
return model
|
| 49 |
+
|
| 50 |
+
def delete_model(self, model_id: str) -> bool:
|
| 51 |
+
"""Delete a model."""
|
| 52 |
+
api_client.delete_model(model_id)
|
| 53 |
+
return True
|
| 54 |
+
|
| 55 |
+
def get_default_models(self) -> DefaultModels:
|
| 56 |
+
"""Get default model assignments."""
|
| 57 |
+
response = api_client.get_default_models()
|
| 58 |
+
defaults_data = response if isinstance(response, dict) else response[0]
|
| 59 |
+
defaults = DefaultModels()
|
| 60 |
+
|
| 61 |
+
# Set the values from API response
|
| 62 |
+
defaults.default_chat_model = defaults_data.get("default_chat_model")
|
| 63 |
+
defaults.default_transformation_model = defaults_data.get(
|
| 64 |
+
"default_transformation_model"
|
| 65 |
+
)
|
| 66 |
+
defaults.large_context_model = defaults_data.get("large_context_model")
|
| 67 |
+
defaults.default_text_to_speech_model = defaults_data.get(
|
| 68 |
+
"default_text_to_speech_model"
|
| 69 |
+
)
|
| 70 |
+
defaults.default_speech_to_text_model = defaults_data.get(
|
| 71 |
+
"default_speech_to_text_model"
|
| 72 |
+
)
|
| 73 |
+
defaults.default_embedding_model = defaults_data.get("default_embedding_model")
|
| 74 |
+
defaults.default_tools_model = defaults_data.get("default_tools_model")
|
| 75 |
+
|
| 76 |
+
return defaults
|
| 77 |
+
|
| 78 |
+
def update_default_models(self, defaults: DefaultModels) -> DefaultModels:
|
| 79 |
+
"""Update default model assignments."""
|
| 80 |
+
updates = {
|
| 81 |
+
"default_chat_model": defaults.default_chat_model,
|
| 82 |
+
"default_transformation_model": defaults.default_transformation_model,
|
| 83 |
+
"large_context_model": defaults.large_context_model,
|
| 84 |
+
"default_text_to_speech_model": defaults.default_text_to_speech_model,
|
| 85 |
+
"default_speech_to_text_model": defaults.default_speech_to_text_model,
|
| 86 |
+
"default_embedding_model": defaults.default_embedding_model,
|
| 87 |
+
"default_tools_model": defaults.default_tools_model,
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
response = api_client.update_default_models(**updates)
|
| 91 |
+
defaults_data = response if isinstance(response, dict) else response[0]
|
| 92 |
+
|
| 93 |
+
# Update the defaults object with the response
|
| 94 |
+
defaults.default_chat_model = defaults_data.get("default_chat_model")
|
| 95 |
+
defaults.default_transformation_model = defaults_data.get(
|
| 96 |
+
"default_transformation_model"
|
| 97 |
+
)
|
| 98 |
+
defaults.large_context_model = defaults_data.get("large_context_model")
|
| 99 |
+
defaults.default_text_to_speech_model = defaults_data.get(
|
| 100 |
+
"default_text_to_speech_model"
|
| 101 |
+
)
|
| 102 |
+
defaults.default_speech_to_text_model = defaults_data.get(
|
| 103 |
+
"default_speech_to_text_model"
|
| 104 |
+
)
|
| 105 |
+
defaults.default_embedding_model = defaults_data.get("default_embedding_model")
|
| 106 |
+
defaults.default_tools_model = defaults_data.get("default_tools_model")
|
| 107 |
+
|
| 108 |
+
return defaults
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
# Global service instance
|
| 112 |
+
models_service = ModelsService()
|
api/notebook_service.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Notebook service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
from open_notebook.domain.notebook import Notebook
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class NotebookService:
|
| 14 |
+
"""Service layer for notebook operations using API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Using API for notebook operations")
|
| 18 |
+
|
| 19 |
+
def get_all_notebooks(self, order_by: str = "updated desc") -> List[Notebook]:
|
| 20 |
+
"""Get all notebooks."""
|
| 21 |
+
notebooks_data = api_client.get_notebooks(order_by=order_by)
|
| 22 |
+
# Convert API response to Notebook objects
|
| 23 |
+
notebooks = []
|
| 24 |
+
for nb_data in notebooks_data:
|
| 25 |
+
nb = Notebook(
|
| 26 |
+
name=nb_data["name"],
|
| 27 |
+
description=nb_data["description"],
|
| 28 |
+
archived=nb_data["archived"],
|
| 29 |
+
)
|
| 30 |
+
nb.id = nb_data["id"]
|
| 31 |
+
nb.created = nb_data["created"]
|
| 32 |
+
nb.updated = nb_data["updated"]
|
| 33 |
+
notebooks.append(nb)
|
| 34 |
+
return notebooks
|
| 35 |
+
|
| 36 |
+
def get_notebook(self, notebook_id: str) -> Optional[Notebook]:
|
| 37 |
+
"""Get a specific notebook."""
|
| 38 |
+
response = api_client.get_notebook(notebook_id)
|
| 39 |
+
nb_data = response if isinstance(response, dict) else response[0]
|
| 40 |
+
nb = Notebook(
|
| 41 |
+
name=nb_data["name"],
|
| 42 |
+
description=nb_data["description"],
|
| 43 |
+
archived=nb_data["archived"],
|
| 44 |
+
)
|
| 45 |
+
nb.id = nb_data["id"]
|
| 46 |
+
nb.created = nb_data["created"]
|
| 47 |
+
nb.updated = nb_data["updated"]
|
| 48 |
+
return nb
|
| 49 |
+
|
| 50 |
+
def create_notebook(self, name: str, description: str = "") -> Notebook:
|
| 51 |
+
"""Create a new notebook."""
|
| 52 |
+
response = api_client.create_notebook(name, description)
|
| 53 |
+
nb_data = response if isinstance(response, dict) else response[0]
|
| 54 |
+
nb = Notebook(
|
| 55 |
+
name=nb_data["name"],
|
| 56 |
+
description=nb_data["description"],
|
| 57 |
+
archived=nb_data["archived"],
|
| 58 |
+
)
|
| 59 |
+
nb.id = nb_data["id"]
|
| 60 |
+
nb.created = nb_data["created"]
|
| 61 |
+
nb.updated = nb_data["updated"]
|
| 62 |
+
return nb
|
| 63 |
+
|
| 64 |
+
def update_notebook(self, notebook: Notebook) -> Notebook:
|
| 65 |
+
"""Update a notebook."""
|
| 66 |
+
updates = {
|
| 67 |
+
"name": notebook.name,
|
| 68 |
+
"description": notebook.description,
|
| 69 |
+
"archived": notebook.archived,
|
| 70 |
+
}
|
| 71 |
+
response = api_client.update_notebook(notebook.id or "", **updates)
|
| 72 |
+
nb_data = response if isinstance(response, dict) else response[0]
|
| 73 |
+
# Update the notebook object with the response
|
| 74 |
+
notebook.name = nb_data["name"]
|
| 75 |
+
notebook.description = nb_data["description"]
|
| 76 |
+
notebook.archived = nb_data["archived"]
|
| 77 |
+
notebook.updated = nb_data["updated"]
|
| 78 |
+
return notebook
|
| 79 |
+
|
| 80 |
+
def delete_notebook(self, notebook: Notebook) -> bool:
|
| 81 |
+
"""Delete a notebook."""
|
| 82 |
+
api_client.delete_notebook(notebook.id or "")
|
| 83 |
+
return True
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# Global service instance
|
| 87 |
+
notebook_service = NotebookService()
|
api/notes_service.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Notes service layer using API.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
|
| 7 |
+
from loguru import logger
|
| 8 |
+
|
| 9 |
+
from api.client import api_client
|
| 10 |
+
from open_notebook.domain.notebook import Note
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class NotesService:
|
| 14 |
+
"""Service layer for notes operations using API."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Using API for notes operations")
|
| 18 |
+
|
| 19 |
+
def get_all_notes(self, notebook_id: Optional[str] = None) -> List[Note]:
|
| 20 |
+
"""Get all notes with optional notebook filtering."""
|
| 21 |
+
notes_data = api_client.get_notes(notebook_id=notebook_id)
|
| 22 |
+
# Convert API response to Note objects
|
| 23 |
+
notes = []
|
| 24 |
+
for note_data in notes_data:
|
| 25 |
+
note = Note(
|
| 26 |
+
title=note_data["title"],
|
| 27 |
+
content=note_data["content"],
|
| 28 |
+
note_type=note_data["note_type"],
|
| 29 |
+
)
|
| 30 |
+
note.id = note_data["id"]
|
| 31 |
+
note.created = note_data["created"]
|
| 32 |
+
note.updated = note_data["updated"]
|
| 33 |
+
notes.append(note)
|
| 34 |
+
return notes
|
| 35 |
+
|
| 36 |
+
def get_note(self, note_id: str) -> Note:
|
| 37 |
+
"""Get a specific note."""
|
| 38 |
+
note_response = api_client.get_note(note_id)
|
| 39 |
+
note_data = (
|
| 40 |
+
note_response if isinstance(note_response, dict) else note_response[0]
|
| 41 |
+
)
|
| 42 |
+
note = Note(
|
| 43 |
+
title=note_data["title"],
|
| 44 |
+
content=note_data["content"],
|
| 45 |
+
note_type=note_data["note_type"],
|
| 46 |
+
)
|
| 47 |
+
note.id = note_data["id"]
|
| 48 |
+
note.created = note_data["created"]
|
| 49 |
+
note.updated = note_data["updated"]
|
| 50 |
+
return note
|
| 51 |
+
|
| 52 |
+
def create_note(
|
| 53 |
+
self,
|
| 54 |
+
content: str,
|
| 55 |
+
title: Optional[str] = None,
|
| 56 |
+
note_type: str = "human",
|
| 57 |
+
notebook_id: Optional[str] = None,
|
| 58 |
+
) -> Note:
|
| 59 |
+
"""Create a new note."""
|
| 60 |
+
note_response = api_client.create_note(
|
| 61 |
+
content=content, title=title, note_type=note_type, notebook_id=notebook_id
|
| 62 |
+
)
|
| 63 |
+
note_data = (
|
| 64 |
+
note_response if isinstance(note_response, dict) else note_response[0]
|
| 65 |
+
)
|
| 66 |
+
note = Note(
|
| 67 |
+
title=note_data["title"],
|
| 68 |
+
content=note_data["content"],
|
| 69 |
+
note_type=note_data["note_type"],
|
| 70 |
+
)
|
| 71 |
+
note.id = note_data["id"]
|
| 72 |
+
note.created = note_data["created"]
|
| 73 |
+
note.updated = note_data["updated"]
|
| 74 |
+
return note
|
| 75 |
+
|
| 76 |
+
def update_note(self, note: Note) -> Note:
|
| 77 |
+
"""Update a note."""
|
| 78 |
+
updates = {
|
| 79 |
+
"title": note.title,
|
| 80 |
+
"content": note.content,
|
| 81 |
+
"note_type": note.note_type,
|
| 82 |
+
}
|
| 83 |
+
note_response = api_client.update_note(note.id or "", **updates)
|
| 84 |
+
note_data = (
|
| 85 |
+
note_response if isinstance(note_response, dict) else note_response[0]
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Update the note object with the response
|
| 89 |
+
note.title = note_data["title"]
|
| 90 |
+
note.content = note_data["content"]
|
| 91 |
+
note.note_type = note_data["note_type"]
|
| 92 |
+
note.updated = note_data["updated"]
|
| 93 |
+
|
| 94 |
+
return note
|
| 95 |
+
|
| 96 |
+
def delete_note(self, note_id: str) -> bool:
|
| 97 |
+
"""Delete a note."""
|
| 98 |
+
api_client.delete_note(note_id)
|
| 99 |
+
return True
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# Global service instance
|
| 103 |
+
notes_service = NotesService()
|
api/podcast_api_service.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Podcast service layer using API client.
|
| 3 |
+
This replaces direct httpx calls in the Streamlit pages.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Any, Dict, List
|
| 7 |
+
|
| 8 |
+
from loguru import logger
|
| 9 |
+
|
| 10 |
+
from api.client import api_client
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class PodcastAPIService:
|
| 14 |
+
"""Service layer for podcast operations using API client."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
logger.info("Using API client for podcast operations")
|
| 18 |
+
|
| 19 |
+
# Episode methods
|
| 20 |
+
def get_episodes(self) -> List[Dict[Any, Any]]:
|
| 21 |
+
"""Get all podcast episodes."""
|
| 22 |
+
result = api_client._make_request("GET", "/api/podcasts/episodes")
|
| 23 |
+
return result if isinstance(result, list) else [result]
|
| 24 |
+
|
| 25 |
+
def delete_episode(self, episode_id: str) -> bool:
|
| 26 |
+
"""Delete a podcast episode."""
|
| 27 |
+
try:
|
| 28 |
+
api_client._make_request("DELETE", f"/api/podcasts/episodes/{episode_id}")
|
| 29 |
+
return True
|
| 30 |
+
except Exception as e:
|
| 31 |
+
logger.error(f"Failed to delete episode: {e}")
|
| 32 |
+
return False
|
| 33 |
+
|
| 34 |
+
# Episode Profile methods
|
| 35 |
+
def get_episode_profiles(self) -> List[Dict]:
|
| 36 |
+
"""Get all episode profiles."""
|
| 37 |
+
return api_client.get_episode_profiles()
|
| 38 |
+
|
| 39 |
+
def create_episode_profile(self, profile_data: Dict) -> bool:
|
| 40 |
+
"""Create a new episode profile."""
|
| 41 |
+
try:
|
| 42 |
+
api_client.create_episode_profile(**profile_data)
|
| 43 |
+
return True
|
| 44 |
+
except Exception as e:
|
| 45 |
+
logger.error(f"Failed to create episode profile: {e}")
|
| 46 |
+
return False
|
| 47 |
+
|
| 48 |
+
def update_episode_profile(self, profile_id: str, profile_data: Dict) -> bool:
|
| 49 |
+
"""Update an episode profile."""
|
| 50 |
+
try:
|
| 51 |
+
api_client.update_episode_profile(profile_id, **profile_data)
|
| 52 |
+
return True
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"Failed to update episode profile: {e}")
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
def delete_episode_profile(self, profile_id: str) -> bool:
|
| 58 |
+
"""Delete an episode profile."""
|
| 59 |
+
try:
|
| 60 |
+
api_client.delete_episode_profile(profile_id)
|
| 61 |
+
return True
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Failed to delete episode profile: {e}")
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
def duplicate_episode_profile(self, profile_id: str) -> bool:
|
| 67 |
+
"""Duplicate an episode profile."""
|
| 68 |
+
try:
|
| 69 |
+
api_client._make_request(
|
| 70 |
+
"POST", f"/api/episode-profiles/{profile_id}/duplicate"
|
| 71 |
+
)
|
| 72 |
+
return True
|
| 73 |
+
except Exception as e:
|
| 74 |
+
logger.error(f"Failed to duplicate episode profile: {e}")
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
# Speaker Profile methods
|
| 78 |
+
def get_speaker_profiles(self) -> List[Dict[Any, Any]]:
|
| 79 |
+
"""Get all speaker profiles."""
|
| 80 |
+
result = api_client._make_request("GET", "/api/speaker-profiles")
|
| 81 |
+
return result if isinstance(result, list) else [result]
|
| 82 |
+
|
| 83 |
+
def create_speaker_profile(self, profile_data: Dict) -> bool:
|
| 84 |
+
"""Create a new speaker profile."""
|
| 85 |
+
try:
|
| 86 |
+
api_client._make_request("POST", "/api/speaker-profiles", json=profile_data)
|
| 87 |
+
return True
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Failed to create speaker profile: {e}")
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
def update_speaker_profile(self, profile_id: str, profile_data: Dict) -> bool:
|
| 93 |
+
"""Update a speaker profile."""
|
| 94 |
+
try:
|
| 95 |
+
api_client._make_request(
|
| 96 |
+
"PUT", f"/api/speaker-profiles/{profile_id}", json=profile_data
|
| 97 |
+
)
|
| 98 |
+
return True
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Failed to update speaker profile: {e}")
|
| 101 |
+
return False
|
| 102 |
+
|
| 103 |
+
def delete_speaker_profile(self, profile_id: str) -> bool:
|
| 104 |
+
"""Delete a speaker profile."""
|
| 105 |
+
try:
|
| 106 |
+
api_client._make_request("DELETE", f"/api/speaker-profiles/{profile_id}")
|
| 107 |
+
return True
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Failed to delete speaker profile: {e}")
|
| 110 |
+
return False
|
| 111 |
+
|
| 112 |
+
def duplicate_speaker_profile(self, profile_id: str) -> bool:
|
| 113 |
+
"""Duplicate a speaker profile."""
|
| 114 |
+
try:
|
| 115 |
+
api_client._make_request(
|
| 116 |
+
"POST", f"/api/speaker-profiles/{profile_id}/duplicate"
|
| 117 |
+
)
|
| 118 |
+
return True
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Failed to duplicate speaker profile: {e}")
|
| 121 |
+
return False
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# Global service instance
|
| 125 |
+
podcast_api_service = PodcastAPIService()
|
api/podcast_service.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
from loguru import logger
|
| 5 |
+
from pydantic import BaseModel
|
| 6 |
+
from surreal_commands import get_command_status, submit_command
|
| 7 |
+
|
| 8 |
+
from open_notebook.domain.notebook import Notebook
|
| 9 |
+
from open_notebook.podcasts.models import EpisodeProfile, PodcastEpisode, SpeakerProfile
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class PodcastGenerationRequest(BaseModel):
|
| 13 |
+
"""Request model for podcast generation"""
|
| 14 |
+
|
| 15 |
+
episode_profile: str
|
| 16 |
+
speaker_profile: str
|
| 17 |
+
episode_name: str
|
| 18 |
+
content: Optional[str] = None
|
| 19 |
+
notebook_id: Optional[str] = None
|
| 20 |
+
briefing_suffix: Optional[str] = None
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class PodcastGenerationResponse(BaseModel):
|
| 24 |
+
"""Response model for podcast generation"""
|
| 25 |
+
|
| 26 |
+
job_id: str
|
| 27 |
+
status: str
|
| 28 |
+
message: str
|
| 29 |
+
episode_profile: str
|
| 30 |
+
episode_name: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class PodcastService:
|
| 34 |
+
"""Service layer for podcast operations"""
|
| 35 |
+
|
| 36 |
+
@staticmethod
|
| 37 |
+
async def submit_generation_job(
|
| 38 |
+
episode_profile_name: str,
|
| 39 |
+
speaker_profile_name: str,
|
| 40 |
+
episode_name: str,
|
| 41 |
+
notebook_id: Optional[str] = None,
|
| 42 |
+
content: Optional[str] = None,
|
| 43 |
+
briefing_suffix: Optional[str] = None,
|
| 44 |
+
) -> str:
|
| 45 |
+
"""Submit a podcast generation job for background processing"""
|
| 46 |
+
try:
|
| 47 |
+
# Validate episode profile exists
|
| 48 |
+
episode_profile = await EpisodeProfile.get_by_name(episode_profile_name)
|
| 49 |
+
if not episode_profile:
|
| 50 |
+
raise ValueError(f"Episode profile '{episode_profile_name}' not found")
|
| 51 |
+
|
| 52 |
+
# Validate speaker profile exists
|
| 53 |
+
speaker_profile = await SpeakerProfile.get_by_name(speaker_profile_name)
|
| 54 |
+
if not speaker_profile:
|
| 55 |
+
raise ValueError(f"Speaker profile '{speaker_profile_name}' not found")
|
| 56 |
+
|
| 57 |
+
# Get content from notebook if not provided directly
|
| 58 |
+
if not content and notebook_id:
|
| 59 |
+
try:
|
| 60 |
+
notebook = await Notebook.get(notebook_id)
|
| 61 |
+
# Get notebook context (this may need to be adjusted based on actual Notebook implementation)
|
| 62 |
+
content = (
|
| 63 |
+
await notebook.get_context()
|
| 64 |
+
if hasattr(notebook, "get_context")
|
| 65 |
+
else str(notebook)
|
| 66 |
+
)
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.warning(
|
| 69 |
+
f"Failed to get notebook content, using notebook_id as content: {e}"
|
| 70 |
+
)
|
| 71 |
+
content = f"Notebook ID: {notebook_id}"
|
| 72 |
+
|
| 73 |
+
if not content:
|
| 74 |
+
raise ValueError(
|
| 75 |
+
"Content is required - provide either content or notebook_id"
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Prepare command arguments
|
| 79 |
+
command_args = {
|
| 80 |
+
"episode_profile": episode_profile_name,
|
| 81 |
+
"speaker_profile": speaker_profile_name,
|
| 82 |
+
"episode_name": episode_name,
|
| 83 |
+
"content": str(content),
|
| 84 |
+
"briefing_suffix": briefing_suffix,
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
# Ensure command modules are imported before submitting
|
| 88 |
+
# This is needed because submit_command validates against local registry
|
| 89 |
+
try:
|
| 90 |
+
import commands.podcast_commands # noqa: F401
|
| 91 |
+
except ImportError as import_err:
|
| 92 |
+
logger.error(f"Failed to import podcast commands: {import_err}")
|
| 93 |
+
raise ValueError("Podcast commands not available")
|
| 94 |
+
|
| 95 |
+
# Submit command to surreal-commands
|
| 96 |
+
job_id = submit_command("open_notebook", "generate_podcast", command_args)
|
| 97 |
+
|
| 98 |
+
# Convert RecordID to string if needed
|
| 99 |
+
if not job_id:
|
| 100 |
+
raise ValueError("Failed to get job_id from submit_command")
|
| 101 |
+
job_id_str = str(job_id)
|
| 102 |
+
logger.info(
|
| 103 |
+
f"Submitted podcast generation job: {job_id_str} for episode '{episode_name}'"
|
| 104 |
+
)
|
| 105 |
+
return job_id_str
|
| 106 |
+
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Failed to submit podcast generation job: {e}")
|
| 109 |
+
raise HTTPException(
|
| 110 |
+
status_code=500,
|
| 111 |
+
detail=f"Failed to submit podcast generation job: {str(e)}",
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
@staticmethod
|
| 115 |
+
async def get_job_status(job_id: str) -> Dict[str, Any]:
|
| 116 |
+
"""Get status of a podcast generation job"""
|
| 117 |
+
try:
|
| 118 |
+
status = await get_command_status(job_id)
|
| 119 |
+
return {
|
| 120 |
+
"job_id": job_id,
|
| 121 |
+
"status": status.status if status else "unknown",
|
| 122 |
+
"result": status.result if status else None,
|
| 123 |
+
"error_message": getattr(status, "error_message", None)
|
| 124 |
+
if status
|
| 125 |
+
else None,
|
| 126 |
+
"created": str(status.created)
|
| 127 |
+
if status and hasattr(status, "created") and status.created
|
| 128 |
+
else None,
|
| 129 |
+
"updated": str(status.updated)
|
| 130 |
+
if status and hasattr(status, "updated") and status.updated
|
| 131 |
+
else None,
|
| 132 |
+
"progress": getattr(status, "progress", None) if status else None,
|
| 133 |
+
}
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.error(f"Failed to get podcast job status: {e}")
|
| 136 |
+
raise HTTPException(
|
| 137 |
+
status_code=500, detail=f"Failed to get job status: {str(e)}"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
@staticmethod
|
| 141 |
+
async def list_episodes() -> list:
|
| 142 |
+
"""List all podcast episodes"""
|
| 143 |
+
try:
|
| 144 |
+
episodes = await PodcastEpisode.get_all(order_by="created desc")
|
| 145 |
+
return episodes
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Failed to list podcast episodes: {e}")
|
| 148 |
+
raise HTTPException(
|
| 149 |
+
status_code=500, detail=f"Failed to list episodes: {str(e)}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
@staticmethod
|
| 153 |
+
async def get_episode(episode_id: str) -> PodcastEpisode:
|
| 154 |
+
"""Get a specific podcast episode"""
|
| 155 |
+
try:
|
| 156 |
+
episode = await PodcastEpisode.get(episode_id)
|
| 157 |
+
return episode
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Failed to get podcast episode {episode_id}: {e}")
|
| 160 |
+
raise HTTPException(status_code=404, detail=f"Episode not found: {str(e)}")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class DefaultProfiles:
|
| 164 |
+
"""Utility class for creating default profiles (if needed beyond migration data)"""
|
| 165 |
+
|
| 166 |
+
@staticmethod
|
| 167 |
+
async def create_default_episode_profiles():
|
| 168 |
+
"""Create default episode profiles if they don't exist"""
|
| 169 |
+
try:
|
| 170 |
+
# Check if profiles already exist
|
| 171 |
+
existing = await EpisodeProfile.get_all()
|
| 172 |
+
if existing:
|
| 173 |
+
logger.info(f"Episode profiles already exist: {len(existing)} found")
|
| 174 |
+
return existing
|
| 175 |
+
|
| 176 |
+
# This would create profiles, but since we have migration data,
|
| 177 |
+
# this is mainly for future extensibility
|
| 178 |
+
logger.info(
|
| 179 |
+
"Default episode profiles should be created via database migration"
|
| 180 |
+
)
|
| 181 |
+
return []
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.error(f"Failed to create default episode profiles: {e}")
|
| 185 |
+
raise
|
| 186 |
+
|
| 187 |
+
@staticmethod
|
| 188 |
+
async def create_default_speaker_profiles():
|
| 189 |
+
"""Create default speaker profiles if they don't exist"""
|
| 190 |
+
try:
|
| 191 |
+
# Check if profiles already exist
|
| 192 |
+
existing = await SpeakerProfile.get_all()
|
| 193 |
+
if existing:
|
| 194 |
+
logger.info(f"Speaker profiles already exist: {len(existing)} found")
|
| 195 |
+
return existing
|
| 196 |
+
|
| 197 |
+
# This would create profiles, but since we have migration data,
|
| 198 |
+
# this is mainly for future extensibility
|
| 199 |
+
logger.info(
|
| 200 |
+
"Default speaker profiles should be created via database migration"
|
| 201 |
+
)
|
| 202 |
+
return []
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
logger.error(f"Failed to create default speaker profiles: {e}")
|
| 206 |
+
raise
|
api/routers/__init__.py
ADDED
|
File without changes
|
api/routers/auth.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication router for Open Notebook API.
|
| 3 |
+
Provides endpoints to check authentication status.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from fastapi import APIRouter
|
| 7 |
+
|
| 8 |
+
from open_notebook.utils.encryption import get_secret_from_env
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/status")
|
| 14 |
+
async def get_auth_status():
|
| 15 |
+
"""
|
| 16 |
+
Check if authentication is enabled.
|
| 17 |
+
Returns whether a password is required to access the API.
|
| 18 |
+
Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
|
| 19 |
+
"""
|
| 20 |
+
auth_enabled = bool(get_secret_from_env("OPEN_NOTEBOOK_PASSWORD"))
|
| 21 |
+
|
| 22 |
+
return {
|
| 23 |
+
"auth_enabled": auth_enabled,
|
| 24 |
+
"message": "Authentication is required"
|
| 25 |
+
if auth_enabled
|
| 26 |
+
else "Authentication is disabled",
|
| 27 |
+
}
|
api/routers/chat.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import traceback
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 6 |
+
from langchain_core.runnables import RunnableConfig
|
| 7 |
+
from loguru import logger
|
| 8 |
+
from pydantic import BaseModel, Field
|
| 9 |
+
|
| 10 |
+
from open_notebook.database.repository import ensure_record_id, repo_query
|
| 11 |
+
from open_notebook.domain.notebook import ChatSession, Note, Notebook, Source
|
| 12 |
+
from open_notebook.exceptions import (
|
| 13 |
+
NotFoundError,
|
| 14 |
+
)
|
| 15 |
+
from open_notebook.graphs.chat import graph as chat_graph
|
| 16 |
+
from open_notebook.utils.graph_utils import get_session_message_count
|
| 17 |
+
|
| 18 |
+
router = APIRouter()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# Request/Response models
|
| 22 |
+
class CreateSessionRequest(BaseModel):
|
| 23 |
+
notebook_id: str = Field(..., description="Notebook ID to create session for")
|
| 24 |
+
title: Optional[str] = Field(None, description="Optional session title")
|
| 25 |
+
model_override: Optional[str] = Field(
|
| 26 |
+
None, description="Optional model override for this session"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class UpdateSessionRequest(BaseModel):
|
| 31 |
+
title: Optional[str] = Field(None, description="New session title")
|
| 32 |
+
model_override: Optional[str] = Field(
|
| 33 |
+
None, description="Model override for this session"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ChatMessage(BaseModel):
|
| 38 |
+
id: str = Field(..., description="Message ID")
|
| 39 |
+
type: str = Field(..., description="Message type (human|ai)")
|
| 40 |
+
content: str = Field(..., description="Message content")
|
| 41 |
+
timestamp: Optional[str] = Field(None, description="Message timestamp")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ChatSessionResponse(BaseModel):
|
| 45 |
+
id: str = Field(..., description="Session ID")
|
| 46 |
+
title: str = Field(..., description="Session title")
|
| 47 |
+
notebook_id: Optional[str] = Field(None, description="Notebook ID")
|
| 48 |
+
created: str = Field(..., description="Creation timestamp")
|
| 49 |
+
updated: str = Field(..., description="Last update timestamp")
|
| 50 |
+
message_count: Optional[int] = Field(
|
| 51 |
+
None, description="Number of messages in session"
|
| 52 |
+
)
|
| 53 |
+
model_override: Optional[str] = Field(
|
| 54 |
+
None, description="Model override for this session"
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ChatSessionWithMessagesResponse(ChatSessionResponse):
|
| 59 |
+
messages: List[ChatMessage] = Field(
|
| 60 |
+
default_factory=list, description="Session messages"
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ExecuteChatRequest(BaseModel):
|
| 65 |
+
session_id: str = Field(..., description="Chat session ID")
|
| 66 |
+
message: str = Field(..., description="User message content")
|
| 67 |
+
context: Dict[str, Any] = Field(
|
| 68 |
+
..., description="Chat context with sources and notes"
|
| 69 |
+
)
|
| 70 |
+
model_override: Optional[str] = Field(
|
| 71 |
+
None, description="Optional model override for this message"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ExecuteChatResponse(BaseModel):
|
| 76 |
+
session_id: str = Field(..., description="Session ID")
|
| 77 |
+
messages: List[ChatMessage] = Field(..., description="Updated message list")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class BuildContextRequest(BaseModel):
|
| 81 |
+
notebook_id: str = Field(..., description="Notebook ID")
|
| 82 |
+
context_config: Dict[str, Any] = Field(..., description="Context configuration")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class BuildContextResponse(BaseModel):
|
| 86 |
+
context: Dict[str, Any] = Field(..., description="Built context data")
|
| 87 |
+
token_count: int = Field(..., description="Estimated token count")
|
| 88 |
+
char_count: int = Field(..., description="Character count")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class SuccessResponse(BaseModel):
|
| 92 |
+
success: bool = Field(True, description="Operation success status")
|
| 93 |
+
message: str = Field(..., description="Success message")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
@router.get("/chat/sessions", response_model=List[ChatSessionResponse])
|
| 97 |
+
async def get_sessions(notebook_id: str = Query(..., description="Notebook ID")):
|
| 98 |
+
"""Get all chat sessions for a notebook."""
|
| 99 |
+
try:
|
| 100 |
+
# Get notebook to verify it exists
|
| 101 |
+
notebook = await Notebook.get(notebook_id)
|
| 102 |
+
if not notebook:
|
| 103 |
+
raise HTTPException(status_code=404, detail="Notebook not found")
|
| 104 |
+
|
| 105 |
+
# Get sessions for this notebook
|
| 106 |
+
sessions_list = await notebook.get_chat_sessions()
|
| 107 |
+
|
| 108 |
+
results = []
|
| 109 |
+
for session in sessions_list:
|
| 110 |
+
session_id = str(session.id)
|
| 111 |
+
|
| 112 |
+
# Get message count from LangGraph state
|
| 113 |
+
msg_count = await get_session_message_count(chat_graph, session_id)
|
| 114 |
+
|
| 115 |
+
results.append(
|
| 116 |
+
ChatSessionResponse(
|
| 117 |
+
id=session.id or "",
|
| 118 |
+
title=session.title or "Untitled Session",
|
| 119 |
+
notebook_id=notebook_id,
|
| 120 |
+
created=str(session.created),
|
| 121 |
+
updated=str(session.updated),
|
| 122 |
+
message_count=msg_count,
|
| 123 |
+
model_override=getattr(session, "model_override", None),
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
return results
|
| 128 |
+
except NotFoundError:
|
| 129 |
+
raise HTTPException(status_code=404, detail="Notebook not found")
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Error fetching chat sessions: {str(e)}")
|
| 132 |
+
raise HTTPException(
|
| 133 |
+
status_code=500, detail=f"Error fetching chat sessions: {str(e)}"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
@router.post("/chat/sessions", response_model=ChatSessionResponse)
|
| 138 |
+
async def create_session(request: CreateSessionRequest):
|
| 139 |
+
"""Create a new chat session."""
|
| 140 |
+
try:
|
| 141 |
+
# Verify notebook exists
|
| 142 |
+
notebook = await Notebook.get(request.notebook_id)
|
| 143 |
+
if not notebook:
|
| 144 |
+
raise HTTPException(status_code=404, detail="Notebook not found")
|
| 145 |
+
|
| 146 |
+
# Create new session
|
| 147 |
+
session = ChatSession(
|
| 148 |
+
title=request.title
|
| 149 |
+
or f"Chat Session {asyncio.get_event_loop().time():.0f}",
|
| 150 |
+
model_override=request.model_override,
|
| 151 |
+
)
|
| 152 |
+
await session.save()
|
| 153 |
+
|
| 154 |
+
# Relate session to notebook
|
| 155 |
+
await session.relate_to_notebook(request.notebook_id)
|
| 156 |
+
|
| 157 |
+
return ChatSessionResponse(
|
| 158 |
+
id=session.id or "",
|
| 159 |
+
title=session.title or "",
|
| 160 |
+
notebook_id=request.notebook_id,
|
| 161 |
+
created=str(session.created),
|
| 162 |
+
updated=str(session.updated),
|
| 163 |
+
message_count=0,
|
| 164 |
+
model_override=session.model_override,
|
| 165 |
+
)
|
| 166 |
+
except NotFoundError:
|
| 167 |
+
raise HTTPException(status_code=404, detail="Notebook not found")
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Error creating chat session: {str(e)}")
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=500, detail=f"Error creating chat session: {str(e)}"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@router.get(
|
| 176 |
+
"/chat/sessions/{session_id}", response_model=ChatSessionWithMessagesResponse
|
| 177 |
+
)
|
| 178 |
+
async def get_session(session_id: str):
|
| 179 |
+
"""Get a specific session with its messages."""
|
| 180 |
+
try:
|
| 181 |
+
# Get session
|
| 182 |
+
# Ensure session_id has proper table prefix
|
| 183 |
+
full_session_id = (
|
| 184 |
+
session_id
|
| 185 |
+
if session_id.startswith("chat_session:")
|
| 186 |
+
else f"chat_session:{session_id}"
|
| 187 |
+
)
|
| 188 |
+
session = await ChatSession.get(full_session_id)
|
| 189 |
+
if not session:
|
| 190 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 191 |
+
|
| 192 |
+
# Get session state from LangGraph to retrieve messages
|
| 193 |
+
# Use sync get_state() in a thread since SqliteSaver doesn't support async
|
| 194 |
+
thread_state = await asyncio.to_thread(
|
| 195 |
+
chat_graph.get_state,
|
| 196 |
+
config=RunnableConfig(configurable={"thread_id": full_session_id}),
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
# Extract messages from state
|
| 200 |
+
messages: list[ChatMessage] = []
|
| 201 |
+
if thread_state and thread_state.values and "messages" in thread_state.values:
|
| 202 |
+
for msg in thread_state.values["messages"]:
|
| 203 |
+
messages.append(
|
| 204 |
+
ChatMessage(
|
| 205 |
+
id=getattr(msg, "id", f"msg_{len(messages)}"),
|
| 206 |
+
type=msg.type if hasattr(msg, "type") else "unknown",
|
| 207 |
+
content=msg.content if hasattr(msg, "content") else str(msg),
|
| 208 |
+
timestamp=None, # LangChain messages don't have timestamps by default
|
| 209 |
+
)
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
# Find notebook_id (we need to query the relationship)
|
| 213 |
+
# Ensure session_id has proper table prefix
|
| 214 |
+
full_session_id = (
|
| 215 |
+
session_id
|
| 216 |
+
if session_id.startswith("chat_session:")
|
| 217 |
+
else f"chat_session:{session_id}"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
notebook_query = await repo_query(
|
| 221 |
+
"SELECT out FROM refers_to WHERE in = $session_id",
|
| 222 |
+
{"session_id": ensure_record_id(full_session_id)},
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
notebook_id = notebook_query[0]["out"] if notebook_query else None
|
| 226 |
+
|
| 227 |
+
if not notebook_id:
|
| 228 |
+
# This might be an old session created before API migration
|
| 229 |
+
logger.warning(
|
| 230 |
+
f"No notebook relationship found for session {session_id} - may be an orphaned session"
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
return ChatSessionWithMessagesResponse(
|
| 234 |
+
id=session.id or "",
|
| 235 |
+
title=session.title or "Untitled Session",
|
| 236 |
+
notebook_id=notebook_id,
|
| 237 |
+
created=str(session.created),
|
| 238 |
+
updated=str(session.updated),
|
| 239 |
+
message_count=len(messages),
|
| 240 |
+
messages=messages,
|
| 241 |
+
model_override=getattr(session, "model_override", None),
|
| 242 |
+
)
|
| 243 |
+
except NotFoundError:
|
| 244 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"Error fetching session: {str(e)}")
|
| 247 |
+
raise HTTPException(status_code=500, detail=f"Error fetching session: {str(e)}")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@router.put("/chat/sessions/{session_id}", response_model=ChatSessionResponse)
|
| 251 |
+
async def update_session(session_id: str, request: UpdateSessionRequest):
|
| 252 |
+
"""Update session title."""
|
| 253 |
+
try:
|
| 254 |
+
# Ensure session_id has proper table prefix
|
| 255 |
+
full_session_id = (
|
| 256 |
+
session_id
|
| 257 |
+
if session_id.startswith("chat_session:")
|
| 258 |
+
else f"chat_session:{session_id}"
|
| 259 |
+
)
|
| 260 |
+
session = await ChatSession.get(full_session_id)
|
| 261 |
+
if not session:
|
| 262 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 263 |
+
|
| 264 |
+
update_data = request.model_dump(exclude_unset=True)
|
| 265 |
+
|
| 266 |
+
if "title" in update_data:
|
| 267 |
+
session.title = update_data["title"]
|
| 268 |
+
|
| 269 |
+
if "model_override" in update_data:
|
| 270 |
+
session.model_override = update_data["model_override"]
|
| 271 |
+
|
| 272 |
+
await session.save()
|
| 273 |
+
|
| 274 |
+
# Find notebook_id
|
| 275 |
+
# Ensure session_id has proper table prefix
|
| 276 |
+
full_session_id = (
|
| 277 |
+
session_id
|
| 278 |
+
if session_id.startswith("chat_session:")
|
| 279 |
+
else f"chat_session:{session_id}"
|
| 280 |
+
)
|
| 281 |
+
notebook_query = await repo_query(
|
| 282 |
+
"SELECT out FROM refers_to WHERE in = $session_id",
|
| 283 |
+
{"session_id": ensure_record_id(full_session_id)},
|
| 284 |
+
)
|
| 285 |
+
notebook_id = notebook_query[0]["out"] if notebook_query else None
|
| 286 |
+
|
| 287 |
+
# Get message count from LangGraph state
|
| 288 |
+
msg_count = await get_session_message_count(chat_graph, full_session_id)
|
| 289 |
+
|
| 290 |
+
return ChatSessionResponse(
|
| 291 |
+
id=session.id or "",
|
| 292 |
+
title=session.title or "",
|
| 293 |
+
notebook_id=notebook_id,
|
| 294 |
+
created=str(session.created),
|
| 295 |
+
updated=str(session.updated),
|
| 296 |
+
message_count=msg_count,
|
| 297 |
+
model_override=session.model_override,
|
| 298 |
+
)
|
| 299 |
+
except NotFoundError:
|
| 300 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 301 |
+
except Exception as e:
|
| 302 |
+
logger.error(f"Error updating session: {str(e)}")
|
| 303 |
+
raise HTTPException(status_code=500, detail=f"Error updating session: {str(e)}")
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
@router.delete("/chat/sessions/{session_id}", response_model=SuccessResponse)
|
| 307 |
+
async def delete_session(session_id: str):
|
| 308 |
+
"""Delete a chat session."""
|
| 309 |
+
try:
|
| 310 |
+
# Ensure session_id has proper table prefix
|
| 311 |
+
full_session_id = (
|
| 312 |
+
session_id
|
| 313 |
+
if session_id.startswith("chat_session:")
|
| 314 |
+
else f"chat_session:{session_id}"
|
| 315 |
+
)
|
| 316 |
+
session = await ChatSession.get(full_session_id)
|
| 317 |
+
if not session:
|
| 318 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 319 |
+
|
| 320 |
+
await session.delete()
|
| 321 |
+
|
| 322 |
+
return SuccessResponse(success=True, message="Session deleted successfully")
|
| 323 |
+
except NotFoundError:
|
| 324 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 325 |
+
except Exception as e:
|
| 326 |
+
logger.error(f"Error deleting session: {str(e)}")
|
| 327 |
+
raise HTTPException(status_code=500, detail=f"Error deleting session: {str(e)}")
|
| 328 |
+
|
| 329 |
+
|
| 330 |
+
@router.post("/chat/execute", response_model=ExecuteChatResponse)
|
| 331 |
+
async def execute_chat(request: ExecuteChatRequest):
|
| 332 |
+
"""Execute a chat request and get AI response."""
|
| 333 |
+
try:
|
| 334 |
+
# Verify session exists
|
| 335 |
+
# Ensure session_id has proper table prefix
|
| 336 |
+
full_session_id = (
|
| 337 |
+
request.session_id
|
| 338 |
+
if request.session_id.startswith("chat_session:")
|
| 339 |
+
else f"chat_session:{request.session_id}"
|
| 340 |
+
)
|
| 341 |
+
session = await ChatSession.get(full_session_id)
|
| 342 |
+
if not session:
|
| 343 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 344 |
+
|
| 345 |
+
# Fetch notebook linked to this session
|
| 346 |
+
notebook_query = await repo_query(
|
| 347 |
+
"SELECT out FROM refers_to WHERE in = $session_id",
|
| 348 |
+
{"session_id": ensure_record_id(full_session_id)},
|
| 349 |
+
)
|
| 350 |
+
notebook = None
|
| 351 |
+
if notebook_query:
|
| 352 |
+
notebook = await Notebook.get(notebook_query[0]["out"])
|
| 353 |
+
|
| 354 |
+
# Determine model override (per-request override takes precedence over session-level)
|
| 355 |
+
model_override = (
|
| 356 |
+
request.model_override
|
| 357 |
+
if request.model_override is not None
|
| 358 |
+
else getattr(session, "model_override", None)
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
# Get current state
|
| 362 |
+
# Use sync get_state() in a thread since SqliteSaver doesn't support async
|
| 363 |
+
current_state = await asyncio.to_thread(
|
| 364 |
+
chat_graph.get_state,
|
| 365 |
+
config=RunnableConfig(configurable={"thread_id": full_session_id}),
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
# Prepare state for execution
|
| 369 |
+
state_values = current_state.values if current_state else {}
|
| 370 |
+
state_values["messages"] = state_values.get("messages", [])
|
| 371 |
+
state_values["context"] = request.context
|
| 372 |
+
state_values["notebook"] = notebook
|
| 373 |
+
state_values["model_override"] = model_override
|
| 374 |
+
|
| 375 |
+
# Add user message to state
|
| 376 |
+
from langchain_core.messages import HumanMessage
|
| 377 |
+
|
| 378 |
+
user_message = HumanMessage(content=request.message)
|
| 379 |
+
state_values["messages"].append(user_message)
|
| 380 |
+
|
| 381 |
+
# Execute chat graph
|
| 382 |
+
result = chat_graph.invoke(
|
| 383 |
+
input=state_values, # type: ignore[arg-type]
|
| 384 |
+
config=RunnableConfig(
|
| 385 |
+
configurable={
|
| 386 |
+
"thread_id": full_session_id,
|
| 387 |
+
"model_id": model_override,
|
| 388 |
+
}
|
| 389 |
+
),
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
# Update session timestamp
|
| 393 |
+
await session.save()
|
| 394 |
+
|
| 395 |
+
# Convert messages to response format
|
| 396 |
+
messages: list[ChatMessage] = []
|
| 397 |
+
for msg in result.get("messages", []):
|
| 398 |
+
messages.append(
|
| 399 |
+
ChatMessage(
|
| 400 |
+
id=getattr(msg, "id", f"msg_{len(messages)}"),
|
| 401 |
+
type=msg.type if hasattr(msg, "type") else "unknown",
|
| 402 |
+
content=msg.content if hasattr(msg, "content") else str(msg),
|
| 403 |
+
timestamp=None,
|
| 404 |
+
)
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
return ExecuteChatResponse(session_id=request.session_id, messages=messages)
|
| 408 |
+
except NotFoundError:
|
| 409 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 410 |
+
except Exception as e:
|
| 411 |
+
# Log detailed error with context for debugging
|
| 412 |
+
logger.error(
|
| 413 |
+
f"Error executing chat: {str(e)}\n"
|
| 414 |
+
f" Session ID: {request.session_id}\n"
|
| 415 |
+
f" Model override: {request.model_override}\n"
|
| 416 |
+
f" Traceback:\n{traceback.format_exc()}"
|
| 417 |
+
)
|
| 418 |
+
raise HTTPException(status_code=500, detail=f"Error executing chat: {str(e)}")
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
@router.post("/chat/context", response_model=BuildContextResponse)
|
| 422 |
+
async def build_context(request: BuildContextRequest):
|
| 423 |
+
"""Build context for a notebook based on context configuration."""
|
| 424 |
+
try:
|
| 425 |
+
# Verify notebook exists
|
| 426 |
+
notebook = await Notebook.get(request.notebook_id)
|
| 427 |
+
if not notebook:
|
| 428 |
+
raise HTTPException(status_code=404, detail="Notebook not found")
|
| 429 |
+
|
| 430 |
+
context_data: dict[str, list[dict[str, str]]] = {"sources": [], "notes": []}
|
| 431 |
+
total_content = ""
|
| 432 |
+
|
| 433 |
+
# Process context configuration if provided
|
| 434 |
+
if request.context_config:
|
| 435 |
+
# Process sources
|
| 436 |
+
for source_id, status in request.context_config.get("sources", {}).items():
|
| 437 |
+
if "not in" in status:
|
| 438 |
+
continue
|
| 439 |
+
|
| 440 |
+
try:
|
| 441 |
+
# Add table prefix if not present
|
| 442 |
+
full_source_id = (
|
| 443 |
+
source_id
|
| 444 |
+
if source_id.startswith("source:")
|
| 445 |
+
else f"source:{source_id}"
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
try:
|
| 449 |
+
source = await Source.get(full_source_id)
|
| 450 |
+
except Exception:
|
| 451 |
+
continue
|
| 452 |
+
|
| 453 |
+
if "insights" in status:
|
| 454 |
+
source_context = await source.get_context(context_size="short")
|
| 455 |
+
context_data["sources"].append(source_context)
|
| 456 |
+
total_content += str(source_context)
|
| 457 |
+
elif "full content" in status:
|
| 458 |
+
source_context = await source.get_context(context_size="long")
|
| 459 |
+
context_data["sources"].append(source_context)
|
| 460 |
+
total_content += str(source_context)
|
| 461 |
+
except Exception as e:
|
| 462 |
+
logger.warning(f"Error processing source {source_id}: {str(e)}")
|
| 463 |
+
continue
|
| 464 |
+
|
| 465 |
+
# Process notes
|
| 466 |
+
for note_id, status in request.context_config.get("notes", {}).items():
|
| 467 |
+
if "not in" in status:
|
| 468 |
+
continue
|
| 469 |
+
|
| 470 |
+
try:
|
| 471 |
+
# Add table prefix if not present
|
| 472 |
+
full_note_id = (
|
| 473 |
+
note_id if note_id.startswith("note:") else f"note:{note_id}"
|
| 474 |
+
)
|
| 475 |
+
note = await Note.get(full_note_id)
|
| 476 |
+
if not note:
|
| 477 |
+
continue
|
| 478 |
+
|
| 479 |
+
if "full content" in status:
|
| 480 |
+
note_context = note.get_context(context_size="long")
|
| 481 |
+
context_data["notes"].append(note_context)
|
| 482 |
+
total_content += str(note_context)
|
| 483 |
+
except Exception as e:
|
| 484 |
+
logger.warning(f"Error processing note {note_id}: {str(e)}")
|
| 485 |
+
continue
|
| 486 |
+
else:
|
| 487 |
+
# Default behavior - include all sources and notes with short context
|
| 488 |
+
sources = await notebook.get_sources()
|
| 489 |
+
for source in sources:
|
| 490 |
+
try:
|
| 491 |
+
source_context = await source.get_context(context_size="short")
|
| 492 |
+
context_data["sources"].append(source_context)
|
| 493 |
+
total_content += str(source_context)
|
| 494 |
+
except Exception as e:
|
| 495 |
+
logger.warning(f"Error processing source {source.id}: {str(e)}")
|
| 496 |
+
continue
|
| 497 |
+
|
| 498 |
+
notes = await notebook.get_notes()
|
| 499 |
+
for note in notes:
|
| 500 |
+
try:
|
| 501 |
+
note_context = note.get_context(context_size="short")
|
| 502 |
+
context_data["notes"].append(note_context)
|
| 503 |
+
total_content += str(note_context)
|
| 504 |
+
except Exception as e:
|
| 505 |
+
logger.warning(f"Error processing note {note.id}: {str(e)}")
|
| 506 |
+
continue
|
| 507 |
+
|
| 508 |
+
# Calculate character and token counts
|
| 509 |
+
char_count = len(total_content)
|
| 510 |
+
# Use token count utility if available
|
| 511 |
+
try:
|
| 512 |
+
from open_notebook.utils import token_count
|
| 513 |
+
|
| 514 |
+
estimated_tokens = token_count(total_content) if total_content else 0
|
| 515 |
+
except ImportError:
|
| 516 |
+
# Fallback to simple estimation
|
| 517 |
+
estimated_tokens = char_count // 4
|
| 518 |
+
|
| 519 |
+
return BuildContextResponse(
|
| 520 |
+
context=context_data, token_count=estimated_tokens, char_count=char_count
|
| 521 |
+
)
|
| 522 |
+
except HTTPException:
|
| 523 |
+
raise
|
| 524 |
+
except Exception as e:
|
| 525 |
+
logger.error(f"Error building context: {str(e)}")
|
| 526 |
+
raise HTTPException(status_code=500, detail=f"Error building context: {str(e)}")
|
api/routers/commands.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Dict, List, Optional
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, HTTPException, Query
|
| 4 |
+
from loguru import logger
|
| 5 |
+
from pydantic import BaseModel, Field
|
| 6 |
+
from surreal_commands import registry
|
| 7 |
+
|
| 8 |
+
from api.command_service import CommandService
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class CommandExecutionRequest(BaseModel):
|
| 14 |
+
command: str = Field(
|
| 15 |
+
..., description="Command function name (e.g., 'process_text')"
|
| 16 |
+
)
|
| 17 |
+
app: str = Field(..., description="Application name (e.g., 'open_notebook')")
|
| 18 |
+
input: Dict[str, Any] = Field(..., description="Arguments to pass to the command")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CommandJobResponse(BaseModel):
|
| 22 |
+
job_id: str
|
| 23 |
+
status: str
|
| 24 |
+
message: str
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CommandJobStatusResponse(BaseModel):
|
| 28 |
+
job_id: str
|
| 29 |
+
status: str
|
| 30 |
+
result: Optional[Dict[str, Any]] = None
|
| 31 |
+
error_message: Optional[str] = None
|
| 32 |
+
created: Optional[str] = None
|
| 33 |
+
updated: Optional[str] = None
|
| 34 |
+
progress: Optional[Dict[str, Any]] = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.post("/commands/jobs", response_model=CommandJobResponse)
|
| 38 |
+
async def execute_command(request: CommandExecutionRequest):
|
| 39 |
+
"""
|
| 40 |
+
Submit a command for background processing.
|
| 41 |
+
Returns immediately with job ID for status tracking.
|
| 42 |
+
|
| 43 |
+
Example request:
|
| 44 |
+
{
|
| 45 |
+
"command": "process_text",
|
| 46 |
+
"app": "open_notebook",
|
| 47 |
+
"input": {
|
| 48 |
+
"text": "Hello world",
|
| 49 |
+
"operation": "uppercase"
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
"""
|
| 53 |
+
try:
|
| 54 |
+
# Submit command using app name (not module name)
|
| 55 |
+
job_id = await CommandService.submit_command_job(
|
| 56 |
+
module_name=request.app, # This should be "open_notebook"
|
| 57 |
+
command_name=request.command,
|
| 58 |
+
command_args=request.input,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
return CommandJobResponse(
|
| 62 |
+
job_id=job_id,
|
| 63 |
+
status="submitted",
|
| 64 |
+
message=f"Command '{request.command}' submitted successfully",
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
except Exception as e:
|
| 68 |
+
logger.error(f"Error submitting command: {str(e)}")
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=500, detail="Failed to submit command"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@router.get("/commands/jobs/{job_id}", response_model=CommandJobStatusResponse)
|
| 75 |
+
async def get_command_job_status(job_id: str):
|
| 76 |
+
"""Get the status of a specific command job"""
|
| 77 |
+
try:
|
| 78 |
+
status_data = await CommandService.get_command_status(job_id)
|
| 79 |
+
return CommandJobStatusResponse(**status_data)
|
| 80 |
+
|
| 81 |
+
except Exception as e:
|
| 82 |
+
logger.error(f"Error fetching job status: {str(e)}")
|
| 83 |
+
raise HTTPException(
|
| 84 |
+
status_code=500, detail="Failed to fetch job status"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@router.get("/commands/jobs", response_model=List[Dict[str, Any]])
|
| 89 |
+
async def list_command_jobs(
|
| 90 |
+
command_filter: Optional[str] = Query(None, description="Filter by command name"),
|
| 91 |
+
status_filter: Optional[str] = Query(None, description="Filter by status"),
|
| 92 |
+
limit: int = Query(50, description="Maximum number of jobs to return"),
|
| 93 |
+
):
|
| 94 |
+
"""List command jobs with optional filtering"""
|
| 95 |
+
try:
|
| 96 |
+
jobs = await CommandService.list_command_jobs(
|
| 97 |
+
command_filter=command_filter, status_filter=status_filter, limit=limit
|
| 98 |
+
)
|
| 99 |
+
return jobs
|
| 100 |
+
|
| 101 |
+
except Exception as e:
|
| 102 |
+
logger.error(f"Error listing command jobs: {str(e)}")
|
| 103 |
+
raise HTTPException(
|
| 104 |
+
status_code=500, detail="Failed to list command jobs"
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@router.delete("/commands/jobs/{job_id}")
|
| 109 |
+
async def cancel_command_job(job_id: str):
|
| 110 |
+
"""Cancel a running command job"""
|
| 111 |
+
try:
|
| 112 |
+
success = await CommandService.cancel_command_job(job_id)
|
| 113 |
+
return {"job_id": job_id, "cancelled": success}
|
| 114 |
+
|
| 115 |
+
except Exception as e:
|
| 116 |
+
logger.error(f"Error cancelling command job: {str(e)}")
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=500, detail="Failed to cancel command job"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@router.get("/commands/registry/debug")
|
| 123 |
+
async def debug_registry():
|
| 124 |
+
"""Debug endpoint to see what commands are registered"""
|
| 125 |
+
try:
|
| 126 |
+
# Get all registered commands
|
| 127 |
+
all_items = registry.get_all_commands()
|
| 128 |
+
|
| 129 |
+
# Create JSON-serializable data
|
| 130 |
+
command_items = []
|
| 131 |
+
for item in all_items:
|
| 132 |
+
try:
|
| 133 |
+
command_items.append(
|
| 134 |
+
{
|
| 135 |
+
"app_id": item.app_id,
|
| 136 |
+
"name": item.name,
|
| 137 |
+
"full_id": f"{item.app_id}.{item.name}",
|
| 138 |
+
}
|
| 139 |
+
)
|
| 140 |
+
except Exception as item_error:
|
| 141 |
+
logger.error(f"Error processing item: {item_error}")
|
| 142 |
+
|
| 143 |
+
# Get the basic command structure
|
| 144 |
+
try:
|
| 145 |
+
commands_dict: dict[str, list[str]] = {}
|
| 146 |
+
for item in all_items:
|
| 147 |
+
if item.app_id not in commands_dict:
|
| 148 |
+
commands_dict[item.app_id] = []
|
| 149 |
+
commands_dict[item.app_id].append(item.name)
|
| 150 |
+
except Exception:
|
| 151 |
+
commands_dict = {}
|
| 152 |
+
|
| 153 |
+
return {
|
| 154 |
+
"total_commands": len(all_items),
|
| 155 |
+
"commands_by_app": commands_dict,
|
| 156 |
+
"command_items": command_items,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.error(f"Error debugging registry: {str(e)}")
|
| 161 |
+
return {
|
| 162 |
+
"error": str(e),
|
| 163 |
+
"total_commands": 0,
|
| 164 |
+
"commands_by_app": {},
|
| 165 |
+
"command_items": [],
|
| 166 |
+
}
|
api/routers/config.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import os
|
| 3 |
+
import time
|
| 4 |
+
import tomllib
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from fastapi import APIRouter, Request
|
| 9 |
+
from loguru import logger
|
| 10 |
+
|
| 11 |
+
from open_notebook.database.repository import repo_query
|
| 12 |
+
from open_notebook.utils.version_utils import (
|
| 13 |
+
compare_versions,
|
| 14 |
+
get_version_from_github_async,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
router = APIRouter()
|
| 18 |
+
|
| 19 |
+
# In-memory cache for version check results
|
| 20 |
+
_version_cache: dict = {
|
| 21 |
+
"latest_version": None,
|
| 22 |
+
"has_update": False,
|
| 23 |
+
"timestamp": 0,
|
| 24 |
+
"check_failed": False,
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Cache TTL in seconds (24 hours)
|
| 28 |
+
VERSION_CACHE_TTL = 24 * 60 * 60
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_version() -> str:
|
| 32 |
+
"""Read version from pyproject.toml"""
|
| 33 |
+
try:
|
| 34 |
+
pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml"
|
| 35 |
+
with open(pyproject_path, "rb") as f:
|
| 36 |
+
pyproject = tomllib.load(f)
|
| 37 |
+
return pyproject.get("project", {}).get("version", "unknown")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.warning(f"Could not read version from pyproject.toml: {e}")
|
| 40 |
+
return "unknown"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
async def get_latest_version_cached(current_version: str) -> tuple[Optional[str], bool]:
|
| 44 |
+
"""
|
| 45 |
+
Check for the latest version from GitHub with caching.
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
tuple: (latest_version, has_update)
|
| 49 |
+
- latest_version: str or None if check failed
|
| 50 |
+
- has_update: bool indicating if update is available
|
| 51 |
+
"""
|
| 52 |
+
global _version_cache
|
| 53 |
+
|
| 54 |
+
# Check if cache is still valid (within TTL)
|
| 55 |
+
cache_age = time.time() - _version_cache["timestamp"]
|
| 56 |
+
if _version_cache["timestamp"] > 0 and cache_age < VERSION_CACHE_TTL:
|
| 57 |
+
logger.debug(f"Using cached version check result (age: {cache_age:.0f}s)")
|
| 58 |
+
return _version_cache["latest_version"], _version_cache["has_update"]
|
| 59 |
+
|
| 60 |
+
# Cache expired or not yet set
|
| 61 |
+
if _version_cache["timestamp"] > 0:
|
| 62 |
+
logger.info(f"Version cache expired (age: {cache_age:.0f}s), refreshing...")
|
| 63 |
+
|
| 64 |
+
# Perform version check with strict error handling
|
| 65 |
+
try:
|
| 66 |
+
logger.info("Checking for latest version from GitHub...")
|
| 67 |
+
|
| 68 |
+
# Fetch latest version from GitHub with 10-second timeout
|
| 69 |
+
latest_version = await get_version_from_github_async(
|
| 70 |
+
"https://github.com/lfnovo/open-notebook", "main"
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
logger.info(
|
| 74 |
+
f"Latest version from GitHub: {latest_version}, Current version: {current_version}"
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Compare versions
|
| 78 |
+
has_update = compare_versions(current_version, latest_version) < 0
|
| 79 |
+
|
| 80 |
+
# Cache the result
|
| 81 |
+
_version_cache["latest_version"] = latest_version
|
| 82 |
+
_version_cache["has_update"] = has_update
|
| 83 |
+
_version_cache["timestamp"] = time.time()
|
| 84 |
+
_version_cache["check_failed"] = False
|
| 85 |
+
|
| 86 |
+
logger.info(f"Version check complete. Update available: {has_update}")
|
| 87 |
+
|
| 88 |
+
return latest_version, has_update
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.warning(f"Version check failed: {e}")
|
| 92 |
+
|
| 93 |
+
# Cache the failure to avoid repeated attempts
|
| 94 |
+
_version_cache["latest_version"] = None
|
| 95 |
+
_version_cache["has_update"] = False
|
| 96 |
+
_version_cache["timestamp"] = time.time()
|
| 97 |
+
_version_cache["check_failed"] = True
|
| 98 |
+
|
| 99 |
+
return None, False
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
async def check_database_health() -> dict:
|
| 103 |
+
"""
|
| 104 |
+
Check if database is reachable using a lightweight query.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
dict with 'status' ("online" | "offline") and optional 'error'
|
| 108 |
+
"""
|
| 109 |
+
try:
|
| 110 |
+
# 2-second timeout for database health check
|
| 111 |
+
result = await asyncio.wait_for(repo_query("RETURN 1"), timeout=2.0)
|
| 112 |
+
if result:
|
| 113 |
+
return {"status": "online"}
|
| 114 |
+
return {"status": "offline", "error": "Empty result"}
|
| 115 |
+
except asyncio.TimeoutError:
|
| 116 |
+
logger.warning("Database health check timed out after 2 seconds")
|
| 117 |
+
return {"status": "offline", "error": "Health check timeout"}
|
| 118 |
+
except Exception as e:
|
| 119 |
+
logger.warning(f"Database health check failed: {e}")
|
| 120 |
+
return {"status": "offline", "error": str(e)}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.get("/config")
|
| 124 |
+
async def get_config(request: Request):
|
| 125 |
+
"""
|
| 126 |
+
Get frontend configuration.
|
| 127 |
+
|
| 128 |
+
Returns version information and health status.
|
| 129 |
+
Note: The frontend determines the API URL via its own runtime-config endpoint,
|
| 130 |
+
so this endpoint no longer returns apiUrl.
|
| 131 |
+
|
| 132 |
+
Also checks for version updates from GitHub (with caching and error handling).
|
| 133 |
+
"""
|
| 134 |
+
# Get current version
|
| 135 |
+
current_version = get_version()
|
| 136 |
+
|
| 137 |
+
# Check for updates (with caching and error handling)
|
| 138 |
+
# This MUST NOT break the endpoint - wrapped in try-except as extra safety
|
| 139 |
+
latest_version = None
|
| 140 |
+
has_update = False
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
latest_version, has_update = await get_latest_version_cached(current_version)
|
| 144 |
+
except Exception as e:
|
| 145 |
+
# Extra safety: ensure version check never breaks the config endpoint
|
| 146 |
+
logger.error(f"Unexpected error during version check: {e}")
|
| 147 |
+
|
| 148 |
+
# Check database health
|
| 149 |
+
db_health = await check_database_health()
|
| 150 |
+
db_status = db_health["status"]
|
| 151 |
+
|
| 152 |
+
if db_status == "offline":
|
| 153 |
+
logger.warning(f"Database offline: {db_health.get('error', 'Unknown error')}")
|
| 154 |
+
|
| 155 |
+
return {
|
| 156 |
+
"version": current_version,
|
| 157 |
+
"latestVersion": latest_version,
|
| 158 |
+
"hasUpdate": has_update,
|
| 159 |
+
"dbStatus": db_status,
|
| 160 |
+
}
|