diff options
Diffstat (limited to '')
-rw-r--r-- | g4f/gui/client/index.html | 6 | ||||
-rw-r--r-- | g4f/gui/client/static/css/style.css | 19 | ||||
-rw-r--r-- | g4f/gui/client/static/js/chat.v1.js | 73 | ||||
-rw-r--r-- | g4f/gui/server/android_gallery.py | 67 | ||||
-rw-r--r-- | g4f/gui/server/api.py | 89 | ||||
-rw-r--r-- | g4f/gui/webview.py | 4 |
6 files changed, 219 insertions, 39 deletions
diff --git a/g4f/gui/client/index.html b/g4f/gui/client/index.html index 9ce6b66a..6b9b1ab9 100644 --- a/g4f/gui/client/index.html +++ b/g4f/gui/client/index.html @@ -133,15 +133,15 @@ <div class="box input-box"> <textarea id="message-input" placeholder="Ask a question" cols="30" rows="10" style="white-space: pre-wrap;resize: none;"></textarea> - <label for="image" title="Works with Bing, Gemini, OpenaiChat and You"> + <label class="file-label" for="image" title="Works with Bing, Gemini, OpenaiChat and You"> <input type="file" id="image" name="image" accept="image/*" required/> <i class="fa-regular fa-image"></i> </label> - <label for="camera"> + <label class="file-label" for="camera"> <input type="file" id="camera" name="camera" accept="image/*" capture="camera" required/> <i class="fa-solid fa-camera"></i> </label> - <label for="file"> + <label class="file-label" for="file"> <input type="file" id="file" name="file" accept="text/plain, text/html, text/xml, application/json, text/javascript, .sh, .py, .php, .css, .yaml, .sql, .log, .csv, .twig, .md" required/> <i class="fa-solid fa-paperclip"></i> </label> diff --git a/g4f/gui/client/static/css/style.css b/g4f/gui/client/static/css/style.css index 936df0d2..28064159 100644 --- a/g4f/gui/client/static/css/style.css +++ b/g4f/gui/client/static/css/style.css @@ -482,25 +482,18 @@ body { display: none; } -label[for="image"]:has(> input:valid){ - color: var(--accent); -} - -label[for="camera"]:has(> input:valid){ - color: var(--accent); -} - -label[for="file"]:has(> input:valid){ - color: var(--accent); -} - -label[for="image"], label[for="file"], label[for="camera"] { +.file-label { cursor: pointer; position: absolute; top: 10px; left: 10px; } +.file-label:has(> input:valid), +.file-label.selected { + color: var(--accent); +} + label[for="image"] { top: 32px; } diff --git a/g4f/gui/client/static/js/chat.v1.js b/g4f/gui/client/static/js/chat.v1.js index 0da72988..bcef4a78 100644 --- a/g4f/gui/client/static/js/chat.v1.js +++ b/g4f/gui/client/static/js/chat.v1.js @@ -211,7 +211,7 @@ async function add_message_chunk(message) { ${message.provider.model ? ' with ' + message.provider.model : ''} ` } else if (message.type == "message") { - console.error(messag.message) + console.error(message.message) } else if (message.type == "error") { window.error = message.error console.error(message.error); @@ -240,6 +240,27 @@ async function add_message_chunk(message) { } } +cameraInput?.addEventListener("click", (e) => { + if (window?.pywebview) { + e.preventDefault(); + pywebview.api.choose_file(); + } +}) + +cameraInput?.addEventListener("click", (e) => { + if (window?.pywebview) { + e.preventDefault(); + pywebview.api.take_picture(); + } +}) + +imageInput?.addEventListener("click", (e) => { + if (window?.pywebview) { + e.preventDefault(); + pywebview.api.choose_image(); + } +}) + const ask_gpt = async () => { regenerate.classList.add(`regenerate-hidden`); messages = await get_messages(window.conversation_id); @@ -307,8 +328,7 @@ const ask_gpt = async () => { console.error(e); if (e.name != "AbortError") { error = true; - text = "oops ! something went wrong, please try again / reload. [stacktrace in console]"; - content_inner.innerHTML = text; + content_inner.innerHTML += `<p><strong>An error occured:</strong> ${e}</p>`; } } if (!error && text) { @@ -592,7 +612,7 @@ document.getElementById("cancelButton").addEventListener("click", async () => { console.log(`aborted ${window.conversation_id}`); }); -document.getElementById(`regenerateButton`).addEventListener(`click`, async () => { +document.getElementById("regenerateButton").addEventListener("click", async () => { prompt_lock = true; await hide_last_message(window.conversation_id); window.token = message_id(); @@ -622,14 +642,20 @@ const message_id = () => { async function hide_sidebar() { sidebar.classList.remove("shown"); sidebar_button.classList.remove("rotated"); + if (window.location.pathname == "/menu/") { + history.back(); + } } +window.addEventListener('popstate', hide_sidebar, false); + sidebar_button.addEventListener("click", (event) => { if (sidebar.classList.contains("shown")) { hide_sidebar(); } else { sidebar.classList.add("shown"); sidebar_button.classList.add("rotated"); + history.pushState({}, null, "/menu/"); } window.scrollTo(0, 0); }); @@ -817,19 +843,6 @@ async function on_api() { register_settings_storage(); - versions = await api("version"); - document.title = 'g4f - ' + versions["version"]; - let text = "version ~ " - if (versions["version"] != versions["latest_version"]) { - let release_url = 'https://github.com/xtekky/gpt4free/releases/tag/' + versions["latest_version"]; - let title = `New version: ${versions["latest_version"]}`; - text += `<a href="${release_url}" target="_blank" title="${title}">${versions["version"]}</a> `; - text += `<i class="fa-solid fa-rotate"></i>` - } else { - text += versions["version"]; - } - document.getElementById("version_text").innerHTML = text - models = await api("models"); models.forEach((model) => { let option = document.createElement("option"); @@ -845,8 +858,24 @@ async function on_api() { }) await load_provider_models(appStorage.getItem("provider")); - load_settings_storage() + await load_settings_storage() +} + +async function load_version() { + const versions = await api("version"); + document.title = 'g4f - ' + versions["version"]; + let text = "version ~ " + if (versions["version"] != versions["latest_version"]) { + let release_url = 'https://github.com/xtekky/gpt4free/releases/tag/' + versions["latest_version"]; + let title = `New version: ${versions["latest_version"]}`; + text += `<a href="${release_url}" target="_blank" title="${title}">${versions["version"]}</a> `; + text += `<i class="fa-solid fa-rotate"></i>` + } else { + text += versions["version"]; + } + document.getElementById("version_text").innerHTML = text } +setTimeout(load_version, 5000); for (const el of [imageInput, cameraInput]) { el.addEventListener('click', async () => { @@ -913,13 +942,13 @@ function get_selected_model() { async function api(ressource, args=null, file=null) { if (window?.pywebview) { - if (args) { + if (args !== null) { if (ressource == "models") { ressource = "provider_models"; } - return pywebview.api["get_" + ressource](args); + return pywebview.api[`get_${ressource}`](args); } - return pywebview.api["get_" + ressource](); + return pywebview.api[`get_${ressource}`](); } if (ressource == "models" && args) { ressource = `${ressource}/${args}`; @@ -930,7 +959,7 @@ async function api(ressource, args=null, file=null) { const headers = { accept: 'text/event-stream' } - if (file) { + if (file !== null) { const formData = new FormData(); formData.append('file', file); formData.append('json', body); diff --git a/g4f/gui/server/android_gallery.py b/g4f/gui/server/android_gallery.py new file mode 100644 index 00000000..9101bc02 --- /dev/null +++ b/g4f/gui/server/android_gallery.py @@ -0,0 +1,67 @@ +from kivy.logger import Logger +from kivy.clock import Clock + +from jnius import autoclass +from jnius import cast +from android import activity + +PythonActivity = autoclass('org.kivy.android.PythonActivity') +Intent = autoclass('android.content.Intent') +Uri = autoclass('android.net.Uri') + +MEDIA_DATA = "_data" +RESULT_LOAD_IMAGE = 1 + +Activity = autoclass('android.app.Activity') + +def user_select_image(on_selection): + """Open Gallery Activity and call callback with absolute image filepath of image user selected. + None if user canceled. + """ + + currentActivity = cast('android.app.Activity', PythonActivity.mActivity) + + # Forum discussion: https://groups.google.com/forum/#!msg/kivy-users/bjsG2j9bptI/-Oe_aGo0newJ + def on_activity_result(request_code, result_code, intent): + if request_code != RESULT_LOAD_IMAGE: + Logger.warning('user_select_image: ignoring activity result that was not RESULT_LOAD_IMAGE') + return + + if result_code == Activity.RESULT_CANCELED: + Clock.schedule_once(lambda dt: on_selection(None), 0) + return + + if result_code != Activity.RESULT_OK: + # This may just go into the void... + raise NotImplementedError('Unknown result_code "{}"'.format(result_code)) + + selectedImage = intent.getData(); # Uri + filePathColumn = [MEDIA_DATA]; # String[] + # Cursor + cursor = currentActivity.getContentResolver().query(selectedImage, + filePathColumn, None, None, None); + cursor.moveToFirst(); + + # int + columnIndex = cursor.getColumnIndex(filePathColumn[0]); + # String + picturePath = cursor.getString(columnIndex); + cursor.close(); + Logger.info('android_ui: user_select_image() selected %s', picturePath) + + # This is possibly in a different thread? + Clock.schedule_once(lambda dt: on_selection(picturePath), 0) + + # See: http://pyjnius.readthedocs.org/en/latest/android.html + activity.bind(on_activity_result=on_activity_result) + + intent = Intent() + + # http://programmerguru.com/android-tutorial/how-to-pick-image-from-gallery/ + # http://stackoverflow.com/questions/18416122/open-gallery-app-in-android + intent.setAction(Intent.ACTION_PICK) + # TODO internal vs external? + intent.setData(Uri.parse('content://media/internal/images/media')) + # TODO setType(Image)? + + currentActivity.startActivityForResult(intent, RESULT_LOAD_IMAGE)
\ No newline at end of file diff --git a/g4f/gui/server/api.py b/g4f/gui/server/api.py index 4dfc43d4..43bb4250 100644 --- a/g4f/gui/server/api.py +++ b/g4f/gui/server/api.py @@ -1,11 +1,40 @@ +from __future__ import annotations + import logging import json +import os.path from typing import Iterator +from uuid import uuid4 +from functools import partial try: import webview + import platformdirs except ImportError: ... +try: + from plyer import camera + from plyer import filechooser + has_plyer = True +except ImportError: + has_plyer = False +try: + from android.runnable import run_on_ui_thread + from android.storage import app_storage_path + from android.permissions import request_permissions, Permission + from android.permissions import _RequestPermissionsManager + _RequestPermissionsManager.register_callback() + from .android_gallery import user_select_image + has_android = True +except ImportError: + run_on_ui_thread = lambda a : a + app_storage_path = platformdirs.user_pictures_dir + user_select_image = partial( + filechooser.open_file, + path=platformdirs.user_pictures_dir(), + filters=[["Image", "*.jpg", "*.jpeg", "*.png", "*.webp", "*.svg"]], + ) + has_android = False from g4f import version, models from g4f import get_last_provider, ChatCompletion @@ -75,13 +104,71 @@ class Api(): return {'title': ''} def get_conversation(self, options: dict, **kwargs) -> Iterator: - window = webview.active_window() + window = webview.windows[0] + if hasattr(self, "image") and self.image is not None: + kwargs["image"] = open(self.image, "rb") for message in self._create_response_stream( self._prepare_conversation_kwargs(options, kwargs), options.get("conversation_id") ): if not window.evaluate_js(f"if (!this.abort) this.add_message_chunk({json.dumps(message)}); !this.abort && !this.error;"): break + self.image = None + self.set_selected(None) + + @run_on_ui_thread + def choose_file(self): + self.request_permissions() + filechooser.open_file( + path=platformdirs.user_pictures_dir(), + on_selection=print + ) + + @run_on_ui_thread + def choose_image(self): + self.request_permissions() + user_select_image( + on_selection=self.on_image_selection + ) + + @run_on_ui_thread + def take_picture(self): + self.request_permissions() + filename = os.path.join(app_storage_path(), f"chat-{uuid4()}.png") + camera.take_picture(filename=filename, on_complete=self.on_camera) + + def on_image_selection(self, filename): + if filename is not None and os.path.exists(filename): + self.image = filename + else: + self.image = None + self.set_selected(None if self.image is None else "image") + + def on_camera(self, filename): + if filename is not None and os.path.exists(filename): + self.image = filename + else: + self.image = None + self.set_selected(None if self.image is None else "camera") + + def set_selected(self, input_id: str = None): + window = webview.windows[0] + if window is not None: + window.evaluate_js( + f"document.querySelector(`.file-label.selected`)?.classList.remove(`selected`);" + ) + if input_id is not None and input_id in ("image", "camera"): + window.evaluate_js( + f'document.querySelector(`label[for="{input_id}"]`)?.classList.add(`selected`);' + ) + + def request_permissions(self): + if has_android: + request_permissions([ + Permission.CAMERA, + Permission.READ_EXTERNAL_STORAGE, + Permission.WRITE_EXTERNAL_STORAGE + ]) def _prepare_conversation_kwargs(self, json_data: dict, kwargs: dict): """ diff --git a/g4f/gui/webview.py b/g4f/gui/webview.py index ba764947..36ad0e60 100644 --- a/g4f/gui/webview.py +++ b/g4f/gui/webview.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys import os.path import webview @@ -20,6 +22,8 @@ def run_webview( dirname = sys._MEIPASS else: dirname = os.path.dirname(__file__) + webview.settings['OPEN_EXTERNAL_LINKS_IN_BROWSER'] = False + webview.settings['ALLOW_DOWNLOADS'] = True webview.create_window( f"g4f - {g4f.version.utils.current_version}", os.path.join(dirname, "client/index.html"), |