Spaces:
Sleeping
Sleeping
Commit
·
e60fb94
1
Parent(s):
19f1030
initial commit
Browse files- Dockerfile +24 -0
- README.md +57 -1
- app.py +200 -0
- requirements.txt +4 -0
- static/css/style.css +761 -0
- static/js/main.js +1052 -0
- templates/index.html +189 -0
- utils/ai_helpers.py +220 -0
- utils/notebook_helpers.py +289 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|