ziadmostafa commited on
Commit
e60fb94
·
1 Parent(s): 19f1030

initial commit

Browse files
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy application files
10
+ COPY . .
11
+
12
+ # Create necessary directories
13
+ RUN mkdir -p flask_session
14
+
15
+ # Set environment variables
16
+ ENV FLASK_APP=app.py
17
+ ENV FLASK_ENV=production
18
+ ENV PORT=7860
19
+
20
+ # Expose the port Hugging Face Spaces uses
21
+ EXPOSE 7860
22
+
23
+ # Command to run the application
24
+ CMD gunicorn --bind 0.0.0.0:7860 app:app
README.md CHANGED
@@ -9,4 +9,60 @@ license: mit
9
  short_description: ipynb file generator using Gemini models to create notebooks
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  short_description: ipynb file generator using Gemini models to create notebooks
10
  ---
11
 
12
+ # NoteGenie
13
+
14
+ NoteGenie is an AI-powered Jupyter notebook generator that uses Google's Gemini models to create and edit notebooks from text prompts.
15
+
16
+ ## Features
17
+
18
+ - Generate complete Jupyter notebooks with a text prompt
19
+ - Edit existing notebooks with text instructions
20
+ - Preview notebooks in the web interface
21
+ - Download notebooks as .ipynb files
22
+ - Streaming responses for a better user experience
23
+
24
+ ## Deployment on Hugging Face Spaces
25
+
26
+ 1. Create a new Space on Hugging Face
27
+ 2. Choose Docker template
28
+ 3. Upload this repository to the Space
29
+ 4. Set the following environment variables in your Space settings:
30
+ - SECRET_KEY: A secure random string for Flask sessions
31
+ - PORT: 7860 (default for Hugging Face Spaces)
32
+
33
+ ## Local Development
34
+
35
+ 1. Install dependencies:
36
+ ```
37
+ pip install -r requirements.txt
38
+ ```
39
+
40
+ 2. Run the application:
41
+ ```
42
+ python app.py
43
+ ```
44
+
45
+ 3. Open http://localhost:5000 in your browser
46
+
47
+ ## Using Docker
48
+
49
+ Build and run the Docker container:
50
+
51
+ ```bash
52
+ docker build -t notegenie .
53
+ docker run -p 7860:7860 -e SECRET_KEY=your_secret_key notegenie
54
+ ```
55
+
56
+ ## Environment Variables
57
+
58
+ - SECRET_KEY: Secret key for Flask session encryption
59
+ - PORT: Port to run the application on (defaults to 5000 locally, 7860 for Hugging Face)
60
+ - FLASK_ENV: Set to "development" for debug mode, "production" for production
61
+
62
+ ## API Key Setup
63
+
64
+ NoteGenie requires a Google Gemini API key. Users can set their own API key in the web interface.
65
+
66
+ ## License
67
+
68
+ [MIT License](LICENSE)
app.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, request, jsonify, render_template, session, redirect, url_for
2
+ from flask_session import Session
3
+ import google.generativeai as genai
4
+ import json
5
+ import uuid
6
+ import os
7
+ from utils.ai_helpers import generate_notebook, stream_notebook_generation, stream_notebook_edit, edit_notebook
8
+ from utils.notebook_helpers import format_notebook, extract_notebook_info
9
+
10
+ app = Flask(__name__)
11
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "notegenie-secret-key-change-in-production")
12
+ app.config["SESSION_TYPE"] = "filesystem"
13
+ app.config["SESSION_PERMANENT"] = True
14
+ app.config["SESSION_USE_SIGNER"] = True
15
+ app.config["PERMANENT_SESSION_LIFETIME"] = 60 * 60 * 24 * 30 # 30 days
16
+ app.config["SESSION_FILE_DIR"] = os.path.join(os.path.dirname(os.path.abspath(__file__)), "flask_session")
17
+ os.makedirs(app.config["SESSION_FILE_DIR"], exist_ok=True) # Ensure directory exists
18
+
19
+ Session(app)
20
+
21
+ # Map front-end model names to API model names
22
+ MODEL_MAPPING = {
23
+ "gemini-2.0-pro": "gemini-2.0-pro-exp-02-05",
24
+ "gemini-2.0-flash": "gemini-2.0-flash",
25
+ "gemini-2.0-flash-thinking": "gemini-2.0-flash-thinking-exp-01-21"
26
+ }
27
+
28
+ def get_api_model_name(frontend_model_name):
29
+ return MODEL_MAPPING.get(frontend_model_name, frontend_model_name)
30
+
31
+ @app.route("/", methods=["GET"])
32
+ def index():
33
+ return render_template("index.html")
34
+
35
+ @app.route("/set_api_key", methods=["POST"])
36
+ def set_api_key():
37
+ api_key = request.form.get("api_key")
38
+ if not api_key:
39
+ return jsonify({"success": False, "message": "API key is required"}), 400
40
+
41
+ try:
42
+ # Test the API key
43
+ genai.configure(api_key=api_key)
44
+ model = genai.GenerativeModel("gemini-2.0-pro-exp-02-05")
45
+ response = model.generate_content("Say 'API key is valid'")
46
+
47
+ # Store API key in session with permanent flag
48
+ session.permanent = True
49
+ session["api_key"] = api_key
50
+
51
+ return jsonify({"success": True})
52
+ except Exception as e:
53
+ return jsonify({"success": False, "message": str(e)}), 400
54
+
55
+ @app.route("/generate_notebook", methods=["GET", "POST"])
56
+ def generate_notebook_route():
57
+ if "api_key" not in session:
58
+ return jsonify({"success": False, "message": "API key not set"}), 401
59
+
60
+ # Handle both GET (for streaming) and POST requests
61
+ if request.method == "GET":
62
+ prompt = request.args.get("prompt")
63
+ model_name = request.args.get("model", "gemini-2.0-pro")
64
+ stream = request.args.get("stream", "false").lower() == "true"
65
+ else:
66
+ data = request.json
67
+ prompt = data.get("prompt")
68
+ model_name = data.get("model", "gemini-2.0-pro")
69
+ stream = data.get("stream", False)
70
+ format_only = data.get("format_only", False)
71
+
72
+ if not prompt:
73
+ return jsonify({"success": False, "message": "Prompt is required"}), 400
74
+
75
+ # Map the frontend model name to the API model name
76
+ api_model_name = get_api_model_name(model_name)
77
+
78
+ genai.configure(api_key=session["api_key"])
79
+
80
+ try:
81
+ # OPTIMIZATION: If format_only is True, skip the AI call and just format the provided content
82
+ if request.method == "POST" and format_only:
83
+ # Use client-provided content as is (it's already the AI response)
84
+ notebook_content = prompt
85
+ notebook_json = format_notebook(notebook_content)
86
+ notebook_info = extract_notebook_info(notebook_content)
87
+
88
+ return jsonify({
89
+ "success": True,
90
+ "notebook": notebook_json,
91
+ "name": notebook_info["name"],
92
+ "description": notebook_info["description"]
93
+ })
94
+ elif stream:
95
+ return stream_notebook_generation(prompt, api_model_name)
96
+ else:
97
+ notebook_content = generate_notebook(prompt, api_model_name)
98
+ notebook_json = format_notebook(notebook_content)
99
+ notebook_info = extract_notebook_info(notebook_content)
100
+
101
+ return jsonify({
102
+ "success": True,
103
+ "notebook": notebook_json,
104
+ "name": notebook_info["name"],
105
+ "description": notebook_info["description"]
106
+ })
107
+ except Exception as e:
108
+ return jsonify({"success": False, "message": str(e)}), 500
109
+
110
+ @app.route("/prepare_edit_notebook", methods=["POST"])
111
+ def prepare_edit_notebook():
112
+ """Store the notebook in the session for editing."""
113
+ if "api_key" not in session:
114
+ return jsonify({"success": False, "message": "API key not set"}), 401
115
+
116
+ data = request.json
117
+ notebook_json = data.get("notebook")
118
+
119
+ if not notebook_json:
120
+ return jsonify({"success": False, "message": "Notebook content is required"}), 400
121
+
122
+ # Store the notebook in the session for later access
123
+ session["current_notebook"] = notebook_json
124
+
125
+ return jsonify({"success": True})
126
+
127
+ @app.route("/edit_notebook", methods=["GET", "POST"])
128
+ def edit_notebook_route():
129
+ if "api_key" not in session:
130
+ return jsonify({"success": False, "message": "API key not set"}), 401
131
+
132
+ # Get edit prompt and current notebook
133
+ if request.method == "GET":
134
+ edit_prompt = request.args.get("edit_prompt")
135
+ model_name = request.args.get("model", "gemini-2.0-pro")
136
+ stream = request.args.get("stream", "true").lower() == "true"
137
+ # For GET streaming requests, get notebook from session
138
+ notebook_json = session.get("current_notebook")
139
+ else:
140
+ data = request.json
141
+ edit_prompt = data.get("edit_prompt")
142
+ notebook_json = data.get("notebook")
143
+ model_name = data.get("model", "gemini-2.0-pro")
144
+ stream = data.get("stream", False)
145
+
146
+ if not edit_prompt:
147
+ return jsonify({"success": False, "message": "Edit prompt is required"}), 400
148
+
149
+ if not notebook_json:
150
+ return jsonify({"success": False, "message": "No notebook available for editing. Please prepare the notebook first."}), 400
151
+
152
+ # Map the frontend model name to the API model name
153
+ api_model_name = get_api_model_name(model_name)
154
+
155
+ genai.configure(api_key=session["api_key"])
156
+
157
+ try:
158
+ if stream:
159
+ return stream_notebook_edit(edit_prompt, notebook_json, api_model_name)
160
+ else:
161
+ # Non-streaming path (not used in current UI but kept for API completeness)
162
+ edited_content = edit_notebook(edit_prompt, notebook_json, api_model_name)
163
+ notebook_json = format_notebook(edited_content)
164
+ notebook_info = extract_notebook_info(edited_content)
165
+
166
+ return jsonify({
167
+ "success": True,
168
+ "notebook": notebook_json,
169
+ "name": notebook_info["name"],
170
+ "description": notebook_info["description"]
171
+ })
172
+ except Exception as e:
173
+ app.logger.error(f"Error editing notebook: {str(e)}")
174
+ return jsonify({"success": False, "message": str(e)}), 500
175
+
176
+ @app.route("/download_notebook", methods=["POST"])
177
+ def download_notebook():
178
+ from flask import Response
179
+
180
+ data = request.json
181
+ notebook_json = data.get("notebook")
182
+ filename = data.get("filename", f"notebook_{uuid.uuid4()}.ipynb")
183
+
184
+ if not notebook_json:
185
+ return jsonify({"success": False, "message": "Notebook content is required"}), 400
186
+
187
+ if not filename.endswith(".ipynb"):
188
+ filename += ".ipynb"
189
+
190
+ response = Response(
191
+ json.dumps(notebook_json, indent=2),
192
+ mimetype="application/json",
193
+ headers={"Content-Disposition": f"attachment;filename={filename}"}
194
+ )
195
+
196
+ return response
197
+
198
+ if __name__ == "__main__":
199
+ port = int(os.environ.get("PORT", 5000))
200
+ app.run(host="0.0.0.0", port=port, debug=(os.environ.get("FLASK_ENV") == "development"))
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask==2.0.1
2
+ flask-session==0.4.0
3
+ google-generativeai==0.3.1
4
+ gunicorn==20.1.0
static/css/style.css ADDED
@@ -0,0 +1,761 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* Google Material Design colors */
3
+ --google-blue: #4285f4;
4
+ --google-red: #ea4335;
5
+ --google-yellow: #fbbc05;
6
+ --google-green: #34a853;
7
+ --google-gray: #5f6368;
8
+ --google-blue-hover: #3367d6;
9
+ --google-surface: #ffffff;
10
+ --google-background: #f8f9fa;
11
+ --google-border: #dadce0;
12
+ --google-text-primary: #202124;
13
+ --google-text-secondary: #5f6368;
14
+ --google-disabled: #e0e0e0;
15
+
16
+ /* Spacing and dimensions */
17
+ --spacing-xs: 4px;
18
+ --spacing-sm: 8px;
19
+ --spacing-md: 16px;
20
+ --spacing-lg: 24px;
21
+ --spacing-xl: 32px;
22
+ --border-radius: 8px;
23
+ --shadow-1: 0 1px 2px 0 rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15);
24
+ --shadow-2: 0 2px 6px 2px rgba(60, 64, 67, 0.15);
25
+ --shadow-3: 0 4px 8px 3px rgba(60, 64, 67, 0.15);
26
+ }
27
+
28
+ /* Base styles */
29
+ body {
30
+ font-family: 'Roboto', sans-serif;
31
+ background-color: var(--google-background);
32
+ color: var(--google-text-primary);
33
+ margin: 0;
34
+ padding: 0;
35
+ height: 100vh;
36
+ display: flex;
37
+ flex-direction: column;
38
+ overflow: hidden;
39
+ }
40
+
41
+ /* Material typography */
42
+ h1, h2, h3, h4, h5, h6 {
43
+ font-family: 'Google Sans', 'Roboto', sans-serif;
44
+ font-weight: 500;
45
+ margin: 0;
46
+ color: var(--google-text-primary);
47
+ }
48
+
49
+ .product-name {
50
+ font-size: 22px;
51
+ line-height: 28px;
52
+ font-weight: 500;
53
+ }
54
+
55
+ /* App container */
56
+ .app-container {
57
+ display: flex;
58
+ flex-direction: column;
59
+ height: 100vh;
60
+ overflow: hidden;
61
+ }
62
+
63
+ /* Header styling */
64
+ .app-header {
65
+ background-color: var(--google-surface);
66
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.3);
67
+ padding: var(--spacing-md);
68
+ z-index: 10;
69
+ }
70
+
71
+ .header-content {
72
+ display: flex;
73
+ justify-content: space-between;
74
+ align-items: center;
75
+ max-width: 1600px;
76
+ margin: 0 auto;
77
+ width: 100%;
78
+ }
79
+
80
+ .logo-section {
81
+ display: flex;
82
+ align-items: center;
83
+ gap: var(--spacing-sm);
84
+ }
85
+
86
+ .logo-icon {
87
+ color: var(--google-blue);
88
+ }
89
+
90
+ .header-actions {
91
+ display: flex;
92
+ gap: var(--spacing-sm);
93
+ }
94
+
95
+ /* Main container */
96
+ .main-container {
97
+ display: flex;
98
+ flex: 1;
99
+ overflow: hidden;
100
+ max-width: 1600px;
101
+ margin: 0 auto;
102
+ width: 100%;
103
+ height: calc(100vh - 64px);
104
+ padding: var(--spacing-md);
105
+ gap: var(--spacing-md);
106
+ }
107
+
108
+ /* Panels - update the width ratio */
109
+ .panel {
110
+ display: flex;
111
+ flex-direction: column;
112
+ overflow: hidden;
113
+ background-color: var(--google-surface);
114
+ border-radius: var(--border-radius);
115
+ height: 100%;
116
+ box-shadow: var(--shadow-1);
117
+ }
118
+
119
+ .chat-panel {
120
+ flex: 0.4; /* Reduced from 1 to 0.4 (40% width) */
121
+ border-right: none;
122
+ }
123
+
124
+ .notebook-panel {
125
+ flex: 0.6; /* Increased from 1 to 0.6 (60% width) */
126
+ overflow: hidden;
127
+ }
128
+
129
+ /* Conversation container */
130
+ .conversation-container {
131
+ flex: 1;
132
+ overflow-y: auto;
133
+ padding: var(--spacing-md);
134
+ }
135
+
136
+ /* Custom scrollbar styles */
137
+ .conversation-container::-webkit-scrollbar,
138
+ .notebook-preview::-webkit-scrollbar {
139
+ width: 8px;
140
+ }
141
+
142
+ .conversation-container::-webkit-scrollbar-track,
143
+ .notebook-preview::-webkit-scrollbar-track {
144
+ background: transparent;
145
+ }
146
+
147
+ .conversation-container::-webkit-scrollbar-thumb,
148
+ .notebook-preview::-webkit-scrollbar-thumb {
149
+ background-color: rgba(95, 99, 104, 0.3);
150
+ border-radius: 20px;
151
+ }
152
+
153
+ .conversation-container::-webkit-scrollbar-thumb:hover,
154
+ .notebook-preview::-webkit-scrollbar-thumb:hover {
155
+ background-color: rgba(95, 99, 104, 0.5);
156
+ }
157
+
158
+ /* Input container */
159
+ .input-container {
160
+ border-top: 1px solid var(--google-border);
161
+ padding: var(--spacing-md);
162
+ background-color: var(--google-surface);
163
+ }
164
+
165
+ .input-options {
166
+ display: flex;
167
+ justify-content: space-between;
168
+ align-items: center;
169
+ margin-bottom: var(--spacing-md);
170
+ }
171
+
172
+ .model-selector {
173
+ border: 1px solid var(--google-border);
174
+ border-radius: var(--border-radius);
175
+ padding: var(--spacing-xs) var(--spacing-md);
176
+ font-size: 14px;
177
+ color: var(--google-text-primary);
178
+ background-color: var(--google-surface);
179
+ width: auto;
180
+ outline: none;
181
+ }
182
+
183
+ .model-selector:focus {
184
+ border-color: var(--google-blue);
185
+ }
186
+
187
+ .mode-toggle {
188
+ display: flex;
189
+ border: 1px solid var(--google-blue);
190
+ border-radius: 4px;
191
+ overflow: hidden;
192
+ }
193
+
194
+ .toggle-button {
195
+ background: none;
196
+ border: none;
197
+ padding: 6px 12px;
198
+ font-size: 14px;
199
+ cursor: pointer;
200
+ color: var(--google-blue);
201
+ }
202
+
203
+ .toggle-button.active {
204
+ background-color: var(--google-blue);
205
+ color: white;
206
+ }
207
+
208
+ /* Fix for disabled toggle buttons */
209
+ .toggle-button:disabled {
210
+ cursor: not-allowed;
211
+ opacity: 0.7;
212
+ }
213
+
214
+ /* Keep active styling even when disabled */
215
+ .toggle-button.active:disabled {
216
+ background-color: var(--google-blue);
217
+ color: white;
218
+ opacity: 0.8;
219
+ }
220
+
221
+ /* Inactive button when disabled */
222
+ .toggle-button:not(.active):disabled {
223
+ color: var(--google-text-secondary);
224
+ background-color: transparent;
225
+ }
226
+
227
+ .input-field-container {
228
+ position: relative;
229
+ margin-bottom: var(--spacing-sm);
230
+ }
231
+
232
+ .prompt-input {
233
+ width: 100%;
234
+ border: 1px solid var(--google-border);
235
+ border-radius: var(--border-radius);
236
+ padding: var(--spacing-md);
237
+ padding-right: 50px; /* Make room for the send button */
238
+ font-family: 'Roboto', sans-serif;
239
+ font-size: 16px;
240
+ resize: none;
241
+ outline: none;
242
+ transition: border-color 0.2s;
243
+ }
244
+
245
+ .prompt-input:focus {
246
+ border-color: var(--google-blue);
247
+ box-shadow: 0 0 0 1px var(--google-blue);
248
+ }
249
+
250
+ /* Send button inside input */
251
+ .send-button {
252
+ position: absolute;
253
+ right: 12px;
254
+ top: 12px;
255
+ background-color: var(--google-blue);
256
+ color: white;
257
+ border: none;
258
+ border-radius: 50%;
259
+ width: 36px;
260
+ height: 36px;
261
+ display: flex;
262
+ align-items: center;
263
+ justify-content: center;
264
+ cursor: pointer;
265
+ transition: background-color 0.2s;
266
+ }
267
+
268
+ .send-button:hover {
269
+ background-color: var(--google-blue-hover);
270
+ }
271
+
272
+ .send-button:disabled {
273
+ background-color: var(--google-disabled);
274
+ cursor: not-allowed;
275
+ }
276
+
277
+ .send-button .material-icons {
278
+ font-size: 18px;
279
+ }
280
+
281
+ /* Hide the original action button container */
282
+ .input-actions {
283
+ display: none;
284
+ }
285
+
286
+ /* Google Buttons */
287
+ .google-button {
288
+ display: inline-flex;
289
+ align-items: center;
290
+ justify-content: center;
291
+ border: none;
292
+ border-radius: 4px;
293
+ font-family: 'Google Sans', 'Roboto', sans-serif;
294
+ font-size: 14px;
295
+ font-weight: 500;
296
+ padding: 8px 24px;
297
+ cursor: pointer;
298
+ transition: background-color 0.2s, box-shadow 0.2s;
299
+ white-space: nowrap;
300
+ gap: var(--spacing-sm);
301
+ height: 36px;
302
+ letter-spacing: 0.25px;
303
+ outline: none;
304
+ position: relative;
305
+ }
306
+
307
+ .google-button .material-icons {
308
+ font-size: 18px;
309
+ }
310
+
311
+ .google-button.primary {
312
+ background-color: var(--google-blue);
313
+ color: white;
314
+ }
315
+
316
+ .google-button.primary:hover {
317
+ background-color: var(--google-blue-hover);
318
+ box-shadow: var(--shadow-1);
319
+ }
320
+
321
+ .google-button.outlined {
322
+ background-color: transparent;
323
+ border: 1px solid var(--google-border);
324
+ color: var(--google-blue);
325
+ }
326
+
327
+ .google-button.outlined:hover {
328
+ background-color: rgba(66, 133, 244, 0.04);
329
+ }
330
+
331
+ .google-button.text {
332
+ background-color: transparent;
333
+ color: var(--google-blue);
334
+ padding: 8px 16px;
335
+ }
336
+
337
+ .google-button.text:hover {
338
+ background-color: rgba(66, 133, 244, 0.04);
339
+ }
340
+
341
+ .google-button:disabled {
342
+ background-color: var(--google-disabled);
343
+ color: var(--google-text-secondary);
344
+ cursor: not-allowed;
345
+ box-shadow: none;
346
+ }
347
+
348
+ /* Notebook panel */
349
+ .notebook-panel {
350
+ overflow: hidden;
351
+ }
352
+
353
+ .notebook-header {
354
+ display: flex;
355
+ justify-content: space-between;
356
+ align-items: center;
357
+ padding: var(--spacing-md);
358
+ border-bottom: 1px solid var(--google-border);
359
+ }
360
+
361
+ .notebook-title {
362
+ font-size: 16px;
363
+ font-weight: 500;
364
+ color: var(--google-text-primary);
365
+ }
366
+
367
+ .notebook-preview {
368
+ flex: 1;
369
+ overflow-y: auto;
370
+ padding: var(--spacing-md);
371
+ background-color: var(--google-surface);
372
+ }
373
+
374
+ .placeholder-content {
375
+ display: flex;
376
+ flex-direction: column;
377
+ align-items: center;
378
+ justify-content: center;
379
+ height: 100%;
380
+ color: var(--google-text-secondary);
381
+ }
382
+
383
+ .large-icon {
384
+ font-size: 48px;
385
+ margin-bottom: var(--spacing-md);
386
+ color: var(--google-gray);
387
+ }
388
+
389
+ /* Welcome message */
390
+ .welcome-message {
391
+ max-width: 600px;
392
+ margin: 0 auto;
393
+ }
394
+
395
+ .welcome-message h2 {
396
+ display: flex;
397
+ align-items: center;
398
+ gap: var(--spacing-sm);
399
+ margin-bottom: var(--spacing-md);
400
+ color: var(--google-blue);
401
+ }
402
+
403
+ .welcome-steps {
404
+ margin: var(--spacing-lg) 0;
405
+ }
406
+
407
+ .welcome-step {
408
+ display: flex;
409
+ align-items: center;
410
+ margin-bottom: var(--spacing-md);
411
+ }
412
+
413
+ .step-number {
414
+ width: 24px;
415
+ height: 24px;
416
+ border-radius: 50%;
417
+ background-color: var(--google-blue);
418
+ color: white;
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ font-size: 14px;
423
+ margin-right: var(--spacing-md);
424
+ }
425
+
426
+ .step-text {
427
+ font-size: 16px;
428
+ }
429
+
430
+ .info-card {
431
+ display: flex;
432
+ background-color: #e8f0fe;
433
+ border-radius: var(--border-radius);
434
+ padding: var(--spacing-md);
435
+ margin: var(--spacing-md) 0;
436
+ }
437
+
438
+ .info-icon {
439
+ color: var(--google-blue);
440
+ margin-right: var(--spacing-md);
441
+ }
442
+
443
+ .info-content {
444
+ flex: 1;
445
+ }
446
+
447
+ .info-content p {
448
+ margin: 0;
449
+ }
450
+
451
+ .example-section {
452
+ margin-top: var(--spacing-lg);
453
+ }
454
+
455
+ .example-list {
456
+ list-style-type: none;
457
+ padding: 0;
458
+ }
459
+
460
+ .example-list li {
461
+ padding: var(--spacing-sm) 0;
462
+ border-left: 3px solid var(--google-blue);
463
+ padding-left: var(--spacing-md);
464
+ margin-bottom: var(--spacing-sm);
465
+ background-color: rgba(66, 133, 244, 0.05);
466
+ border-radius: 0 var(--border-radius) var(--border-radius) 0;
467
+ }
468
+
469
+ /* Messages */
470
+ .message {
471
+ margin-bottom: var(--spacing-md);
472
+ max-width: 85%;
473
+ padding: var(--spacing-md);
474
+ border-radius: var(--border-radius);
475
+ }
476
+
477
+ .user-message {
478
+ background-color: #e8f0fe;
479
+ margin-left: auto;
480
+ position: relative;
481
+ box-shadow: var(--shadow-1);
482
+ }
483
+
484
+ .ai-message {
485
+ background-color: white;
486
+ border: 1px solid var(--google-border);
487
+ margin-right: auto;
488
+ position: relative;
489
+ box-shadow: var(--shadow-1);
490
+ }
491
+
492
+ /* Fix for user message formatting */
493
+ .user-prefix {
494
+ display: inline;
495
+ font-weight: 500;
496
+ }
497
+
498
+ .message-content {
499
+ display: inline;
500
+ }
501
+
502
+ .message-content p {
503
+ display: inline;
504
+ margin: 0;
505
+ }
506
+
507
+ .message-content p:not(:first-child) {
508
+ display: block;
509
+ margin-top: var(--spacing-sm);
510
+ }
511
+
512
+ /* Typing indicator */
513
+ .typing-indicator {
514
+ display: flex;
515
+ align-items: center;
516
+ gap: 4px;
517
+ }
518
+
519
+ .typing-dot {
520
+ background-color: var(--google-blue);
521
+ border-radius: 50%;
522
+ width: 8px;
523
+ height: 8px;
524
+ animation: typing-dot 1.4s infinite;
525
+ }
526
+
527
+ .typing-dot:nth-child(2) {
528
+ animation-delay: 0.2s;
529
+ }
530
+
531
+ .typing-dot:nth-child(3) {
532
+ animation-delay: 0.4s;
533
+ }
534
+
535
+ @keyframes typing-dot {
536
+ 0%, 60%, 100% { transform: translateY(0); }
537
+ 30% { transform: translateY(-6px); }
538
+ }
539
+
540
+ /* Notebook cell styling */
541
+ .notebook-cell {
542
+ margin-bottom: var(--spacing-md);
543
+ border: 1px solid var(--google-border);
544
+ border-radius: var(--border-radius);
545
+ overflow: hidden;
546
+ background-color: var(--google-surface);
547
+ box-shadow: var(--shadow-1);
548
+ }
549
+
550
+ .cell-header {
551
+ padding: var(--spacing-sm) var(--spacing-md);
552
+ font-size: 13px;
553
+ font-weight: 500;
554
+ display: flex;
555
+ justify-content: space-between;
556
+ align-items: center;
557
+ border-bottom: 1px solid var(--google-border);
558
+ }
559
+
560
+ .cell-type {
561
+ display: inline-flex;
562
+ align-items: center;
563
+ font-family: 'Google Sans', 'Roboto', sans-serif;
564
+ font-size: 13px;
565
+ }
566
+
567
+ .markdown-header {
568
+ background-color: #e8f0fe;
569
+ color: #000000;
570
+ }
571
+
572
+ /* Make code header match markdown header */
573
+ .code-header {
574
+ background-color: #e8f0fe; /* Changed from #e6f4ea to match markdown-header */
575
+ color: #000000; /* Changed from #188038 to match markdown-header */
576
+ }
577
+
578
+ .cell-content {
579
+ padding: 0;
580
+ }
581
+
582
+ .cell-markdown .cell-content {
583
+ padding: var(--spacing-md);
584
+ }
585
+
586
+ .cell-code .cell-content {
587
+ padding: 0;
588
+ }
589
+
590
+ /* Code cell styling */
591
+ .cell-code pre {
592
+ margin: 0 !important;
593
+ border-radius: 0;
594
+ }
595
+
596
+ .cell-code code {
597
+ padding: var(--spacing-md) var(--spacing-md) var(--spacing-md) 0 !important;
598
+ font-family: 'Roboto Mono', monospace;
599
+ font-size: 14px;
600
+ }
601
+
602
+ /* Modal styling */
603
+ .google-modal {
604
+ border-radius: var(--border-radius);
605
+ border: none;
606
+ box-shadow: var(--shadow-3);
607
+ }
608
+
609
+ .google-modal .modal-header {
610
+ border-bottom: 1px solid var(--google-border);
611
+ padding: var(--spacing-md);
612
+ }
613
+
614
+ .google-modal .modal-title {
615
+ font-family: 'Google Sans', 'Roboto', sans-serif;
616
+ font-weight: 500;
617
+ font-size: 18px;
618
+ display: flex;
619
+ align-items: center;
620
+ gap: var(--spacing-sm);
621
+ }
622
+
623
+ .google-modal .modal-body {
624
+ padding: var(--spacing-md);
625
+ }
626
+
627
+ .google-modal .modal-footer {
628
+ border-top: 1px solid var(--google-border);
629
+ padding: var(--spacing-md);
630
+ }
631
+
632
+ /* Input fields in modal */
633
+ .input-field {
634
+ margin-bottom: var(--spacing-md);
635
+ }
636
+
637
+ .input-label {
638
+ display: block;
639
+ margin-bottom: var(--spacing-sm);
640
+ font-size: 14px;
641
+ color: var(--google-text-secondary);
642
+ }
643
+
644
+ .text-input {
645
+ width: 100%;
646
+ border: 1px solid var(--google-border);
647
+ border-radius: 4px;
648
+ padding: 10px 12px;
649
+ font-family: 'Roboto', sans-serif;
650
+ font-size: 16px;
651
+ outline: none;
652
+ transition: border-color 0.2s;
653
+ }
654
+
655
+ .text-input:focus {
656
+ border-color: var(--google-blue);
657
+ box-shadow: 0 0 0 1px var(--google-blue);
658
+ }
659
+
660
+ .input-helper {
661
+ margin-top: 4px;
662
+ font-size: 12px;
663
+ color: var(--google-text-secondary);
664
+ }
665
+
666
+ /* Steps card */
667
+ .steps-card {
668
+ background-color: var(--google-background);
669
+ border-radius: var(--border-radius);
670
+ overflow: hidden;
671
+ margin-bottom: var(--spacing-md);
672
+ }
673
+
674
+ .steps-header {
675
+ background-color: #e8f0fe;
676
+ padding: var(--spacing-sm) var(--spacing-md);
677
+ display: flex;
678
+ align-items: center;
679
+ gap: var(--spacing-sm);
680
+ color: var(--google-blue);
681
+ }
682
+
683
+ .steps-content {
684
+ padding: var(--spacing-md);
685
+ }
686
+
687
+ .steps-list {
688
+ margin: 0;
689
+ padding-left: 20px;
690
+ }
691
+
692
+ .steps-list li {
693
+ padding: 4px 0;
694
+ }
695
+
696
+ /* Floating scroll button */
697
+ .scroll-button {
698
+ position: fixed;
699
+ bottom: 20px;
700
+ right: 20px;
701
+ width: 40px;
702
+ height: 40px;
703
+ border-radius: 50%;
704
+ background-color: white;
705
+ color: var(--google-text-primary);
706
+ display: flex;
707
+ align-items: center;
708
+ justify-content: center;
709
+ border: 1px solid var(--google-border);
710
+ cursor: pointer;
711
+ box-shadow: var(--shadow-2);
712
+ opacity: 0;
713
+ transition: opacity 0.3s ease;
714
+ z-index: 1000;
715
+ }
716
+
717
+ .scroll-button.visible {
718
+ opacity: 1;
719
+ }
720
+
721
+ .scroll-button:hover {
722
+ background-color: #f8f9fa;
723
+ }
724
+
725
+ /* Add spinning animation for loading state */
726
+ @keyframes spin-animation {
727
+ 0% { transform: rotate(0deg); }
728
+ 100% { transform: rotate(360deg); }
729
+ }
730
+
731
+ .material-icons.spinning {
732
+ animation: spin-animation 1.5s linear infinite;
733
+ }
734
+
735
+ /* Media queries for responsive design */
736
+ @media (max-width: 768px) {
737
+ .main-container {
738
+ flex-direction: column;
739
+ padding: var(--spacing-sm);
740
+ gap: var(--spacing-sm);
741
+ }
742
+
743
+ .chat-panel {
744
+ height: 40%; /* Reduced from 50% to 40% */
745
+ border-right: none;
746
+ border-bottom: none;
747
+ flex: none; /* Override the horizontal flex ratio when in column layout */
748
+ }
749
+
750
+ .notebook-panel {
751
+ height: 60%; /* Increased from 50% to 60% */
752
+ flex: none; /* Override the horizontal flex ratio when in column layout */
753
+ }
754
+
755
+ .send-button {
756
+ right: 8px;
757
+ top: 8px;
758
+ width: 32px;
759
+ height: 32px;
760
+ }
761
+ }
static/js/main.js ADDED
@@ -0,0 +1,1052 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', function() {
2
+ // DOM Elements
3
+ const conversationEl = document.getElementById('conversation');
4
+ const promptInputEl = document.getElementById('promptInput');
5
+ const actionBtnEl = document.getElementById('actionBtn');
6
+ const downloadBtnEl = document.getElementById('downloadBtn');
7
+ const notebookPreviewEl = document.getElementById('notebookPreview');
8
+ const notebookTitleEl = document.getElementById('notebookTitle');
9
+ const apiKeyInputEl = document.getElementById('apiKeyInput');
10
+ const saveApiKeyBtnEl = document.getElementById('saveApiKeyBtn');
11
+ const apiKeyFeedbackEl = document.getElementById('apiKeyFeedback');
12
+ const modelSelectEl = document.getElementById('modelSelect');
13
+ const generateModeBtnEl = document.getElementById('generateModeBtn');
14
+ const editModeBtnEl = document.getElementById('editModeBtn');
15
+
16
+ // Create scroll-to-bottom button
17
+ const scrollToBottomBtn = document.createElement('button');
18
+ scrollToBottomBtn.className = 'scroll-to-bottom';
19
+ scrollToBottomBtn.innerHTML = '<i class="bi bi-arrow-down"></i>';
20
+ scrollToBottomBtn.title = 'Scroll to bottom';
21
+ document.body.appendChild(scrollToBottomBtn);
22
+
23
+ // Scroll to bottom button event listener
24
+ scrollToBottomBtn.addEventListener('click', function() {
25
+ notebookPreviewEl.scrollTo({
26
+ top: notebookPreviewEl.scrollHeight,
27
+ behavior: 'smooth'
28
+ });
29
+ });
30
+
31
+ // Show/hide scroll to bottom button based on scroll position
32
+ notebookPreviewEl.addEventListener('scroll', function() {
33
+ const scrollPosition = notebookPreviewEl.scrollTop + notebookPreviewEl.clientHeight;
34
+ const scrollThreshold = notebookPreviewEl.scrollHeight - 100;
35
+
36
+ if (scrollPosition < scrollThreshold) {
37
+ scrollToBottomBtn.classList.add('visible');
38
+ } else {
39
+ scrollToBottomBtn.classList.remove('visible');
40
+ }
41
+ });
42
+
43
+ // Global state
44
+ let currentNotebook = null;
45
+ let eventSource = null;
46
+ let aiResponseText = '';
47
+ let currentMode = 'generate'; // 'generate' or 'edit'
48
+
49
+ // Add a debounce flag to prevent button spamming
50
+ let isActionInProgress = false;
51
+
52
+ // Initialize tooltips
53
+ const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
54
+ tooltipTriggerList.map(function(tooltipTriggerEl) {
55
+ return new bootstrap.Tooltip(tooltipTriggerEl);
56
+ });
57
+
58
+ // Check if we have an API key in localStorage on page load
59
+ const savedApiKey = localStorage.getItem('notegenie_api_key');
60
+ if (savedApiKey) {
61
+ // Try to set the API key automatically
62
+ apiKeyInputEl.value = savedApiKey;
63
+ checkAndSetApiKey();
64
+ }
65
+
66
+ // Event Listeners
67
+ saveApiKeyBtnEl.addEventListener('click', saveApiKey);
68
+ actionBtnEl.addEventListener('click', handleAction);
69
+ downloadBtnEl.addEventListener('click', downloadNotebook);
70
+ generateModeBtnEl.addEventListener('click', () => setMode('generate'));
71
+ editModeBtnEl.addEventListener('click', () => setMode('edit'));
72
+
73
+ // Allow Enter key to submit prompt (Shift+Enter for new line)
74
+ promptInputEl.addEventListener('keydown', function(e) {
75
+ if (e.key === 'Enter' && !e.shiftKey) {
76
+ e.preventDefault();
77
+ handleAction();
78
+ }
79
+ });
80
+
81
+ // Set mode (generate or edit)
82
+ function setMode(mode) {
83
+ currentMode = mode;
84
+
85
+ if (mode === 'generate') {
86
+ generateModeBtnEl.classList.add('active');
87
+ generateModeBtnEl.classList.remove('btn-outline-primary');
88
+ generateModeBtnEl.classList.add('btn-primary');
89
+
90
+ editModeBtnEl.classList.remove('active');
91
+ editModeBtnEl.classList.remove('btn-primary');
92
+ editModeBtnEl.classList.add('btn-outline-primary');
93
+
94
+ promptInputEl.placeholder = 'Describe the notebook you want...';
95
+ } else { // edit mode
96
+ editModeBtnEl.classList.add('active');
97
+ editModeBtnEl.classList.remove('btn-outline-primary');
98
+ editModeBtnEl.classList.add('btn-primary');
99
+
100
+ generateModeBtnEl.classList.remove('active');
101
+ generateModeBtnEl.classList.remove('btn-primary');
102
+ generateModeBtnEl.classList.add('btn-outline-primary');
103
+
104
+ promptInputEl.placeholder = 'Describe what changes you want to make to the notebook...';
105
+ promptInputEl.focus();
106
+ }
107
+ }
108
+
109
+ // Handle action button click based on current mode
110
+ function handleAction() {
111
+ // Prevent multiple rapid clicks
112
+ if (isActionInProgress) return;
113
+
114
+ // Get the prompt text and check if it's empty
115
+ const prompt = promptInputEl.value.trim();
116
+
117
+ if (currentMode === 'generate') {
118
+ if (!prompt) {
119
+ addSystemMessage('<i class="bi bi-exclamation-triangle"></i> Please enter a prompt to generate a notebook.');
120
+ return;
121
+ }
122
+
123
+ // Set the debounce flag and disable the button
124
+ isActionInProgress = true;
125
+ actionBtnEl.disabled = true;
126
+
127
+ generateNotebook();
128
+ } else {
129
+ if (!prompt || !currentNotebook) {
130
+ if (!currentNotebook) {
131
+ addSystemMessage('<i class="bi bi-exclamation-triangle"></i> No notebook to edit. Generate a notebook first.');
132
+ } else if (!prompt) {
133
+ addSystemMessage('<i class="bi bi-exclamation-triangle"></i> Please describe the changes you want to make.');
134
+ }
135
+ return;
136
+ }
137
+
138
+ // Set the debounce flag and disable the button
139
+ isActionInProgress = true;
140
+ actionBtnEl.disabled = true;
141
+
142
+ editNotebook();
143
+ }
144
+ }
145
+
146
+ // API Key handling
147
+ function checkAndSetApiKey() {
148
+ const apiKey = apiKeyInputEl.value.trim();
149
+
150
+ if (!apiKey) {
151
+ return;
152
+ }
153
+
154
+ fetch('/set_api_key', {
155
+ method: 'POST',
156
+ headers: {
157
+ 'Content-Type': 'application/x-www-form-urlencoded',
158
+ },
159
+ body: new URLSearchParams({
160
+ 'api_key': apiKey
161
+ })
162
+ })
163
+ .then(response => response.json())
164
+ .then(data => {
165
+ if (data.success) {
166
+ // Store API key in localStorage as backup
167
+ localStorage.setItem('notegenie_api_key', apiKey);
168
+
169
+ if (document.querySelector('#apiKeyModal.show')) {
170
+ showApiKeyFeedback('API key saved successfully!', 'success');
171
+ setTimeout(() => {
172
+ document.querySelector('#apiKeyModal .btn-close').click();
173
+ }, 1500);
174
+ }
175
+ } else {
176
+ showApiKeyFeedback(`Error: ${data.message}`, 'danger');
177
+ localStorage.removeItem('notegenie_api_key'); // Remove invalid key
178
+ }
179
+ })
180
+ .catch(error => {
181
+ showApiKeyFeedback(`Error: ${error.message}`, 'danger');
182
+ localStorage.removeItem('notegenie_api_key'); // Remove key on error
183
+ });
184
+ }
185
+
186
+ function saveApiKey() {
187
+ checkAndSetApiKey();
188
+ }
189
+
190
+ function showApiKeyFeedback(message, type) {
191
+ apiKeyFeedbackEl.innerHTML = `<div class="alert alert-${type} mb-0">${message}</div>`;
192
+ setTimeout(() => {
193
+ apiKeyFeedbackEl.innerHTML = '';
194
+ }, 5000);
195
+ }
196
+
197
+ // Notebook generation
198
+ function generateNotebook() {
199
+ const prompt = promptInputEl.value.trim();
200
+
201
+ if (!prompt) {
202
+ addSystemMessage('<i class="bi bi-exclamation-triangle"></i> Please enter a prompt to generate a notebook.');
203
+ return;
204
+ }
205
+
206
+ // Clear any existing notebook
207
+ notebookPreviewEl.innerHTML = '';
208
+ downloadBtnEl.disabled = true;
209
+ editModeBtnEl.disabled = true;
210
+ currentNotebook = null;
211
+
212
+ // Add user message to conversation
213
+ addUserMessage(prompt);
214
+
215
+ // Disable input during generation
216
+ setGeneratingState(true);
217
+
218
+ const modelName = modelSelectEl.value;
219
+
220
+ // Add AI typing indicator
221
+ const aiMessageId = 'ai-typing-' + Date.now();
222
+ addTypingIndicator(aiMessageId);
223
+
224
+ // Always use streaming for better UX
225
+ handleStreamingResponse(prompt, modelName, aiMessageId);
226
+
227
+ // Clear the input field after sending
228
+ promptInputEl.value = '';
229
+ }
230
+
231
+ // Notebook editing
232
+ function editNotebook() {
233
+ const editPrompt = promptInputEl.value.trim();
234
+
235
+ if (!editPrompt || !currentNotebook) {
236
+ if (!currentNotebook) {
237
+ addSystemMessage('<i class="bi bi-exclamation-triangle"></i> No notebook to edit. Generate a notebook first.');
238
+ } else {
239
+ addSystemMessage('<i class="bi bi-exclamation-triangle"></i> Please describe the changes you want to make.');
240
+ }
241
+ return;
242
+ }
243
+
244
+ // Add user message to conversation
245
+ addUserMessage('Edit request: ' + editPrompt);
246
+
247
+ // Disable input during editing
248
+ setGeneratingState(true);
249
+
250
+ const modelName = modelSelectEl.value;
251
+
252
+ // Add AI typing indicator
253
+ const aiMessageId = 'ai-typing-' + Date.now();
254
+ addTypingIndicator(aiMessageId);
255
+
256
+ handleEditStreamingResponse(editPrompt, currentNotebook, modelName, aiMessageId);
257
+
258
+ // Clear the input field after sending
259
+ promptInputEl.value = '';
260
+ }
261
+
262
+ function handleStreamingResponse(prompt, modelName, aiMessageId) {
263
+ aiResponseText = '';
264
+
265
+ // Clear any existing notebook preview and add loading indicator
266
+ notebookPreviewEl.innerHTML = '<div class="loading-preview-message text-center p-5"><div class="spinner-border text-primary" role="status"></div><p class="mt-3">Building notebook preview...</p></div>';
267
+
268
+ // Close any existing event source
269
+ if (eventSource) {
270
+ eventSource.close();
271
+ }
272
+
273
+ // Keep track of when we should update the preview (not on every tiny chunk)
274
+ let lastPreviewUpdate = 0;
275
+ const PREVIEW_UPDATE_INTERVAL = 1000; // Update preview every 1 second during streaming
276
+
277
+ // Track the last time we got a chunk - for timeout detection
278
+ let lastChunkTime = Date.now();
279
+ const CONNECTION_TIMEOUT = 30000; // 30 seconds without data = timeout
280
+
281
+ // Start a monitoring timer to detect stalled connections
282
+ const connectionTimer = setInterval(() => {
283
+ if (Date.now() - lastChunkTime > CONNECTION_TIMEOUT) {
284
+ clearInterval(connectionTimer);
285
+ if (eventSource) {
286
+ console.log("Connection timed out - closing event source");
287
+ eventSource.close();
288
+ eventSource = null;
289
+
290
+ // Process what we've got so far
291
+ updateAiMessage(aiMessageId, "**NoteGenie:** Generation timed out, but I'll process what I've received so far.");
292
+ processNotebookResponse(aiResponseText);
293
+ setGeneratingState(false);
294
+ }
295
+ }
296
+ }, 5000); // Check every 5 seconds
297
+
298
+ // Create a new event source
299
+ eventSource = new EventSource(`/generate_notebook?${new URLSearchParams({
300
+ prompt: prompt,
301
+ model: modelName,
302
+ stream: true
303
+ }).toString()}`);
304
+
305
+ eventSource.onmessage = function(event) {
306
+ // Update our last-activity timestamp
307
+ lastChunkTime = Date.now();
308
+
309
+ try {
310
+ const data = JSON.parse(event.data);
311
+
312
+ // Handle errors sent from the server
313
+ if (data.error) {
314
+ console.error("Server error:", data.error);
315
+ updateAiMessage(aiMessageId, `**Error:** ${data.error}`);
316
+
317
+ // Try to salvage what we have so far
318
+ if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
319
+ processNotebookResponse(aiResponseText);
320
+ }
321
+
322
+ eventSource.close();
323
+ eventSource = null;
324
+ setGeneratingState(false);
325
+ clearInterval(connectionTimer);
326
+ return;
327
+ }
328
+
329
+ if (data.chunk) {
330
+ aiResponseText += data.chunk;
331
+
332
+ // Extract notebook info as soon as it's available
333
+ const nameMatch = aiResponseText.match(/NOTEBOOK_NAME:?\s*(.+?)(?:\n|$)/);
334
+ const descMatch = aiResponseText.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?:\n|$)/);
335
+
336
+ if (nameMatch && nameMatch[1].trim()) {
337
+ // Update notebook title immediately when found
338
+ notebookTitleEl.textContent = nameMatch[1].trim();
339
+ }
340
+
341
+ if (descMatch && descMatch[1].trim()) {
342
+ // Update AI message to only display the description, not the full response
343
+ updateAiMessage(aiMessageId, `**NoteGenie:** ${descMatch[1].trim()}`);
344
+ } else {
345
+ // Show a simple generating message while waiting for description
346
+ updateAiMessage(aiMessageId, "**NoteGenie:** Generating your notebook...");
347
+ }
348
+
349
+ // Update preview periodically during streaming
350
+ const now = Date.now();
351
+ if (now - lastPreviewUpdate > PREVIEW_UPDATE_INTERVAL) {
352
+ lastPreviewUpdate = now;
353
+
354
+ // Only try to update preview if we have meaningful content
355
+ if (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL')) {
356
+ updateNotebookPreviewDuringStream(aiResponseText);
357
+ }
358
+ }
359
+ }
360
+
361
+ if (data.done) {
362
+ eventSource.close();
363
+ eventSource = null;
364
+ clearInterval(connectionTimer);
365
+
366
+ // Process the complete response for final rendering
367
+ processNotebookResponse(aiResponseText);
368
+ setGeneratingState(false);
369
+ }
370
+ } catch (error) {
371
+ console.error('Error parsing event data:', error, event.data);
372
+ }
373
+ };
374
+
375
+ eventSource.onerror = function(err) {
376
+ console.error('EventSource error:', err);
377
+ eventSource.close();
378
+ eventSource = null;
379
+ clearInterval(connectionTimer);
380
+
381
+ // Check if it's an auth error (most likely API key not set)
382
+ if (err.status === 401) {
383
+ updateAiMessage(aiMessageId, '**Error: API key not set or invalid.** \n\nPlease click the API Key button in the top right corner to set your Google Gemini API key.');
384
+ showApiKeyModal();
385
+ } else {
386
+ // Try to salvage what we have so far
387
+ if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
388
+ updateAiMessage(aiMessageId, '**Warning:** Connection issue occurred but I\'ll try to process what I received so far.');
389
+ processNotebookResponse(aiResponseText);
390
+ } else {
391
+ updateAiMessage(aiMessageId, '**Error:** Failed to generate notebook. Please try again.');
392
+ }
393
+ }
394
+
395
+ setGeneratingState(false);
396
+ };
397
+ }
398
+
399
+ function handleEditStreamingResponse(editPrompt, notebook, modelName, aiMessageId) {
400
+ aiResponseText = '';
401
+
402
+ // Clear any existing notebook preview and add loading indicator
403
+ notebookPreviewEl.innerHTML = '<div class="loading-preview-message text-center p-5"><div class="spinner-border text-primary" role="status"></div><p class="mt-3">Updating notebook based on your edit request...</p></div>';
404
+
405
+ // Close any existing event source
406
+ if (eventSource) {
407
+ eventSource.close();
408
+ }
409
+
410
+ // Keep track of when we should update the preview (not on every tiny chunk)
411
+ let lastPreviewUpdate = 0;
412
+ const PREVIEW_UPDATE_INTERVAL = 1000; // Update preview every 1 second during streaming, same as generate
413
+
414
+ // First, send the notebook data to the server so it's available in the session
415
+ fetch('/prepare_edit_notebook', {
416
+ method: 'POST',
417
+ headers: {
418
+ 'Content-Type': 'application/json',
419
+ },
420
+ body: JSON.stringify({
421
+ notebook: notebook
422
+ })
423
+ })
424
+ .then(response => {
425
+ if (!response.ok) {
426
+ throw new Error(`Server error: ${response.status}`);
427
+ }
428
+ return response.json();
429
+ })
430
+ .then(data => {
431
+ if (data.success) {
432
+ // Now create a new event source for editing - the notebook is now in the session
433
+ eventSource = new EventSource(`/edit_notebook?${new URLSearchParams({
434
+ edit_prompt: editPrompt,
435
+ model: modelName,
436
+ stream: true
437
+ }).toString()}`);
438
+
439
+ // Track the last time we got a chunk - for timeout detection
440
+ let lastChunkTime = Date.now();
441
+ const CONNECTION_TIMEOUT = 30000; // 30 seconds without data = timeout
442
+
443
+ // Start a monitoring timer to detect stalled connections
444
+ const connectionTimer = setInterval(() => {
445
+ if (Date.now() - lastChunkTime > CONNECTION_TIMEOUT) {
446
+ clearInterval(connectionTimer);
447
+ if (eventSource) {
448
+ console.log("Connection timed out - closing event source");
449
+ eventSource.close();
450
+ eventSource = null;
451
+
452
+ // Process what we've got so far
453
+ updateAiMessage(aiMessageId, "**NoteGenie:** Edit processing timed out, but I'll use what I've received so far.");
454
+ processNotebookResponse(aiResponseText);
455
+ setGeneratingState(false);
456
+
457
+ // Switch back to generate mode
458
+ setMode('generate');
459
+ }
460
+ }
461
+ }, 5000); // Check every 5 seconds
462
+
463
+ eventSource.onmessage = function(event) {
464
+ // Update our last-activity timestamp
465
+ lastChunkTime = Date.now();
466
+
467
+ try {
468
+ const data = JSON.parse(event.data);
469
+
470
+ // Handle errors sent from the server
471
+ if (data.error) {
472
+ console.error("Server error:", data.error);
473
+ updateAiMessage(aiMessageId, `**Error:** ${data.error}`);
474
+
475
+ // Try to salvage what we have so far
476
+ if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
477
+ processNotebookResponse(aiResponseText);
478
+ }
479
+
480
+ eventSource.close();
481
+ eventSource = null;
482
+ setGeneratingState(false);
483
+ clearInterval(connectionTimer);
484
+
485
+ // Switch back to generate mode
486
+ setMode('generate');
487
+ return;
488
+ }
489
+
490
+ if (data.chunk) {
491
+ aiResponseText += data.chunk;
492
+
493
+ // Extract notebook info as soon as it's available, similar to generate function
494
+ const nameMatch = aiResponseText.match(/NOTEBOOK_NAME:?\s*(.+?)(?:\n|$)/);
495
+ const descMatch = aiResponseText.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?:\n|$)/);
496
+
497
+ if (nameMatch && nameMatch[1].trim()) {
498
+ // Update notebook title immediately when found
499
+ notebookTitleEl.textContent = nameMatch[1].trim();
500
+ }
501
+
502
+ if (descMatch && descMatch[1].trim()) {
503
+ // Update AI message with the actual description from the edited notebook
504
+ updateAiMessage(aiMessageId, `**NoteGenie:** ${descMatch[1].trim()}`);
505
+ } else {
506
+ // Show a simple editing message while waiting for description
507
+ updateAiMessage(aiMessageId, "**NoteGenie:** Updating notebook based on your edit request...");
508
+ }
509
+
510
+ // Update preview periodically during streaming, just like in generate function
511
+ const now = Date.now();
512
+ if (now - lastPreviewUpdate > PREVIEW_UPDATE_INTERVAL) {
513
+ lastPreviewUpdate = now;
514
+
515
+ // Only try to update preview if we have meaningful content
516
+ if (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL')) {
517
+ updateNotebookPreviewDuringStream(aiResponseText);
518
+ }
519
+ }
520
+ }
521
+
522
+ if (data.done) {
523
+ eventSource.close();
524
+ eventSource = null;
525
+ clearInterval(connectionTimer);
526
+
527
+ // Process the complete response for final rendering
528
+ processNotebookResponse(aiResponseText);
529
+ setGeneratingState(false);
530
+
531
+ // Get the final description for the AI message
532
+ const finalDescMatch = aiResponseText.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?=\n\s*---|\n\s*$|$)/);
533
+ const finalDesc = finalDescMatch ? finalDescMatch[1].trim() : "I've updated the notebook based on your edit request.";
534
+
535
+ // Update AI message with final description
536
+ updateAiMessage(aiMessageId, `**NoteGenie:** ${finalDesc}`);
537
+
538
+ // Switch back to generate mode
539
+ setMode('generate');
540
+ }
541
+ } catch (error) {
542
+ console.error('Error parsing event data:', error, event.data);
543
+ }
544
+ };
545
+
546
+ eventSource.onerror = function(err) {
547
+ console.error('EventSource error:', err);
548
+ eventSource.close();
549
+ eventSource = null;
550
+ clearInterval(connectionTimer);
551
+
552
+ // Try to salvage what we have so far
553
+ if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
554
+ updateAiMessage(aiMessageId, '**Warning:** Connection issue occurred but I\'ll try to process what I received so far.');
555
+ processNotebookResponse(aiResponseText);
556
+ } else {
557
+ updateAiMessage(aiMessageId, '**Error:** Failed to edit notebook. ' + (err.message || 'Unknown error'));
558
+ }
559
+
560
+ setGeneratingState(false);
561
+
562
+ // Switch back to generate mode
563
+ setMode('generate');
564
+ };
565
+ } else {
566
+ throw new Error(data.message || 'Failed to prepare notebook for editing');
567
+ }
568
+ })
569
+ .catch(error => {
570
+ console.error('Error preparing notebook for edit:', error);
571
+ updateAiMessage(aiMessageId, '**Error:** ' + error.message);
572
+ setGeneratingState(false);
573
+ });
574
+ }
575
+
576
+ // Show API key modal
577
+ function showApiKeyModal() {
578
+ const apiKeyModal = new bootstrap.Modal(document.getElementById('apiKeyModal'));
579
+ apiKeyModal.show();
580
+ }
581
+
582
+ // Helper functions
583
+ function updateNotebookPreviewDuringStream(text) {
584
+ try {
585
+ // Extract notebook info for title
586
+ const nameMatch = text.match(/NOTEBOOK_NAME:?\s*(.+?)(?:\n|$)/);
587
+ const name = nameMatch ? nameMatch[1].trim() : 'Generating Notebook...';
588
+
589
+ // Update notebook title
590
+ notebookTitleEl.textContent = name;
591
+
592
+ // Find all cell markers in order of appearance
593
+ const cellMarkers = [...text.matchAll(/---\s*(MARKDOWN|CODE)\s*CELL\s*---/g)];
594
+
595
+ // If no markers found, don't update preview yet
596
+ if (cellMarkers.length === 0) return;
597
+
598
+ // Check if user is already near the bottom before deciding to auto-scroll
599
+ const isNearBottom = notebookPreviewEl.scrollHeight - notebookPreviewEl.scrollTop - notebookPreviewEl.clientHeight < 100;
600
+
601
+ // Initialize cell counters
602
+ let markdownCellCount = 0;
603
+ let codeCellCount = 0;
604
+
605
+ // Clear preview and prepare for new content
606
+ notebookPreviewEl.innerHTML = '';
607
+
608
+ // Process each cell in order
609
+ for (let i = 0; i < cellMarkers.length; i++) {
610
+ const currentMarker = cellMarkers[i];
611
+ const nextMarker = cellMarkers[i + 1];
612
+ const cellType = currentMarker[1]; // MARKDOWN or CODE
613
+
614
+ // Extract cell content between current marker and next marker (or end of text)
615
+ const markerEndIndex = currentMarker.index + currentMarker[0].length;
616
+ const contentEndIndex = nextMarker ? nextMarker.index : text.length;
617
+ let cellContent = text.substring(markerEndIndex, contentEndIndex).trim();
618
+
619
+ if (cellType === 'MARKDOWN') {
620
+ markdownCellCount++;
621
+ createMarkdownCell(cellContent, markdownCellCount, notebookPreviewEl);
622
+ } else if (cellType === 'CODE') {
623
+ // Extract Python code from between ```python and ```
624
+ const codeMatch = cellContent.match(/```python\s*([\s\S]*?)```/);
625
+ if (codeMatch) {
626
+ codeCellCount++;
627
+ createCodeCell(codeMatch[1].trim(), codeCellCount, notebookPreviewEl);
628
+ }
629
+ }
630
+ }
631
+
632
+ // Only auto-scroll if user was already near the bottom
633
+ if (isNearBottom) {
634
+ notebookPreviewEl.scrollTop = notebookPreviewEl.scrollHeight;
635
+ } else {
636
+ // Show the scroll to bottom button
637
+ scrollToBottomBtn.classList.add('visible');
638
+ }
639
+ } catch (error) {
640
+ console.error('Error updating preview during stream:', error);
641
+ // Don't update if there's an error - wait for complete response
642
+ }
643
+ }
644
+
645
+ function createMarkdownCell(content, cellNumber, container) {
646
+ const cellDiv = document.createElement('div');
647
+ cellDiv.className = 'notebook-cell cell-markdown';
648
+
649
+ // Add header for markdown cell
650
+ const headerDiv = document.createElement('div');
651
+ headerDiv.className = 'cell-header markdown-header';
652
+ headerDiv.innerHTML = `<span class="cell-type">Markdown [${cellNumber}]</span>`;
653
+ cellDiv.appendChild(headerDiv);
654
+
655
+ // Add markdown content
656
+ const contentDiv = document.createElement('div');
657
+ contentDiv.className = 'cell-content';
658
+ contentDiv.innerHTML = marked.parse(content);
659
+ cellDiv.appendChild(contentDiv);
660
+
661
+ container.appendChild(cellDiv);
662
+ }
663
+
664
+ function createCodeCell(content, cellNumber, container) {
665
+ const cellDiv = document.createElement('div');
666
+ cellDiv.className = 'notebook-cell cell-code';
667
+
668
+ // Add header for code cell
669
+ const headerDiv = document.createElement('div');
670
+ headerDiv.className = 'cell-header code-header';
671
+ headerDiv.innerHTML = `<span class="cell-type">Code [${cellNumber}]</span>`;
672
+ cellDiv.appendChild(headerDiv);
673
+
674
+ // Add code content
675
+ const contentDiv = document.createElement('div');
676
+ contentDiv.className = 'cell-content';
677
+ const preEl = document.createElement('pre');
678
+ const codeEl = document.createElement('code');
679
+ codeEl.className = 'language-python';
680
+ codeEl.textContent = content;
681
+ preEl.appendChild(codeEl);
682
+ contentDiv.appendChild(preEl);
683
+ cellDiv.appendChild(contentDiv);
684
+
685
+ container.appendChild(cellDiv);
686
+
687
+ // Highlight syntax
688
+ Prism.highlightElement(codeEl);
689
+ }
690
+
691
+ function processNotebookResponse(text) {
692
+ // Extract notebook info using improved regex patterns that handle markdown better
693
+ const nameMatch = text.match(/NOTEBOOK_NAME:?\s*(.+?)(?=\n\s*NOTEBOOK_DESCRIPTION|\n\s*---|\n\s*$|$)/);
694
+ const descMatch = text.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?=\n\s*---|\n\s*$|$)/);
695
+
696
+ // Clean up the name and description
697
+ let name = nameMatch ? nameMatch[1].trim() : 'Generated Notebook';
698
+ let description = descMatch ? descMatch[1].trim() : '';
699
+
700
+ // Remove markdown formatting
701
+ name = name.replace(/\*\*/g, '').replace(/\*/g, '');
702
+ description = description.replace(/\*\*/g, '').replace(/\*/g, '');
703
+
704
+ // Update notebook title
705
+ notebookTitleEl.textContent = name;
706
+
707
+ // Update the AI message with just the description
708
+ const chatMessageId = document.querySelector('.ai-message').id;
709
+ updateAiMessage(chatMessageId, `**NoteGenie:** ${description}`);
710
+
711
+ // OPTIMIZATION: Process the notebook client-side if possible for small to medium notebooks
712
+ if (text.length < 50000) { // Only try client-side processing for reasonably sized notebooks
713
+ try {
714
+ // Try to process notebook directly in the client
715
+ const notebookJson = clientSideFormatNotebook(text);
716
+ renderNotebook(notebookJson);
717
+ currentNotebook = notebookJson;
718
+ downloadBtnEl.disabled = false;
719
+ editModeBtnEl.disabled = false;
720
+ return; // Exit if successful
721
+ } catch (error) {
722
+ console.log("Client-side formatting failed, falling back to server:", error);
723
+ // Fall through to server-side processing if client-side fails
724
+ }
725
+ }
726
+
727
+ // OPTIMIZATION: Show loading indicator with progress message
728
+ notebookPreviewEl.innerHTML = '<div class="loading-preview-message text-center p-5"><div class="spinner-border text-primary" role="status"></div><p class="mt-3">Finalizing notebook format...</p></div>';
729
+
730
+ // Make API call to convert text to notebook format with a timeout to prevent UI freezing
731
+ setTimeout(() => {
732
+ fetch('/generate_notebook', {
733
+ method: 'POST',
734
+ headers: {
735
+ 'Content-Type': 'application/json',
736
+ },
737
+ body: JSON.stringify({
738
+ prompt: text, // Send the full AI response as a prompt
739
+ model: 'gemini-2.0-flash', // Use a faster model for formatting
740
+ stream: false,
741
+ format_only: true
742
+ })
743
+ })
744
+ .then(response => response.json())
745
+ .then(data => {
746
+ if (data.success) {
747
+ renderNotebook(data.notebook);
748
+ currentNotebook = data.notebook;
749
+ downloadBtnEl.disabled = false;
750
+ editModeBtnEl.disabled = false;
751
+ }
752
+ })
753
+ .catch(error => {
754
+ console.error('Error formatting notebook:', error);
755
+ notebookPreviewEl.innerHTML = `<div class="alert alert-danger">Error formatting notebook: ${error.message}</div>`;
756
+ });
757
+ }, 10); // Small delay to allow UI to update
758
+ }
759
+
760
+ // ADDED: Client-side notebook formatting function to reduce server dependency
761
+ function clientSideFormatNotebook(content) {
762
+ // Extract cells with improved regex that captures end of file properly
763
+ const markdownCells = content.match(/---\s*MARKDOWN\s*CELL\s*---\s*([\s\S]*?)(?=---\s*(?:MARKDOWN|CODE)\s*CELL\s*---|$)/g) || [];
764
+ const codeCells = content.match(/---\s*CODE\s*CELL\s*---\s*```python\s*([\s\S]*?)```/gs) || [];
765
+
766
+ // Get cell order with improved markers
767
+ const cellMarkers = [...content.matchAll(/---\s*(MARKDOWN|CODE)\s*CELL\s*---/g)];
768
+ const cellTypes = cellMarkers.map(match => match[1]);
769
+
770
+ // Initialize notebook structure
771
+ const cells = [];
772
+ let mdIdx = 0;
773
+ let codeIdx = 0;
774
+
775
+ // Populate cells in the correct order
776
+ for (let i = 0; i < cellTypes.length; i++) {
777
+ const cellType = cellTypes[i];
778
+
779
+ if (cellType === 'MARKDOWN' && mdIdx < markdownCells.length) {
780
+ // Extract markdown content with improved regex
781
+ let md = markdownCells[mdIdx].replace(/---\s*MARKDOWN\s*CELL\s*---/, '').trim();
782
+ cells.push({
783
+ cell_type: 'markdown',
784
+ metadata: {},
785
+ source: md.split('\n')
786
+ });
787
+ mdIdx++;
788
+ } else if (cellType === 'CODE' && codeIdx < codeCells.length) {
789
+ // Extract code content with improved regex
790
+ let codeMatch = codeCells[codeIdx].match(/```python\s*([\s\S]*?)```/s);
791
+ let code = codeMatch ? codeMatch[1].trim() : '';
792
+ cells.push({
793
+ cell_type: 'code',
794
+ execution_count: null,
795
+ metadata: {},
796
+ outputs: [],
797
+ source: code.split('\n')
798
+ });
799
+ codeIdx++;
800
+ }
801
+ }
802
+
803
+ // Process the final cell if it wasn't captured above
804
+ if (cellMarkers.length > 0) {
805
+ const lastMarker = cellMarkers[cellMarkers.length - 1];
806
+ const lastMarkerEndIndex = lastMarker.index + lastMarker[0].length;
807
+
808
+ // If there's content after the last marker, process it
809
+ if (lastMarkerEndIndex < content.length) {
810
+ const lastCellType = lastMarker[1]; // MARKDOWN or CODE
811
+ const lastCellContent = content.substring(lastMarkerEndIndex).trim();
812
+
813
+ // Only add if not already captured
814
+ const isAlreadyCaptured =
815
+ (lastCellType === 'MARKDOWN' && mdIdx >= markdownCells.length) ||
816
+ (lastCellType === 'CODE' && codeIdx >= codeCells.length);
817
+
818
+ if (!isAlreadyCaptured && lastCellContent) {
819
+ if (lastCellType === 'MARKDOWN') {
820
+ cells.push({
821
+ cell_type: 'markdown',
822
+ metadata: {},
823
+ source: lastCellContent.split('\n')
824
+ });
825
+ } else if (lastCellType === 'CODE') {
826
+ // Extract code if present
827
+ const codeMatch = lastCellContent.match(/```python\s*([\s\S]*?)```/s);
828
+ if (codeMatch) {
829
+ cells.push({
830
+ cell_type: 'code',
831
+ execution_count: null,
832
+ metadata: {},
833
+ outputs: [],
834
+ source: codeMatch[1].trim().split('\n')
835
+ });
836
+ }
837
+ }
838
+ }
839
+ }
840
+ }
841
+
842
+ // Fallback approach if no cells were found or if the extraction didn't work
843
+ // ...existing code...
844
+
845
+ return {
846
+ cells: cells,
847
+ metadata: {
848
+ kernelspec: {
849
+ display_name: 'Python 3',
850
+ language: 'python',
851
+ name: 'python3'
852
+ },
853
+ language_info: {
854
+ name: 'python',
855
+ version: '3.8.0'
856
+ }
857
+ },
858
+ nbformat: 4,
859
+ nbformat_minor: 4
860
+ };
861
+ }
862
+
863
+ function downloadNotebook() {
864
+ if (!currentNotebook) {
865
+ return;
866
+ }
867
+
868
+ // Improved filename cleaning - remove markdown formatting and unsafe filename characters
869
+ let cleanName = notebookTitleEl.textContent || 'generated_notebook';
870
+ // First remove markdown formatting characters
871
+ cleanName = cleanName.replace(/\*\*/g, '').replace(/\*/g, '').replace(/_/g, ' ');
872
+ // Then remove any characters that aren't safe for filenames
873
+ cleanName = cleanName.replace(/[^\w\s-]/gi, '').trim();
874
+ // Replace multiple spaces with single space
875
+ cleanName = cleanName.replace(/\s+/g, ' ');
876
+
877
+ const filename = cleanName || 'generated_notebook';
878
+
879
+ fetch('/download_notebook', {
880
+ method: 'POST',
881
+ headers: {
882
+ 'Content-Type': 'application/json',
883
+ },
884
+ body: JSON.stringify({
885
+ notebook: currentNotebook,
886
+ filename: `${filename}.ipynb`
887
+ })
888
+ })
889
+ .then(response => response.blob())
890
+ .then(blob => {
891
+ const url = window.URL.createObjectURL(blob);
892
+ const a = document.createElement('a');
893
+ a.style.display = 'none';
894
+ a.href = url;
895
+ a.download = `${filename}.ipynb`;
896
+ document.body.appendChild(a);
897
+ a.click();
898
+ window.URL.revokeObjectURL(url);
899
+ })
900
+ .catch(error => {
901
+ console.error('Error downloading notebook:', error);
902
+ addSystemMessage('Error downloading notebook: ' + error.message);
903
+ });
904
+ }
905
+
906
+ function renderNotebook(notebook) {
907
+ notebookPreviewEl.innerHTML = '';
908
+
909
+ if (!notebook || !notebook.cells || !notebook.cells.length) {
910
+ notebookPreviewEl.innerHTML = '<div class="alert alert-warning">No notebook content available</div>';
911
+ return;
912
+ }
913
+
914
+ let cellCount = 0;
915
+
916
+ notebook.cells.forEach((cell, index) => {
917
+ cellCount++;
918
+
919
+ if (cell.cell_type === 'markdown') {
920
+ const content = Array.isArray(cell.source) ? cell.source.join('\n') : cell.source;
921
+ createMarkdownCell(content, cellCount, notebookPreviewEl);
922
+ } else if (cell.cell_type === 'code') {
923
+ const codeContent = Array.isArray(cell.source) ? cell.source.join('\n') : cell.source;
924
+ createCodeCell(codeContent, cellCount, notebookPreviewEl);
925
+ }
926
+ });
927
+
928
+ // Check if we should scroll to the bottom after rendering
929
+ const isNearBottom = notebookPreviewEl.scrollHeight - notebookPreviewEl.scrollTop - notebookPreviewEl.clientHeight < 100;
930
+ if (isNearBottom) {
931
+ notebookPreviewEl.scrollTop = notebookPreviewEl.scrollHeight;
932
+ } else {
933
+ // Show the scroll to bottom button
934
+ scrollToBottomBtn.classList.add('visible');
935
+ }
936
+
937
+ // Enable edit and download buttons when notebook is available
938
+ downloadBtnEl.disabled = false;
939
+ editModeBtnEl.disabled = false;
940
+ }
941
+
942
+ // UI Message handling
943
+ function addUserMessage(text) {
944
+ // Remove welcome message if present
945
+ const welcomeMessage = document.querySelector('.welcome-message');
946
+ if (welcomeMessage) {
947
+ welcomeMessage.remove();
948
+ }
949
+
950
+ const messageDiv = document.createElement('div');
951
+ messageDiv.className = 'message user-message';
952
+
953
+ // Fix: Create a span for the "You:" prefix and handle the text separately
954
+ // to prevent marked from adding paragraph tags that cause line breaks
955
+ const userPrefix = document.createElement('span');
956
+ userPrefix.className = 'user-prefix';
957
+ userPrefix.innerHTML = '<strong>You:</strong> ';
958
+
959
+ const messageContent = document.createElement('span');
960
+ messageContent.className = 'message-content';
961
+ messageContent.innerHTML = marked.parse(text);
962
+
963
+ // Remove any leading <p> and trailing </p> tags that marked adds
964
+ messageContent.innerHTML = messageContent.innerHTML
965
+ .replace(/^<p>/, '')
966
+ .replace(/<\/p>$/, '');
967
+
968
+ messageDiv.appendChild(userPrefix);
969
+ messageDiv.appendChild(messageContent);
970
+
971
+ conversationEl.appendChild(messageDiv);
972
+ conversationEl.scrollTop = conversationEl.scrollHeight;
973
+ }
974
+
975
+ function addTypingIndicator(id) {
976
+ const messageDiv = document.createElement('div');
977
+ messageDiv.className = 'message ai-message';
978
+ messageDiv.id = id;
979
+
980
+ const typingDiv = document.createElement('div');
981
+ typingDiv.className = 'typing-indicator';
982
+ for (let i = 0; i < 3; i++) {
983
+ const dot = document.createElement('span');
984
+ dot.className = 'typing-dot';
985
+ typingDiv.appendChild(dot);
986
+ }
987
+
988
+ messageDiv.appendChild(typingDiv);
989
+ conversationEl.appendChild(messageDiv);
990
+ conversationEl.scrollTop = conversationEl.scrollHeight;
991
+ }
992
+
993
+ function updateAiMessage(id, text) {
994
+ const messageDiv = document.getElementById(id);
995
+ if (messageDiv) {
996
+ if (text.startsWith('**NoteGenie:**')) {
997
+ text = text.replace('**NoteGenie:**', '**NoteGenie:**');
998
+ } else if (text.startsWith('**Error:**')) {
999
+ text = text.replace('**Error:**', '**<i class="bi bi-exclamation-triangle"></i> Error:**');
1000
+ }
1001
+
1002
+ messageDiv.innerHTML = marked.parse(text);
1003
+ // Ensure auto-scrolling in the chat panel
1004
+ conversationEl.scrollTop = conversationEl.scrollHeight;
1005
+ }
1006
+ }
1007
+
1008
+ function addSystemMessage(text) {
1009
+ const messageDiv = document.createElement('div');
1010
+ messageDiv.className = 'info-card mb-3';
1011
+ messageDiv.innerHTML = text;
1012
+ conversationEl.appendChild(messageDiv);
1013
+ conversationEl.scrollTop = conversationEl.scrollHeight;
1014
+ }
1015
+
1016
+ // UI state management
1017
+ function setGeneratingState(isGenerating) {
1018
+ if (isGenerating) {
1019
+ actionBtnEl.disabled = true;
1020
+ // Update loading state for the send button
1021
+ actionBtnEl.innerHTML = '<span class="material-icons spinning">autorenew</span>';
1022
+ promptInputEl.disabled = true;
1023
+
1024
+ // Disable buttons but preserve their active/inactive visual state
1025
+ generateModeBtnEl.disabled = true;
1026
+ editModeBtnEl.disabled = true;
1027
+
1028
+ // Make sure mode toggle visual state is preserved
1029
+ if (currentMode === 'generate') {
1030
+ generateModeBtnEl.classList.add('active');
1031
+ editModeBtnEl.classList.remove('active');
1032
+ } else {
1033
+ editModeBtnEl.classList.add('active');
1034
+ generateModeBtnEl.classList.remove('active');
1035
+ }
1036
+ } else {
1037
+ // Reset the debounce flag when generation completes
1038
+ isActionInProgress = false;
1039
+
1040
+ actionBtnEl.disabled = false;
1041
+ // Always use the send icon for the inline button
1042
+ actionBtnEl.innerHTML = '<span class="material-icons">send</span>';
1043
+ promptInputEl.disabled = false;
1044
+ promptInputEl.focus();
1045
+
1046
+ generateModeBtnEl.disabled = false;
1047
+
1048
+ // Only enable edit mode button if we have a current notebook
1049
+ editModeBtnEl.disabled = !currentNotebook;
1050
+ }
1051
+ }
1052
+ });
templates/index.html ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>NoteGenie - AI-Powered Jupyter Notebook Generator</title>
7
+ <!-- Google fonts -->
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Roboto:wght@300;400;500&display=swap" rel="stylesheet">
11
+ <!-- Material Icons -->
12
+ <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
13
+ <!-- Bootstrap (still used for grid and components) -->
14
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
15
+ <!-- Prism for code highlighting -->
16
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
17
+ <!-- Custom styles -->
18
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
19
+ </head>
20
+ <body>
21
+ <div class="app-container">
22
+ <!-- App Header -->
23
+ <header class="app-header">
24
+ <div class="header-content">
25
+ <div class="logo-section">
26
+ <span class="material-icons logo-icon">auto_awesome</span>
27
+ <h1 class="product-name">NoteGenie</h1>
28
+ </div>
29
+ <div class="header-actions">
30
+ <button class="google-button outlined" data-bs-toggle="modal" data-bs-target="#apiKeyModal">
31
+ <span class="material-icons">vpn_key</span>
32
+ <span>API Key</span>
33
+ </button>
34
+ </div>
35
+ </div>
36
+ </header>
37
+
38
+ <!-- Main Content Area -->
39
+ <main class="main-container">
40
+ <!-- Left Panel - Chat Interface -->
41
+ <div class="panel chat-panel">
42
+ <div id="conversation" class="conversation-container">
43
+ <div class="welcome-message">
44
+ <h2><span class="material-icons">auto_awesome</span> Welcome to NoteGenie</h2>
45
+ <p>Generate complete Jupyter notebooks from your text prompts using Google's Gemini AI.</p>
46
+ <div class="welcome-steps">
47
+ <div class="welcome-step">
48
+ <div class="step-number">1</div>
49
+ <div class="step-text">Set your Google Gemini API key</div>
50
+ </div>
51
+ <div class="welcome-step">
52
+ <div class="step-number">2</div>
53
+ <div class="step-text">Describe the notebook you want</div>
54
+ </div>
55
+ <div class="welcome-step">
56
+ <div class="step-number">3</div>
57
+ <div class="step-text">View and refine the generated notebook</div>
58
+ </div>
59
+ <div class="welcome-step">
60
+ <div class="step-number">4</div>
61
+ <div class="step-text">Download as .ipynb file</div>
62
+ </div>
63
+ </div>
64
+ <div class="info-card">
65
+ <span class="material-icons info-icon">lightbulb</span>
66
+ <div class="info-content">
67
+ <p><strong>Tip:</strong> Be specific in your request for best results. Include the topic, intended audience, and desired level of detail.</p>
68
+ </div>
69
+ </div>
70
+ <div class="example-section">
71
+ <h5>Example prompts:</h5>
72
+ <ul class="example-list">
73
+ <li>"Create a notebook for data visualization with Plotly Express showing different chart types"</li>
74
+ <li>"Make a machine learning notebook that shows text classification using BERT"</li>
75
+ <li>"Build a beginner-friendly introduction to pandas with common data operations"</li>
76
+ </ul>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <div class="input-container">
82
+ <div class="input-options">
83
+ <select id="modelSelect" class="model-selector">
84
+ <option value="gemini-2.0-pro">Gemini 2.0 Pro</option>
85
+ <option value="gemini-2.0-flash">Gemini 2.0 Flash</option>
86
+ <option value="gemini-2.0-flash-thinking">Gemini 2.0 Flash Thinking</option>
87
+ </select>
88
+ <div class="mode-toggle">
89
+ <button type="button" class="toggle-button active" id="generateModeBtn">Generate</button>
90
+ <button type="button" class="toggle-button" id="editModeBtn" disabled>Edit</button>
91
+ </div>
92
+ </div>
93
+ <div class="input-field-container">
94
+ <textarea id="promptInput" class="prompt-input" placeholder="Describe the notebook you want..." rows="3"></textarea>
95
+ <button id="actionBtn" class="send-button">
96
+ <span class="material-icons">send</span>
97
+ </button>
98
+ </div>
99
+ <!-- Keep the original div but it will be hidden via CSS -->
100
+ <div class="input-actions">
101
+ <!-- Original button is now moved into the input field -->
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <!-- Right Panel - Notebook Preview -->
107
+ <div class="panel notebook-panel">
108
+ <div class="notebook-header">
109
+ <h3 id="notebookTitle" class="notebook-title"></h3>
110
+ <div class="notebook-actions">
111
+ <button id="downloadBtn" class="google-button" disabled>
112
+ <span class="material-icons">download</span>
113
+ <span>Download</span>
114
+ </button>
115
+ </div>
116
+ </div>
117
+ <div id="notebookPreview" class="notebook-preview">
118
+ <div class="placeholder-content">
119
+ <span class="material-icons large-icon">description</span>
120
+ <p>Generate a notebook to see the preview here</p>
121
+ </div>
122
+ </div>
123
+ </div>
124
+ </main>
125
+ </div>
126
+
127
+ <!-- API Key Modal -->
128
+ <div class="modal fade" id="apiKeyModal" tabindex="-1" aria-hidden="true">
129
+ <div class="modal-dialog modal-dialog-centered">
130
+ <div class="modal-content google-modal">
131
+ <div class="modal-header">
132
+ <h5 class="modal-title">
133
+ <span class="material-icons">vpn_key</span>
134
+ Set Google Gemini API Key
135
+ </h5>
136
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
137
+ </div>
138
+ <div class="modal-body">
139
+ <div class="info-card">
140
+ <span class="material-icons info-icon">info</span>
141
+ <div class="info-content">
142
+ <p><strong>You need to set your Google API key to use NoteGenie.</strong></p>
143
+ <p>Your key will be remembered for future sessions on this device.</p>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="steps-card">
148
+ <div class="steps-header">
149
+ <span class="material-icons">key</span>
150
+ <strong>How to get your API key:</strong>
151
+ </div>
152
+ <div class="steps-content">
153
+ <ol class="steps-list">
154
+ <li>Go to <a href="https://aistudio.google.com/" target="_blank">Google AI Studio</a></li>
155
+ <li>Click on "Get API key" in the top left</li>
156
+ <li>Click "Create API key"</li>
157
+ <li>Copy the generated API key and paste it below</li>
158
+ </ol>
159
+ </div>
160
+ </div>
161
+
162
+ <div class="input-field">
163
+ <label for="apiKeyInput" class="input-label">API Key</label>
164
+ <input type="password" class="text-input" id="apiKeyInput" placeholder="Enter your Gemini API key">
165
+ <div class="input-helper">Your API key is stored securely and never sent to third parties.</div>
166
+ </div>
167
+ <div id="apiKeyFeedback"></div>
168
+ </div>
169
+ <div class="modal-footer">
170
+ <button type="button" class="google-button text" data-bs-dismiss="modal">Cancel</button>
171
+ <button type="button" class="google-button primary" id="saveApiKeyBtn">Save</button>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <!-- Floating scroll button -->
178
+ <button class="scroll-button" id="scrollToBottomBtn">
179
+ <span class="material-icons">keyboard_arrow_down</span>
180
+ </button>
181
+
182
+ <!-- Scripts -->
183
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
184
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-core.min.js"></script>
185
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
186
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
187
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
188
+ </body>
189
+ </html>
utils/ai_helpers.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import google.generativeai as genai
2
+ from flask import Response, stream_with_context
3
+ import json
4
+ import time
5
+
6
+ def craft_notebook_prompt(user_prompt):
7
+ """Enhance the user prompt with instructions for generating a well-structured Jupyter notebook."""
8
+ enhanced_prompt = f"""
9
+ Create a complete Jupyter notebook based on this request: "{user_prompt}"
10
+
11
+ Please structure your response as follows:
12
+
13
+ NOTEBOOK_NAME: [Short, descriptive name for the notebook with no formating "**"]
14
+ NOTEBOOK_DESCRIPTION: [a description of the notebook's purpose with no formating]
15
+
16
+ Then provide the complete notebook with proper alternating Markdown and code cells.
17
+ Format each cell as follows:
18
+
19
+ --- MARKDOWN CELL ---
20
+ [Markdown content here]
21
+
22
+ --- CODE CELL ---
23
+ ```python
24
+ [Python code here]
25
+ ```
26
+
27
+ Important guidelines:
28
+ - Include comprehensive explanations in Markdown cells
29
+ - Ensure all code is executable and properly commented
30
+ - Include data loading, processing, and visualization where appropriate
31
+ - Add explanatory text before and after code sections
32
+ - Include example outputs or expected results when relevant
33
+ - Structure the notebook with clear section headers in Markdown
34
+ """
35
+ return enhanced_prompt
36
+
37
+ def craft_edit_prompt(edit_request, notebook_json):
38
+ """Create a prompt for editing an existing notebook."""
39
+ # Extract cells from notebook JSON for context
40
+ cells = []
41
+ for i, cell in enumerate(notebook_json.get('cells', [])):
42
+ cell_type = cell.get('cell_type', 'unknown')
43
+ source = cell.get('source', [])
44
+ if isinstance(source, list):
45
+ source = '\n'.join(source)
46
+
47
+ if cell_type == 'markdown':
48
+ cells.append(f"--- CELL {i+1} (MARKDOWN) ---\n{source}")
49
+ elif cell_type == 'code':
50
+ cells.append(f"--- CELL {i+1} (CODE) ---\n```python\n{source}\n```")
51
+
52
+ notebook_content = '\n\n'.join(cells)
53
+
54
+ # Create prompt with edit instructions
55
+ enhanced_prompt = f"""
56
+ I have a Jupyter notebook that I'd like you to modify based on this edit request: "{edit_request}"
57
+
58
+ Here's the current notebook content:
59
+
60
+ NOTEBOOK_STRUCTURE:
61
+ {notebook_content}
62
+
63
+ Please provide the complete updated notebook with the requested changes, following the same format:
64
+
65
+ NOTEBOOK_NAME: [Keep or update the notebook name]
66
+ NOTEBOOK_DESCRIPTION: [Keep or update the notebook description]
67
+
68
+ Then provide the complete notebook with proper alternating Markdown and code cells.
69
+ Format each cell as follows:
70
+
71
+ --- MARKDOWN CELL ---
72
+ [Markdown content here]
73
+
74
+ --- CODE CELL ---
75
+ ```python
76
+ [Python code here]
77
+ ```
78
+
79
+ Important guidelines:
80
+ - Make only the changes requested in the edit request
81
+ - Preserve the overall structure of the notebook
82
+ - Keep all content from the original notebook that doesn't need modification
83
+ - Ensure all code remains executable and properly commented
84
+ - Feel free to reorganize, add, or remove cells as needed to fulfill the edit request
85
+ """
86
+ return enhanced_prompt
87
+
88
+ def generate_notebook(user_prompt, model_name="gemini-2.0-pro-exp-02-05"):
89
+ """Generate a complete notebook using Gemini API."""
90
+ model = genai.GenerativeModel(model_name)
91
+
92
+ enhanced_prompt = craft_notebook_prompt(user_prompt)
93
+ response = model.generate_content(enhanced_prompt)
94
+
95
+ return response.text
96
+
97
+ def edit_notebook(edit_request, notebook_json, model_name="gemini-2.0-pro-exp-02-05"):
98
+ """Edit an existing notebook based on user request."""
99
+ model = genai.GenerativeModel(model_name)
100
+
101
+ enhanced_prompt = craft_edit_prompt(edit_request, notebook_json)
102
+ response = model.generate_content(enhanced_prompt)
103
+
104
+ return response.text
105
+
106
+ def stream_notebook_generation(user_prompt, model_name="gemini-2.0-pro-exp-02-05"):
107
+ """Stream notebook generation responses from Gemini API."""
108
+ model = genai.GenerativeModel(model_name)
109
+ enhanced_prompt = craft_notebook_prompt(user_prompt)
110
+
111
+ def generate():
112
+ try:
113
+ response = model.generate_content(enhanced_prompt, stream=True)
114
+
115
+ # Send a notification that streaming has started
116
+ yield f"data: {json.dumps({'chunk': 'Starting notebook generation...'})}\n\n"
117
+
118
+ for chunk in response:
119
+ try:
120
+ # More robust empty chunk detection
121
+ if not hasattr(chunk, 'parts') or not chunk.parts:
122
+ # Skip this empty chunk and continue
123
+ print("Warning: Empty chunk received (no parts)")
124
+ continue
125
+
126
+ # First try the standard text property
127
+ try:
128
+ if hasattr(chunk, 'text') and chunk.text:
129
+ yield f"data: {json.dumps({'chunk': chunk.text})}\n\n"
130
+ continue # If we successfully got text, continue to next chunk
131
+ except (AttributeError, IndexError):
132
+ # If accessing text property fails, we'll try extracting from parts
133
+ pass
134
+
135
+ # If we're here, we couldn't get text directly, try to extract from parts
136
+ for part in chunk.parts:
137
+ # Extract text from part using different approaches
138
+ if hasattr(part, 'text') and part.text:
139
+ yield f"data: {json.dumps({'chunk': part.text})}\n\n"
140
+ elif isinstance(part, dict) and 'text' in part:
141
+ yield f"data: {json.dumps({'chunk': part['text']})}\n\n"
142
+ elif hasattr(part, 'string_value'):
143
+ yield f"data: {json.dumps({'chunk': part.string_value})}\n\n"
144
+
145
+ except (AttributeError, IndexError, TypeError) as e:
146
+ # Log the error but continue - don't break the stream
147
+ print(f"Error processing chunk: {e}, chunk structure: {repr(chunk)[:200]}")
148
+ continue
149
+
150
+ # Briefly pause to prevent overwhelming the client
151
+ time.sleep(0.01)
152
+
153
+ yield f"data: {json.dumps({'done': True})}\n\n"
154
+
155
+ except Exception as e:
156
+ # Send error to client and close stream
157
+ error_message = f"Error generating notebook: {str(e)}"
158
+ print(error_message)
159
+ yield f"data: {json.dumps({'error': error_message})}\n\n"
160
+ yield f"data: {json.dumps({'done': True})}\n\n"
161
+
162
+ return Response(stream_with_context(generate()), content_type="text/event-stream")
163
+
164
+ def stream_notebook_edit(edit_request, notebook_json, model_name="gemini-2.0-pro-exp-02-05"):
165
+ """Stream notebook editing responses from Gemini API."""
166
+ model = genai.GenerativeModel(model_name)
167
+ enhanced_prompt = craft_edit_prompt(edit_request, notebook_json)
168
+
169
+ def generate():
170
+ try:
171
+ response = model.generate_content(enhanced_prompt, stream=True)
172
+
173
+ # Send a notification that editing has started
174
+ yield f"data: {json.dumps({'chunk': 'Starting notebook edit...'})}\n\n"
175
+
176
+ for chunk in response:
177
+ try:
178
+ # More robust empty chunk detection
179
+ if not hasattr(chunk, 'parts') or not chunk.parts:
180
+ # Skip this empty chunk and continue
181
+ print("Warning: Empty chunk received (no parts)")
182
+ continue
183
+
184
+ # First try the standard text property
185
+ try:
186
+ if hasattr(chunk, 'text') and chunk.text:
187
+ yield f"data: {json.dumps({'chunk': chunk.text})}\n\n"
188
+ continue # If we successfully got text, continue to next chunk
189
+ except (AttributeError, IndexError):
190
+ # If accessing text property fails, we'll try extracting from parts
191
+ pass
192
+
193
+ # If we're here, we couldn't get text directly, try to extract from parts
194
+ for part in chunk.parts:
195
+ # Extract text from part using different approaches
196
+ if hasattr(part, 'text') and part.text:
197
+ yield f"data: {json.dumps({'chunk': part.text})}\n\n"
198
+ elif isinstance(part, dict) and 'text' in part:
199
+ yield f"data: {json.dumps({'chunk': part['text']})}\n\n"
200
+ elif hasattr(part, 'string_value'):
201
+ yield f"data: {json.dumps({'chunk': part.string_value})}\n\n"
202
+
203
+ except (AttributeError, IndexError, TypeError) as e:
204
+ # Log the error but continue - don't break the stream
205
+ print(f"Error processing chunk: {e}, chunk structure: {repr(chunk)[:200]}")
206
+ continue
207
+
208
+ # Briefly pause to prevent overwhelming the client
209
+ time.sleep(0.01)
210
+
211
+ yield f"data: {json.dumps({'done': True})}\n\n"
212
+
213
+ except Exception as e:
214
+ # Send error to client and close stream
215
+ error_message = f"Error editing notebook: {str(e)}"
216
+ print(error_message)
217
+ yield f"data: {json.dumps({'error': error_message})}\n\n"
218
+ yield f"data: {json.dumps({'done': True})}\n\n"
219
+
220
+ return Response(stream_with_context(generate()), content_type="text/event-stream")
utils/notebook_helpers.py ADDED
@@ -0,0 +1,289 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import json
3
+ import functools
4
+
5
+ # Add a simple LRU cache for regex patterns
6
+ def get_cached_pattern(pattern, flags=0):
7
+ """Cache compiled regex patterns for better performance."""
8
+ @functools.lru_cache(maxsize=32)
9
+ def _get_pattern(pattern_str, pattern_flags):
10
+ return re.compile(pattern_str, pattern_flags)
11
+
12
+ return _get_pattern(pattern, flags)
13
+
14
+ def extract_notebook_info(content):
15
+ """Extract notebook name and description from the AI response."""
16
+ # Improved regex pattern that handles multiline and markdown formatting better
17
+ name_match = re.search(r"NOTEBOOK_NAME:?\s*(.+?)(?=\n\s*NOTEBOOK_DESCRIPTION|\n\s*---|\n\s*$|$)", content, re.DOTALL)
18
+ desc_match = re.search(r"NOTEBOOK_DESCRIPTION:?\s*(.+?)(?=\n\s*---|\n\s*$|$)", content, re.DOTALL)
19
+
20
+ # Extract and clean up potential markdown formatting
21
+ name = name_match.group(1).strip() if name_match else "Generated Notebook"
22
+ description = desc_match.group(1).strip() if desc_match else "Notebook generated using NoteGenie"
23
+
24
+ # Remove markdown formatting from name and description
25
+ name = re.sub(r'\*\*(.*?)\*\*', r'\1', name) # Remove bold formatting
26
+ name = re.sub(r'\*(.*?)\*', r'\1', name) # Remove italic formatting
27
+ name = re.sub(r'_(.*?)_', r'\1', name) # Remove underline formatting
28
+
29
+ description = re.sub(r'\*\*(.*?)\*\*', r'\1', description) # Remove bold formatting
30
+ description = re.sub(r'\*(.*?)\*', r'\1', description) # Remove italic formatting
31
+ description = re.sub(r'_(.*?)_', r'\1', description) # Remove underline formatting
32
+
33
+ return {
34
+ "name": name,
35
+ "description": description
36
+ }
37
+
38
+ def format_notebook(content):
39
+ """Convert the AI text response into a properly formatted Jupyter notebook JSON.
40
+ Optimized for performance with larger texts."""
41
+ # Use faster pattern matching approach with improved end-of-file handling
42
+ markdown_pattern = get_cached_pattern(r"---\s*MARKDOWN\s*CELL\s*---\s*([\s\S]*?)(?=---\s*(?:MARKDOWN|CODE)\s*CELL\s*---|$)", re.DOTALL)
43
+ code_pattern = get_cached_pattern(r"---\s*CODE\s*CELL\s*---\s*```python\s*([\s\S]*?)```", re.DOTALL)
44
+ cell_marker_pattern = get_cached_pattern(r"---\s*(MARKDOWN|CODE)\s*CELL\s*---", re.DOTALL)
45
+
46
+ # OPTIMIZATION: Do a quick initial scan to determine notebook size and complexity
47
+ complexity = len(content) // 1000 # Rough estimate based on content length
48
+ cell_count = len(cell_marker_pattern.findall(content))
49
+
50
+ # For very large notebooks, use a more memory-efficient but slower approach
51
+ if complexity > 200 or cell_count > 50: # If over ~200KB or 50 cells
52
+ return format_large_notebook(content)
53
+
54
+ # For regular notebooks, use the standard approach which is faster for medium-sized content
55
+ try:
56
+ # Extract cells from the content in a single pass if possible
57
+ markdown_cells = markdown_pattern.findall(content)
58
+ code_cells = code_pattern.findall(content)
59
+
60
+ # If the AI didn't use the expected format, try alternate patterns
61
+ if not markdown_cells and not code_cells:
62
+ # Simplified handling for non-standard format
63
+ sections = re.split(r"```python|```", content)
64
+ cells = []
65
+
66
+ for i, section in enumerate(sections):
67
+ section = section.strip()
68
+ if section and i % 2 == 0:
69
+ # This is markdown content
70
+ cells.append({"cell_type": "markdown", "source": section})
71
+ elif section:
72
+ # This is code content
73
+ cells.append({"cell_type": "code", "source": section})
74
+ else:
75
+ # Interleave markdown and code cells in the correct order
76
+ cells = []
77
+
78
+ # Find overall ordering of cells
79
+ all_matches = list(cell_marker_pattern.finditer(content))
80
+ all_types = [m.group(1) for m in all_matches]
81
+
82
+ md_idx = 0
83
+ code_idx = 0
84
+
85
+ for i, cell_type in enumerate(all_types):
86
+ marker = all_matches[i]
87
+ marker_end = marker.end()
88
+ next_marker_start = all_matches[i+1].start() if i+1 < len(all_matches) else len(content)
89
+ cell_content = content[marker_end:next_marker_start].strip()
90
+
91
+ if cell_type == "MARKDOWN":
92
+ if md_idx < len(markdown_cells) or (i == len(all_types) - 1 and cell_content):
93
+ if md_idx < len(markdown_cells):
94
+ cell_source = markdown_cells[md_idx].strip()
95
+ md_idx += 1
96
+ else:
97
+ # Handle the last markdown cell if it wasn't captured by the pattern
98
+ cell_source = cell_content
99
+
100
+ cells.append({
101
+ "cell_type": "markdown",
102
+ "source": cell_source
103
+ })
104
+ elif cell_type == "CODE":
105
+ if code_idx < len(code_cells) or (i == len(all_types) - 1 and "```python" in cell_content):
106
+ if code_idx < len(code_cells):
107
+ cell_source = code_cells[code_idx].strip()
108
+ code_idx += 1
109
+ else:
110
+ # Handle the last code cell if it wasn't captured by the pattern
111
+ code_match = re.search(r"```python\s*([\s\S]*?)```", cell_content, re.DOTALL)
112
+ cell_source = code_match.group(1).strip() if code_match else ""
113
+
114
+ cells.append({
115
+ "cell_type": "code",
116
+ "source": cell_source
117
+ })
118
+
119
+ # Ensure we have at least a title cell if nothing was extracted
120
+ if not cells:
121
+ notebook_info = extract_notebook_info(content)
122
+ cells.append({
123
+ "cell_type": "markdown",
124
+ "source": f"# {notebook_info['name']}\n\n{notebook_info['description']}"
125
+ })
126
+
127
+ # Try to extract any code blocks that might be present - only if needed
128
+ code_blocks = re.findall(r"```python\s*(.*?)```", content, re.DOTALL)
129
+ for block in code_blocks:
130
+ cells.append({
131
+ "cell_type": "code",
132
+ "source": block.strip()
133
+ })
134
+
135
+ # Format cells for Jupyter notebook structure - optimize by processing in chunks
136
+ formatted_cells = []
137
+ for cell in cells:
138
+ cell_source = cell["source"]
139
+ # Only split if it's a string, not if it's already a list
140
+ if isinstance(cell_source, str):
141
+ # OPTIMIZATION: For very large cells, process line by line to avoid memory issues
142
+ if len(cell_source) > 10000: # If cell is over 10KB
143
+ source_lines = []
144
+ for line in cell_source.splitlines():
145
+ source_lines.append(line)
146
+ else:
147
+ source_lines = cell_source.split("\n")
148
+ else:
149
+ source_lines = cell_source
150
+
151
+ formatted_cell = {
152
+ "cell_type": cell["cell_type"],
153
+ "metadata": {},
154
+ "source": source_lines
155
+ }
156
+
157
+ if cell["cell_type"] == "code":
158
+ formatted_cell["execution_count"] = None
159
+ formatted_cell["outputs"] = []
160
+
161
+ formatted_cells.append(formatted_cell)
162
+
163
+ # Create the notebook structure
164
+ notebook = {
165
+ "cells": formatted_cells,
166
+ "metadata": {
167
+ "kernelspec": {
168
+ "display_name": "Python 3",
169
+ "language": "python",
170
+ "name": "python3"
171
+ },
172
+ "language_info": {
173
+ "name": "python",
174
+ "version": "3.8.0"
175
+ }
176
+ },
177
+ "nbformat": 4,
178
+ "nbformat_minor": 4
179
+ }
180
+
181
+ return notebook
182
+ except Exception as e:
183
+ # If standard approach fails, fall back to the more robust method
184
+ print(f"Error in standard format_notebook: {e}. Using fallback method.")
185
+ return format_large_notebook(content)
186
+
187
+ def format_large_notebook(content):
188
+ """Memory-efficient formatter for very large notebooks.
189
+ Processes content in chunks to avoid memory issues."""
190
+ # Get notebook info
191
+ notebook_info = extract_notebook_info(content)
192
+
193
+ # Initialize cells with the title
194
+ cells = [{
195
+ "cell_type": "markdown",
196
+ "metadata": {},
197
+ "source": [f"# {notebook_info['name']}", "", notebook_info['description']]
198
+ }]
199
+
200
+ # Process content in chunks using incremental parsing
201
+ # Find cell markers and their positions
202
+ marker_positions = []
203
+ for match in re.finditer(r"---\s*(MARKDOWN|CODE)\s*CELL\s*---", content):
204
+ marker_positions.append((match.start(), match.end(), match.group(1)))
205
+
206
+ # If no markers are found, try to extract code blocks directly
207
+ if not marker_positions:
208
+ # Just extract code blocks and treat everything else as markdown
209
+ remaining_text = content
210
+ last_end = 0
211
+
212
+ for match in re.finditer(r"```python\s*(.*?)```", content, re.DOTALL):
213
+ # If there's text before this code block, add it as markdown
214
+ if match.start() > last_end:
215
+ markdown_text = content[last_end:match.start()].strip()
216
+ if markdown_text:
217
+ cells.append({
218
+ "cell_type": "markdown",
219
+ "metadata": {},
220
+ "source": markdown_text.split("\n")
221
+ })
222
+
223
+ # Add the code block
224
+ code_text = match.group(1).strip()
225
+ if code_text:
226
+ cells.append({
227
+ "cell_type": "code",
228
+ "metadata": {},
229
+ "source": code_text.split("\n"),
230
+ "execution_count": None,
231
+ "outputs": []
232
+ })
233
+
234
+ last_end = match.end()
235
+
236
+ # If there's text after the last code block, add it as markdown
237
+ if last_end < len(content):
238
+ markdown_text = content[last_end:].strip()
239
+ if markdown_text:
240
+ cells.append({
241
+ "cell_type": "markdown",
242
+ "metadata": {},
243
+ "source": markdown_text.split("\n")
244
+ })
245
+ else:
246
+ # Process each cell based on its markers
247
+ for i, (start, end, cell_type) in enumerate(marker_positions):
248
+ # Find the end of this cell (start of next cell or end of content)
249
+ cell_end = marker_positions[i+1][0] if i+1 < len(marker_positions) else len(content)
250
+ cell_content = content[end:cell_end].strip()
251
+
252
+ if cell_type == "MARKDOWN":
253
+ cells.append({
254
+ "cell_type": "markdown",
255
+ "metadata": {},
256
+ "source": cell_content.split("\n")
257
+ })
258
+ elif cell_type == "CODE":
259
+ # Extract code from between triple backticks
260
+ code_match = re.search(r"```python\s*(.*?)```", cell_content, re.DOTALL)
261
+ if code_match:
262
+ code_text = code_match.group(1).strip()
263
+ cells.append({
264
+ "cell_type": "code",
265
+ "metadata": {},
266
+ "source": code_text.split("\n"),
267
+ "execution_count": None,
268
+ "outputs": []
269
+ })
270
+
271
+ # Create the notebook structure
272
+ notebook = {
273
+ "cells": cells,
274
+ "metadata": {
275
+ "kernelspec": {
276
+ "display_name": "Python 3",
277
+ "language": "python",
278
+ "name": "python3"
279
+ },
280
+ "language_info": {
281
+ "name": "python",
282
+ "version": "3.8.0"
283
+ }
284
+ },
285
+ "nbformat": 4,
286
+ "nbformat_minor": 4
287
+ }
288
+
289
+ return notebook