Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create experimental visual editor toolbar & services #821

Merged
merged 10 commits into from
Aug 23, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ai/services/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Services

This is meant to be an independently runnable service.
3 changes: 3 additions & 0 deletions ai/services/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
openai
flask
gunicorn
142 changes: 142 additions & 0 deletions ai/services/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import os
import re
import secrets
import urllib.parse
from os import getenv
from typing import NamedTuple

from flask import Flask, jsonify, request
from openai import OpenAI

app = Flask(__name__)

SYSTEM_INSTRUCTION_PART_1_PATH = "../ft/prompts/mesop_overview.txt"
SYSTEM_INSTRUCTION_PART_2_PATH = "../ft/prompts/mini_docs.txt"

with open(SYSTEM_INSTRUCTION_PART_1_PATH) as f:
SYSTEM_INSTRUCTION_PART_1 = f.read()

with open(SYSTEM_INSTRUCTION_PART_2_PATH) as f:
SYSTEM_INSTRUCTION_PART_2 = f.read()

SYSTEM_INSTRUCTION = SYSTEM_INSTRUCTION_PART_1 + SYSTEM_INSTRUCTION_PART_2
PROMPT_PATH = "../ft/prompts/revise_prompt.txt"

with open(PROMPT_PATH) as f:
REVISE_APP_BASE_PROMPT = f.read().strip()


@app.route("/save-interaction", methods=["POST"])
def save_interaction_endpoint():
data = request.json
assert data is not None
prompt = data.get("prompt")
before_code = data.get("beforeCode")
diff = data.get("diff")
if not prompt or not before_code or not diff:
return jsonify({"error": "Invalid request"}), 400

folder_name = generate_folder_name(prompt)
base_path = "../ft/goldens"
folder_path = os.path.join(base_path, folder_name)

os.makedirs(folder_path, exist_ok=True)

with open(os.path.join(folder_path, "prompt.txt"), "w") as f:
f.write(prompt)
with open(os.path.join(folder_path, "source.py"), "w") as f:
f.write(before_code)
with open(os.path.join(folder_path, "diff.txt"), "w") as f:
f.write(diff)

return jsonify({"folder": folder_name}), 200


@app.route("/adjust-mesop-app", methods=["POST"])
def adjust_mesop_app_endpoint():
data = request.json
assert data is not None
code = data.get("code")
prompt = data.get("prompt")

if not code or not prompt:
return jsonify({"error": "Both 'code' and 'prompt' are required"}), 400

try:
diff = adjust_mesop_app(code, prompt)
result = apply_patch(code, diff)
if result.has_error:
raise Exception(result.result)
return jsonify({"code": result.result, "diff": diff})
except Exception as e:
return jsonify({"error": str(e)}), 500


class ApplyPatchResult(NamedTuple):
has_error: bool
result: str


def apply_patch(original_code: str, patch: str) -> ApplyPatchResult:
# Extract the diff content
diff_pattern = r"<<<<<<< ORIGINAL(.*?)=======\n(.*?)>>>>>>> UPDATED"
matches = re.findall(diff_pattern, patch, re.DOTALL)
patched_code = original_code
if len(matches) == 0:
print("[WARN] No diff found:", patch)
return ApplyPatchResult(True, "WARN: NO_DIFFS_FOUND")
for original, updated in matches:
original = original.strip()
updated = updated.strip()

# Replace the original part with the updated part
new_patched_code = patched_code.replace(original, updated, 1)
if new_patched_code == patched_code:
return ApplyPatchResult(True, "WARN: DID_NOT_APPLY_PATCH")
patched_code = new_patched_code

return ApplyPatchResult(False, patched_code)


def adjust_mesop_app(code: str, msg: str) -> str:
model = "ft:gpt-4o-mini-2024-07-18:personal::9yoxJtKf"
client = OpenAI(
api_key=getenv("OPENAI_API_KEY"),
)
return adjust_mesop_app_openai_client(code, msg, client, model=model)


def adjust_mesop_app_openai_client(
code: str, msg: str, client: OpenAI, model: str
) -> str:
completion = client.chat.completions.create(
model=model,
max_tokens=10_000,
messages=[
{
"role": "system",
"content": SYSTEM_INSTRUCTION,
},
{
"role": "user",
"content": REVISE_APP_BASE_PROMPT.replace("<APP_CODE>", code).replace(
"<APP_CHANGES>", msg
),
},
],
)
print("[INFO] LLM output:", completion.choices[0].message.content)
llm_output = completion.choices[0].message.content
return llm_output


def generate_folder_name(prompt: str) -> str:
# Generate a unique 4-character suffix to avoid naming collisions
suffix = secrets.token_urlsafe(4)
cleaned_prompt = urllib.parse.quote(prompt)[:50]
return f"{cleaned_prompt}_{suffix}"


if __name__ == "__main__":
port = int(getenv("PORT", 43234))
app.run(port=port)
6 changes: 6 additions & 0 deletions build_defs/defaults.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ THIRD_PARTY_JS_RXJS = [
"@npm//rxjs",
]

THIRD_PARTY_JS_CODEMIRROR = [
"@npm//codemirror",
"@npm//@codemirror/lang-python",
"@npm//@codemirror/merge",
]

THIRD_PARTY_PY_ABSL_PY = [
requirement("absl-py"),
]
Expand Down
2 changes: 2 additions & 0 deletions mesop/features/page.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from copy import deepcopy
from functools import wraps
from typing import Callable

from mesop.runtime import OnLoadHandler, PageConfig, runtime
Expand Down Expand Up @@ -29,6 +30,7 @@ def page(
"""

def decorator(func: Callable[[], None]) -> Callable[[], None]:
@wraps(func)
def wrapper() -> None:
return func()

Expand Down
136 changes: 136 additions & 0 deletions mesop/server/server.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import base64
import inspect
import json
import secrets
import time
import urllib.parse as urlparse
from typing import Generator, Sequence
from urllib import request as urllib_request
from urllib.error import URLError

from flask import Flask, Response, abort, request, stream_with_context

Expand Down Expand Up @@ -261,6 +265,128 @@ def teardown_clear_stale_state_sessions(error=None):

if not prod_mode:

@flask_app.route("/__editor__/page-commit", methods=["POST"])
def page_commit() -> Response:
check_editor_access()

try:
data = request.get_json()
except json.JSONDecodeError:
return Response("Invalid JSON format", status=400)
code = data.get("code")
path = data.get("path")
page_config = runtime().get_page_config(path=path)
assert page_config
module = inspect.getmodule(page_config.page_fn)
assert module
module_file = module.__file__
assert module_file
module_file_path = module.__file__
assert module_file_path
with open(module_file_path, "w") as file:
file.write(code)

response_data = {"message": "Page commit successful"}
return Response(
json.dumps(response_data), status=200, mimetype="application/json"
)

@flask_app.route("/__editor__/save-interaction", methods=["POST"])
def save_interaction() -> Response:
check_editor_access()

data = request.get_json()
if not data:
return Response("Invalid JSON data", status=400)

try:
req = urllib_request.Request(
wwwillchen marked this conversation as resolved.
Show resolved Hide resolved
"http://localhost:43234/save-interaction",
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(req) as response:
if response.status == 200:
folder = json.loads(response.read().decode("utf-8"))["folder"]
response_data = {"folder": folder}
return Response(
wwwillchen marked this conversation as resolved.
Show resolved Hide resolved
json.dumps(response_data), status=200, mimetype="application/json"
)
else:
print(f"Error from AI service: {response.read().decode('utf-8')}")
return Response(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Could be wrong, but think you can return error_message, status But I think what's here is a bit more clear.

f"Error from AI service: {response.read().decode('utf-8')}",
status=500,
)
except URLError as e:
return Response(
f"Error making request to AI service: {e!s}", status=500
)

@flask_app.route("/__editor__/page-generate", methods=["POST"])
def page_generate() -> Response:
check_editor_access()

try:
data = request.get_json()
except json.JSONDecodeError:
return Response("Invalid JSON format", status=400)
if not data:
return Response("Invalid JSON data", status=400)

prompt = data.get("prompt")
if not prompt:
return Response("Missing 'prompt' in JSON data", status=400)

path = data.get("path")
page_config = runtime().get_page_config(path=path)
assert page_config
module = inspect.getmodule(page_config.page_fn)
if module is None:
return Response("Could not retrieve module source code.", status=500)
module_file = module.__file__
assert module_file
with open(module_file) as file:
source_code = file.read()
print(f"Source code of module {module.__name__}:")

try:
req = urllib_request.Request(
"http://localhost:43234/adjust-mesop-app",
data=json.dumps({"prompt": prompt, "code": source_code}).encode(
"utf-8"
),
headers={"Content-Type": "application/json"},
)
with urllib_request.urlopen(req) as response:
if response.status == 200:
response_data = json.loads(response.read().decode("utf-8"))
generated_code = response_data["code"]
diff = response_data["diff"]
else:
print(f"Error from AI service: {response.read().decode('utf-8')}")
return Response(
f"Error from AI service: {response.read().decode('utf-8')}",
status=500,
)
except URLError as e:
return Response(
f"Error making request to AI service: {e!s}", status=500
)

response_data = {
"prompt": prompt,
"path": path,
"beforeCode": source_code,
"afterCode": generated_code,
"diff": diff,
"message": "Prompt processed successfully",
}

return Response(
json.dumps(response_data), status=200, mimetype="application/json"
)

@flask_app.route("/__hot-reload__")
def hot_reload() -> Response:
counter = int(request.args["counter"])
Expand All @@ -275,6 +401,16 @@ def hot_reload() -> Response:
return flask_app


def check_editor_access():
# Prevent accidental usages of editor mode outside of
# one's local computer
if request.remote_addr not in LOCALHOSTS:
abort(403) # Throws a Forbidden Error
# Visual editor should only be enabled in debug mode.
if not runtime().debug_mode:
abort(403) # Throws a Forbidden Error


def serialize(response: pb.UiResponse) -> str:
encoded = base64.b64encode(response.SerializeToString()).decode("utf-8")
return f"data: {encoded}\n\n"
Expand Down
2 changes: 2 additions & 0 deletions mesop/web/src/app/styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ body {
// Use system font.
font-family: var(--default-font-family);
-webkit-font-smoothing: antialiased;

--mat-dialog-container-max-width: calc(90vw);
}

* {
Expand Down
1 change: 1 addition & 0 deletions mesop/web/src/editor/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ ng_module(
"//mesop/web/src/component_renderer",
"//mesop/web/src/dev_tools",
"//mesop/web/src/dev_tools/services",
"//mesop/web/src/editor_toolbar",
"//mesop/web/src/error",
"//mesop/web/src/services",
"//mesop/web/src/shell",
Expand Down
3 changes: 3 additions & 0 deletions mesop/web/src/editor/editor.ng.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<mat-sidenav-container class="container">
<mat-sidenav-content #sidenavContent class="content">
<mesop-shell></mesop-shell>
@defer (when showEditorToolbar()) {
<mesop-editor-toolbar></mesop-editor-toolbar>
}
<div #dragHandle class="resize-handle" [hidden]="!showDevTools()"></div>
<div class="editor-button-container">
<button class="editor-button" mat-icon-button (click)="toggleDevTools()">
Expand Down
6 changes: 6 additions & 0 deletions mesop/web/src/editor/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import {Shell, registerComponentRendererElement} from '../shell/shell';
import {EditorService, SelectionMode} from '../services/editor_service';
import {Channel} from '../services/channel';
import {EditorToolbar} from '../editor_toolbar/editor_toolbar';
import {isMac} from '../utils/platform';
import {
DebugErrorDialogService,
Expand All @@ -46,6 +47,7 @@ import {
CommonModule,
ComponentRenderer,
MatProgressBarModule,
EditorToolbar,
ErrorBox,
DevTools,
MatIconModule,
Expand Down Expand Up @@ -159,6 +161,10 @@ class Editor {
return;
}
}

showEditorToolbar(): boolean {
return Boolean(window.localStorage.getItem('MESOP://SHOW_EDITOR_TOOLBAR'));
}
}

const routes: Routes = [{path: '**', component: Editor}];
Expand Down
Loading
Loading