diff --git a/.gitignore b/.gitignore index a1a5b8a0..28c19937 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Program related process_pids/ +kernel.* kernel_connection_file.json # Python stuff diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3212d273..b6374f9f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,7 +41,7 @@ function App() { let [messages, setMessages] = useState>( Array.from([ { - text: "Hello! I'm a GPT Code assistant. Ask me to do something for you! Pro tip: you can upload a file and I'll be able to use it.", + text: "Hello! I am a GPT Code assistant. Ask me to do something for you! Pro tip: you can upload a file and I'll be able to use it.", role: "generator", type: "message", }, @@ -53,7 +53,7 @@ function App() { ]) ); let [waitingForSystem, setWaitingForSystem] = useState( - WaitingStates.Idle + WaitingStates.StartingKernel ); const chatScrollRef = React.useRef(null); @@ -78,6 +78,7 @@ function App() { const handleCommand = (command: string) => { if (command == "reset") { addMessage({ text: "Restarting the kernel.", type: "message", role: "system" }); + setWaitingForSystem(WaitingStates.StartingKernel); fetch(`${Config.API_ADDRESS}/restart`, { method: "POST", @@ -161,19 +162,6 @@ function App() { function completeUpload(message: string) { addMessage({ text: message, type: "message", role: "upload" }); setWaitingForSystem(WaitingStates.Idle); - - // Inform prompt server - fetch(`${Config.WEB_ADDRESS}/inject-context`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - prompt: message, - }), - }) - .then(() => {}) - .catch((error) => console.error("Error:", error)); } function startUpload(_: string) { diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 4fbedb1e..4ea1d76d 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -72,7 +72,25 @@ function Message(props: {
))} - {(props.type == "message_raw") && + {props.type == "message_error" && + (props.showLoader ? ( +
+ {text} {props.showLoader ?
: null} +
+ ) : ( +
+ Execution Error: + +
+ ))} + + {props.type == "message_raw" && (props.showLoader ? (
{text} {props.showLoader ?
: null} @@ -80,7 +98,7 @@ function Message(props: { ) : (
))} - + {props.type == "image/png" &&
` }}>
} @@ -94,6 +112,7 @@ function Message(props: { export enum WaitingStates { + StartingKernel = "Starting Kernel", GeneratingCode = "Generating code", RunningCode = "Running code", UploadingFile = "Uploading file", diff --git a/gpt_code_ui/kernel_program/config.py b/gpt_code_ui/kernel_program/config.py index aa37c08f..b4a4a6df 100644 --- a/gpt_code_ui/kernel_program/config.py +++ b/gpt_code_ui/kernel_program/config.py @@ -15,4 +15,4 @@ def get_logger(): logger = logging.getLogger(__name__) if "DEBUG" in os.environ: logger.setLevel(logging.DEBUG) - return logger \ No newline at end of file + return logger diff --git a/gpt_code_ui/kernel_program/kernel_manager.py b/gpt_code_ui/kernel_program/kernel_manager.py index 93f6d799..43dbaba1 100644 --- a/gpt_code_ui/kernel_program/kernel_manager.py +++ b/gpt_code_ui/kernel_program/kernel_manager.py @@ -1,14 +1,16 @@ import sys import subprocess import os +import shutil +import atexit import queue import json import signal import pathlib import threading import time -import atexit import traceback +import venv from time import sleep from jupyter_client import BlockingKernelClient @@ -56,7 +58,7 @@ def cleanup_spawned_processes(): os.kill(pid, signal.CTRL_BREAK_EVENT) else: os.kill(pid, signal.SIGKILL) - + # After successful kill, cleanup pid file os.remove(fp) @@ -149,7 +151,7 @@ def flush_kernel_msgs(kc, tries=1, timeout=0.2): elif msg["msg_type"] == "error": send_message( utils.escape_ansi("\n".join(msg["content"]["traceback"])), - "message_raw", + "message_error", ) except queue.Empty: hit_empty += 1 @@ -167,58 +169,116 @@ def flush_kernel_msgs(kc, tries=1, timeout=0.2): logger.debug(f"{e} [{type(e)}") -def start_kernel(): - kernel_connection_file = os.path.join(os.getcwd(), "kernel_connection_file.json") +def create_venv(venv_dir: pathlib.Path, install_default_packages: bool) -> pathlib.Path: + venv_bindir = venv_dir / 'bin' + venv_python_executable = venv_bindir / os.path.basename(sys.executable) - if os.path.isfile(kernel_connection_file): - os.remove(kernel_connection_file) - if os.path.isdir(kernel_connection_file): - os.rmdir(kernel_connection_file) + if not os.path.isdir(venv_dir): + # create virtual env inside venv_dir directory + venv.create(venv_dir, system_site_packages=True, with_pip=True, upgrade_deps=True) - launch_kernel_script_path = os.path.join( - pathlib.Path(__file__).parent.resolve(), "launch_kernel.py" - ) + if install_default_packages: + # install wheel because some packages do not like being installed without + subprocess.run([venv_python_executable, '-m', 'pip', 'install', 'wheel>=0.41,<1.0']) + # install all default packages into the venv + default_packages = [ + "ipykernel>=6,<7", + "numpy>=1.24,<1.25", + "dateparser>=1.1,<1.2", + "pandas>=1.5,<1.6", + "geopandas>=0.13,<0.14", + "tabulate>=0.9.0<1.0", + "PyPDF2>=3.0,<3.1", + "pdfminer>=20191125,<20191200", + "pdfplumber>=0.9,<0.10", + "matplotlib>=3.7,<3.8", + "openpyxl>=3.1.2,<4", + ] + subprocess.run([venv_python_executable, '-m', 'pip', 'install'] + default_packages) + + # get base env library path as we need this to refer to this form a derived venv + site_packages = subprocess.check_output([venv_python_executable, '-c', 'import sysconfig; print(sysconfig.get_paths()["purelib"])']) + site_packages = site_packages.decode('utf-8').split('\n')[0] + + return pathlib.Path(site_packages) + + +def create_derived_venv(base_venv: pathlib.Path, venv_dir: pathlib.Path): + site_packages_base = create_venv(base_venv, install_default_packages=True) + site_packages_derived = create_venv(venv_dir, install_default_packages=False) + + # create a link from derived venv into the base venv, see https://stackoverflow.com/a/75545634 + with open(site_packages_derived / '_base_packages.pth', 'w') as pth: + pth.write(f'{site_packages_base}\n') + + venv_bindir = venv_dir / 'bin' + venv_python_executable = venv_bindir / os.path.basename(sys.executable) + + return venv_bindir, venv_python_executable - os.makedirs('workspace/', exist_ok=True) +def start_kernel(id: str): + cwd = pathlib.Path(os.getcwd()) + kernel_dir = cwd / f'kernel.{id}' + base_dir = cwd / 'kernel.base' + + # Cleanup potential leftovers + shutil.rmtree(kernel_dir, ignore_errors=True) + os.makedirs(kernel_dir) + + kernel_env = os.environ.copy() + kernel_connection_file = kernel_dir / "kernel_connection_file.json" + launch_kernel_script_path = pathlib.Path(__file__).parent.resolve() / "launch_kernel.py" + + kernel_venv_dir = kernel_dir / 'venv' + kernel_venv_bindir, kernel_python_executable = create_derived_venv(base_dir, kernel_venv_dir) + kernel_env['PATH'] = str(kernel_venv_bindir) + os.pathsep + kernel_env['PATH'] + + # start the kernel using the virtual env python executable kernel_process = subprocess.Popen( [ - sys.executable, + kernel_python_executable, launch_kernel_script_path, "--IPKernelApp.connection_file", kernel_connection_file, "--matplotlib=inline", "--quiet", ], - cwd='workspace/' + cwd=kernel_dir, + env=kernel_env, ) - # Write PID for caller to kill - str_kernel_pid = str(kernel_process.pid) - os.makedirs(config.KERNEL_PID_DIR, exist_ok=True) - with open(os.path.join(config.KERNEL_PID_DIR, str_kernel_pid + ".pid"), "w") as p: - p.write("kernel") + + utils.store_pid(kernel_process.pid, "kernel") # Wait for kernel connection file to be written while True: - if not os.path.isfile(kernel_connection_file): + try: + with open(kernel_connection_file, 'r') as fp: + json.load(fp) + except (FileNotFoundError, json.JSONDecodeError): + # Either file was not yet there or incomplete (then JSON parsing failed) sleep(0.1) + pass else: - # Keep looping if JSON parsing fails, file may be partially written - try: - with open(kernel_connection_file, 'r') as fp: - json.load(fp) - break - except json.JSONDecodeError: - pass + break # Client - kc = BlockingKernelClient(connection_file=kernel_connection_file) + kc = BlockingKernelClient(connection_file=str(kernel_connection_file)) kc.load_connection_file() kc.start_channels() kc.wait_for_ready() - return kc + return kc, kernel_dir if __name__ == "__main__": - kc = start_kernel() - start_snakemq(kc) \ No newline at end of file + try: + kernel_id = sys.argv[1] + except IndexError as e: + logger.exception('Missing kernel ID command line parameter', e) + else: + kc, kernel_dir = start_kernel(id=kernel_id) + + # make sure the dir with the virtualenv will be deleted after kernel termination + atexit.register(lambda: shutil.rmtree(kernel_dir, ignore_errors=True)) + + start_snakemq(kc) diff --git a/gpt_code_ui/kernel_program/launch_kernel.py b/gpt_code_ui/kernel_program/launch_kernel.py index d193051d..f66b36c8 100644 --- a/gpt_code_ui/kernel_program/launch_kernel.py +++ b/gpt_code_ui/kernel_program/launch_kernel.py @@ -1,4 +1,4 @@ if __name__ == "__main__": from ipykernel import kernelapp as app - app.launch_new_instance() \ No newline at end of file + app.launch_new_instance() diff --git a/gpt_code_ui/kernel_program/main.py b/gpt_code_ui/kernel_program/main.py index 401133ee..10341bb3 100644 --- a/gpt_code_ui/kernel_program/main.py +++ b/gpt_code_ui/kernel_program/main.py @@ -7,7 +7,6 @@ import time import asyncio -import json import threading from queue import Queue @@ -47,24 +46,26 @@ app = Flask(__name__) CORS(app) + def start_kernel_manager(): global kernel_manager_process kernel_manager_script_path = os.path.join( pathlib.Path(__file__).parent.resolve(), "kernel_manager.py" ) - kernel_manager_process = subprocess.Popen( - [sys.executable, kernel_manager_script_path] - ) + kernel_manager_process = subprocess.Popen([ + sys.executable, + kernel_manager_script_path, + 'workspace', # This will be used as part of the folder name for the workspace and to create the venv inside. Can be anything, but using 'workspace' makes file up-/download very simple + ]) + + utils.store_pid(kernel_manager_process.pid, "kernel_manager") - # Write PID as .pid to config.KERNEL_PID_DIR - os.makedirs(config.KERNEL_PID_DIR, exist_ok=True) - with open(os.path.join(config.KERNEL_PID_DIR, "%d.pid" % kernel_manager_process.pid), "w") as p: - p.write("kernel_manager") def cleanup_kernel_program(): kernel_manager.cleanup_spawned_processes() + async def start_snakemq(): global messaging @@ -77,11 +78,11 @@ def on_recv(conn, ident, message): if message["value"] == "ready": logger.debug("Kernel is ready.") result_queue.put({ - "value":"Kernel is ready.", - "type": "message" + "value": "Kernel is ready.", + "type": "message_status" }) - elif message["type"] in ["message", "message_raw", "image/png", "image/jpeg"]: + elif message["type"] in ["message", "message_raw", "message_error", "image/png", "image/jpeg"]: # TODO: 1:1 kernel <> channel mapping logger.debug("%s of type %s" % (message["value"], message["type"])) @@ -97,8 +98,9 @@ def send_queued_messages(): while True: if send_queue.qsize() > 0: message = send_queue.get() - utils.send_json(messaging, - {"type": "execute", "value": message["command"]}, + utils.send_json( + messaging, + {"type": "execute", "value": message["command"]}, config.IDENT_KERNEL_MANAGER ) time.sleep(0.1) @@ -117,7 +119,7 @@ async def async_link_loop(): @app.route("/api", methods=["POST", "GET"]) def handle_request(): - + if request.method == "GET": # Handle GET requests by sending everything that's in the receive_queue results = [result_queue.get() for _ in range(result_queue.qsize())] @@ -128,7 +130,8 @@ def handle_request(): send_queue.put(data) return jsonify({"result": "success"}) - + + @app.route("/restart", methods=["POST"]) def handle_restart(): @@ -152,9 +155,6 @@ async def main(): def run_flask_app(): app.run(host="0.0.0.0", port=APP_PORT) + if __name__ == "__main__": asyncio.run(main()) - - - - \ No newline at end of file diff --git a/gpt_code_ui/kernel_program/utils.py b/gpt_code_ui/kernel_program/utils.py index cce7d704..c7f4441d 100644 --- a/gpt_code_ui/kernel_program/utils.py +++ b/gpt_code_ui/kernel_program/utils.py @@ -1,3 +1,4 @@ +import os import re import json import snakemq.link @@ -7,6 +8,7 @@ import gpt_code_ui.kernel_program.config as config + def escape_ansi(line): ansi_escape = re.compile(r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]") return ansi_escape.sub("", line) @@ -16,6 +18,7 @@ def send_json(messaging, message, identity): message = snakemq.message.Message(json.dumps(message).encode("utf-8"), ttl=600) messaging.send_message(identity, message) + def init_snakemq(ident, init_type="listen"): link = snakemq.link.Link() packeter = snakemq.packeter.Packeter(link) @@ -26,4 +29,13 @@ def init_snakemq(ident, init_type="listen"): link.add_connector(("localhost", config.SNAKEMQ_PORT)) else: raise Exception("Unsupported init type.") - return messaging, link \ No newline at end of file + return messaging, link + + +def store_pid(pid: int, process_name: str): + ''' + Write PID as .pid to config.KERNEL_PID_DIR + ''' + os.makedirs(config.KERNEL_PID_DIR, exist_ok=True) + with open(os.path.join(config.KERNEL_PID_DIR, f"{pid}.pid"), "w") as p: + p.write(process_name) diff --git a/gpt_code_ui/main.py b/gpt_code_ui/main.py index 5683608b..4e09ba1d 100644 --- a/gpt_code_ui/main.py +++ b/gpt_code_ui/main.py @@ -16,20 +16,23 @@ APP_URL = "http://localhost:%s" % APP_PORT + def run_webapp(): try: app.run(host="0.0.0.0", port=APP_PORT, use_reloader=False) - except Exception as e: - logging.exception("Error running the webapp:") + except Exception: + logging.exception("Error running the webapp") sys.exit(1) + def run_kernel_program(): try: asyncio.run(kernel_program_main()) - except Exception as e: - logging.exception("Error running the kernel_program:") + except Exception: + logging.exception("Error running the kernel_program") sys.exit(1) + def setup_logging(): log_format = "%(asctime)s [%(levelname)s]: %(message)s" logging.basicConfig(level=logging.INFO, format=log_format) @@ -38,32 +41,33 @@ def setup_logging(): file_handler.setFormatter(logging.Formatter(log_format)) logging.getLogger().addHandler(file_handler) + def print_color(text, color="gray"): # Default to gray - code="242" + code = "242" if color == "green": - code="35" - + code = "35" + gray_code = "\033[38;5;%sm" % code reset_code = "\033[0m" print(f"{gray_code}{text}{reset_code}") def print_banner(): - - print(""" + print(""" █▀▀ █▀█ ▀█▀ ▄▄ █▀▀ █▀█ █▀▄ █▀▀ █▄█ █▀▀ ░█░ ░░ █▄▄ █▄█ █▄▀ ██▄ - """) + """) + + print("> Open GPT-Code UI in your browser %s" % APP_URL) + print("") + print("You can inspect detailed logs in app.log.") + print("") + print("Find your OpenAI API key at https://platform.openai.com/account/api-keys") + print("") + print_color("Contribute to GPT-Code UI at https://github.com/ricklamers/gpt-code-ui") - print("> Open GPT-Code UI in your browser %s" % APP_URL) - print("") - print("You can inspect detailed logs in app.log.") - print("") - print("Find your OpenAI API key at https://platform.openai.com/account/api-keys") - print("") - print_color("Contribute to GPT-Code UI at https://github.com/ricklamers/gpt-code-ui") def main(): setup_logging() @@ -80,20 +84,19 @@ def main(): try: app.test_client().get("/") break - except: + except Exception: time.sleep(0.1) - - print_banner() - + + print_banner() + webbrowser.open(APP_URL) webapp_process.join() kernel_program_process.join() - except KeyboardInterrupt: print("Terminating processes...") - + cleanup_kernel_program() kernel_program_process.terminate() @@ -103,6 +106,7 @@ def main(): kernel_program_process.join() print("Processes terminated.") - + + if __name__ == '__main__': main() diff --git a/gpt_code_ui/webapp/main.py b/gpt_code_ui/webapp/main.py index 332986e5..d3b529cf 100644 --- a/gpt_code_ui/webapp/main.py +++ b/gpt_code_ui/webapp/main.py @@ -9,8 +9,6 @@ import openai import pandas as pd -from collections import deque - from flask_cors import CORS from flask import Flask, request, jsonify, send_from_directory, Response from dotenv import load_dotenv @@ -33,32 +31,69 @@ else: raise ValueError(f'Invalid OPENAI_API_TYPE: {openai.api_type}') -UPLOAD_FOLDER = 'workspace/' +UPLOAD_FOLDER = 'kernel.workspace/' os.makedirs(UPLOAD_FOLDER, exist_ok=True) APP_PORT = int(os.environ.get("WEB_PORT", 8080)) -class LimitedLengthString: - def __init__(self, maxlen=2000): - self.data = deque() - self.len = 0 - self.maxlen = maxlen +class ChatHistory(): + def __init__(self): + self._buffer = list() + + self.append( + "system", + """Write Python code, in a triple backtick Markdown code block, that answers the user prompts. + +Notes: + Do not use your own knowledge to answer the user prompt. Instead, focus on generating Python code for doing so. + First, think step by step what you want to do and write it down in English. + Then generate valid Python code in a single code block. + Do not add commands to install packages. + Make sure all code is valid - it will e run in a Jupyter Python 3 kernel environment. + Define every variable before you use it. + For data processing, you can use + 'numpy', # numpy==1.24.3 + 'dateparser' #dateparser==1.1.8 + 'pandas', # matplotlib==1.5.3 + 'geopandas' # geopandas==0.13.2 + 'tabulate' # tabulate==0.9.0 + For pdf extraction, you can use + 'PyPDF2', # PyPDF2==3.0.1 + 'pdfminer', # pdfminer==20191125 + 'pdfplumber', # pdfplumber==0.9.0 + For data visualization, you can use + 'matplotlib', # matplotlib==3.7.1 + Be sure to generate charts with matplotlib. If you need geographical charts, use geopandas with the geopandas.datasets module. + If the user requests to generate a table, produce code that prints a markdown table. + If the user has just uploaded a file, focus on the file that was most recently uploaded (and optionally all previously uploaded files) + +If the code modifies or produces a file, at the end of the code block insert a print statement that prints a link to it as HTML string: Download file. Replace INSERT_FILENAME_HERE with the actual filename.""") + + def append(self, role: str, content: str): + if role not in ("user", "assistant", "system"): + raise ValueError(f"Invalid role: {role}") + + self._buffer.append({ + "role": role, + "content": content, + }) + + def upload_file(self, filename: str, file_info: str = None): + self.append("user", f"In the following, I will refer to the file {filename}.\n{file_info}") + + def add_execution_result(self, result: str): + self.append("user", f"Executing this code yielded the following output:\n{result}") - def append(self, string): - self.data.append(string) - self.len += len(string) - while self.len > self.maxlen: - popped = self.data.popleft() - self.len -= len(popped) + def add_error(self, message: str): + self.append("user", f"Executing this code lead to an error.\nThe error message reads:\n{message}") - def get_string(self): - result = ''.join(self.data) - return result[-self.maxlen:] + def __call__(self): + return self._buffer -message_buffer = LimitedLengthString() +chat_history = ChatHistory() def allowed_file(filename): @@ -92,36 +127,7 @@ def inspect_file(filename: str) -> str: return '' # file reading failed. - Don't want to know why. -async def get_code(user_prompt, user_openai_key=None, model="gpt-3.5-turbo"): - - prompt = f"""First, here is a history of what I asked you to do earlier. - The actual prompt follows after ENDOFHISTORY. - History: - {message_buffer.get_string()} - ENDOFHISTORY. - Write Python code, in a triple backtick Markdown code block, that does the following: - {user_prompt} - - Notes: - First, think step by step what you want to do and write it down in English. - Then generate valid Python code in a code block - Make sure all code is valid - it be run in a Jupyter Python 3 kernel environment. - Define every variable before you use it. - For data munging, you can use - 'numpy', # numpy==1.24.3 - 'dateparser' #dateparser==1.1.8 - 'pandas', # matplotlib==1.5.3 - 'geopandas' # geopandas==0.13.2 - For pdf extraction, you can use - 'PyPDF2', # PyPDF2==3.0.1 - 'pdfminer', # pdfminer==20191125 - 'pdfplumber', # pdfplumber==0.9.0 - For data visualization, you can use - 'matplotlib', # matplotlib==3.7.1 - Be sure to generate charts with matplotlib. If you need geographical charts, use geopandas with the geopandas.datasets module. - If the user has just uploaded a file, focus on the file that was most recently uploaded (and optionally all previously uploaded files) - - Teacher mode: if the code modifies or produces a file, at the end of the code block insert a print statement that prints a link to it as HTML string: Download file. Replace INSERT_FILENAME_HERE with the actual filename.""" +async def get_code(messages, user_openai_key=None, model="gpt-3.5-turbo"): if user_openai_key: openai.api_key = user_openai_key @@ -129,10 +135,7 @@ async def get_code(user_prompt, user_openai_key=None, model="gpt-3.5-turbo"): arguments = dict( temperature=0.7, headers=OPENAI_EXTRA_HEADERS, - messages=[ - # {"role": "system", "content": system}, - {"role": "user", "content": prompt}, - ] + messages=messages, ) if openai.api_type == 'open_ai': @@ -209,6 +212,15 @@ def proxy_kernel_manager(path): else: resp = requests.get(f'http://localhost:{KERNEL_APP_PORT}/{path}') + # store execution results in conversation history to allow back-references by the user + for res in json.loads(resp.content).get('results', []): + if res['type'] == "message": + chat_history.add_execution_result(res['value']) + elif res['type'] == "message_error": + chat_history.add_error(res['value']) + + print(res) + excluded_headers = ['content-encoding', 'content-length', 'transfer-encoding', 'connection'] headers = [(name, value) for (name, value) in resp.raw.headers.items() @@ -230,17 +242,7 @@ def download_file(): file = request.args.get('file') # from `workspace/` send the file # make sure to set required headers to make it download the file - return send_from_directory(os.path.join(os.getcwd(), 'workspace'), file, as_attachment=True) - - -@app.route('/inject-context', methods=['POST']) -def inject_context(): - user_prompt = request.json.get('prompt', '') - - # Append all messages to the message buffer for later use - message_buffer.append(user_prompt + "\n\n") - - return jsonify({"result": "success"}) + return send_from_directory(os.path.join(os.getcwd(), 'kernel.workspace'), file, as_attachment=True) @app.route('/generate', methods=['POST']) @@ -252,12 +254,14 @@ def generate_code(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) + chat_history.append("user", user_prompt) + code, text, status = loop.run_until_complete( - get_code(user_prompt, user_openai_key, model)) + get_code(chat_history(), user_openai_key, model)) loop.close() - # Append all messages to the message buffer for later use - message_buffer.append(user_prompt + "\n\n") + if status == 200: + chat_history.append("assistant", text) return jsonify({'code': code, 'text': text}), status @@ -276,6 +280,7 @@ def upload_file(): file_target = os.path.join(app.config['UPLOAD_FOLDER'], file.filename) file.save(file_target) file_info = inspect_file(file_target) + chat_history.upload_file(file.filename, file_info) return jsonify({'message': f'File {file.filename} uploaded successfully.\n{file_info}'}), 200 else: return jsonify({'error': 'File type not allowed'}), 400