C2MV commited on
Commit
bd0c393
·
0 Parent(s):

Deploy Open Notebook to HuggingFace Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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
+ }