Merge pull request #2572 from hlohaus/15Jan

Readd JsApi for Webview UI
This commit is contained in:
H Lohaus 2025-01-15 22:35:28 +01:00 committed by GitHub
commit 55d6709efc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 270 additions and 106 deletions

View file

@ -382,7 +382,7 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
# if auth_result.arkose_token is None:
# raise MissingAuthError("No arkose token found in .har file")
if "proofofwork" in chat_requirements:
if getattr(auth_result, "proof_token") is None:
if getattr(auth_result, "proof_token", None) is None:
auth_result.proof_token = get_config(auth_result.headers.get("user-agent"))
proofofwork = generate_proof_token(
**chat_requirements["proofofwork"],
@ -444,12 +444,9 @@ class OpenaiChat(AsyncAuthedProvider, ProviderModelMixin):
headers=headers
) as response:
cls._update_request_args(auth_result, session)
if response.status in (403, 404) and max_retries > 0:
max_retries -= 1
debug.log(f"Retry: Error {response.status}: {await response.text()}")
conversation.conversation_id = None
await asyncio.sleep(5)
continue
if response.status == 403:
auth_result.proof_token = None
RequestConfig.proof_token = None
await raise_for_status(response)
buffer = u""
async for line in response.iter_lines():

View file

@ -32,6 +32,7 @@ from types import SimpleNamespace
from typing import Union, Optional, List
import g4f
import g4f.Provider
import g4f.debug
from g4f.client import AsyncClient, ChatCompletion, ImagesResponse, convert_to_provider
from g4f.providers.response import BaseConversation, JsonConversation
@ -223,7 +224,18 @@ class Api:
"created": 0,
"owned_by": model.base_provider,
"image": isinstance(model, g4f.models.ImageModel),
} for model_id, model in g4f.models.ModelUtils.convert.items()]
"provider": False,
} for model_id, model in g4f.models.ModelUtils.convert.items()] +
[{
"id": provider_name,
"object": "model",
"created": 0,
"owned_by": getattr(provider, "label", None),
"image": bool(getattr(provider, "image_models", False)),
"provider": True,
} for provider_name, provider in g4f.Provider.ProviderUtils.convert.items()
if provider.working and provider_name != "Custom"
]
}
@self.app.get("/v1/models/{model_name}", responses={

View file

@ -136,8 +136,7 @@
font-size: 1.2rem;
margin-bottom: 30px;
color: var(--colour-2);
} return app
}
.input-field {
width: 80%;
@ -211,9 +210,7 @@
<!-- Input and Button -->
<form action="/chat/">
<!--
<input type="text" name="prompt" class="input-field" placeholder="Enter your query...">
-->
<input type="text" name="prompt" class="input-field" placeholder="Enter your query...">
<button class="button">Open Chat</button>
</form>

View file

@ -10,11 +10,11 @@
<meta property="og:description" content="A conversational AI system that listens, learns, and challenges">
<meta property="og:url" content="https://g4f.ai">
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/all.min.css">
<link rel="apple-touch-icon" sizes="180x180" href="/static/img/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/static/img/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/static/img/favicon-16x16.png">
<link rel="manifest" href="/static/img/site.webmanifest">
<script src="/static/js/icons.js"></script>
<script src="/static/js/highlightjs-copy.min.js"></script>
<script src="/static/js/chat.v1.js" defer></script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@13.0.1/dist/markdown-it.min.js"></script>
@ -172,7 +172,7 @@
</div>
<div class="bottom_buttons">
<button onclick="delete_conversations()">
<i class="fa-regular fa-trash"></i>
<i class="fa-solid fa-trash"></i>
<span>Clear Conversations</span>
</button>
<button onclick="save_storage()">
@ -243,7 +243,7 @@
<i class="fa-solid fa-microphone-slash"></i>
</label>
<div id="send-button">
<i class="fa-solid fa-paper-plane-top"></i>
<i class="fa-regular fa-paper-plane"></i>
</div>
</div>
</div>

9
g4f/gui/client/static/css/all.min.css vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1290,7 +1290,7 @@ ul {
border: 1px dashed #e4d4ffa6;
border-radius: 4px;
cursor: pointer;
padding-left: 8px;
padding-left: 4px;
padding-right: 5px;
padding-top: 2px;
padding-bottom: 2px;

View file

@ -15,5 +15,13 @@
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
"display": "standalone",
"share_target": {
"action": "/chat/",
"method": "GET",
"enctype": "application/x-www-form-urlencoded",
"params": {
"title": "prompt"
}
}
}

View file

@ -421,18 +421,18 @@ regenerate_button.addEventListener("click", async () => {
});
stop_generating.addEventListener("click", async () => {
stop_generating.classList.add("stop_generating-hidden");
regenerate_button.classList.remove("regenerate-hidden");
stop_generating.classList.add("stop_generating-hidden");
let key;
for (key in controller_storage) {
if (!controller_storage[key].signal.aborted) {
console.log(`aborted ${window.conversation_id} #${key}`);
controller_storage[key].abort();
let message = message_storage[key];
if (message) {
content_storage[key].inner.innerHTML += " [aborted]";
message_storage[key] += " [aborted]";
console.log(`aborted ${window.conversation_id} #${key}`);
}
controller_storage[key].abort();
}
}
await load_conversation(window.conversation_id, false);
@ -727,6 +727,13 @@ async function add_message_chunk(message, message_id, provider, scroll) {
}
}
function is_stopped() {
if (stop_generating.classList.contains('stop_generating-hidden')) {
return true;
}
return false;
}
const ask_gpt = async (message_id, message_index = -1, regenerate = false, provider = null, model = null, action = null) => {
if (!model && !provider) {
model = get_selected_model()?.value || null;
@ -826,13 +833,12 @@ const ask_gpt = async (message_id, message_index = -1, regenerate = false, provi
content_map.inner.innerHTML += markdown_render(`**An error occured:** ${e}`);
}
}
delete controller_storage[message_id];
if (message_storage[message_id]) {
const message_provider = message_id in provider_storage ? provider_storage[message_id] : null;
await add_message(
window.conversation_id,
"assistant",
message_storage[message_id] + (error_storage[message_id] ? " [error]" : ""),
message_storage[message_id] + (error_storage[message_id] ? " [error]" : "") + (stop_generating.classList.contains('stop_generating-hidden') ? " [aborted]" : ""),
message_provider,
message_index,
synthesize_storage[message_id],
@ -842,6 +848,7 @@ const ask_gpt = async (message_id, message_index = -1, regenerate = false, provi
usage_storage[message_id],
action=="continue"
);
delete controller_storage[message_id];
delete message_storage[message_id];
if (!error_storage[message_id]) {
await safe_load_conversation(window.conversation_id, scroll);
@ -961,7 +968,7 @@ const delete_conversation = async (conversation_id) => {
const set_conversation = async (conversation_id) => {
try {
history.pushState({}, null, `/chat/${conversation_id}`);
add_url_to_history(`/chat/${conversation_id}`);
} catch (e) {
console.error(e);
}
@ -1242,7 +1249,7 @@ async function add_conversation(conversation_id) {
});
}
try {
history.pushState({}, null, `/chat/${conversation_id}`);
add_url_to_history(`/chat/${conversation_id}`);
} catch (e) {
console.error(e);
}
@ -1371,7 +1378,7 @@ const load_conversations = async () => {
</div>
<i onclick="show_option('${conversation.id}')" class="fa-solid fa-ellipsis-vertical" id="conv-${conversation.id}"></i>
<div id="cho-${conversation.id}" class="choise" style="display:none;">
<i onclick="delete_conversation('${conversation.id}')" class="fa-regular fa-trash"></i>
<i onclick="delete_conversation('${conversation.id}')" class="fa-solid fa-trash"></i>
<i onclick="hide_option('${conversation.id}')" class="fa-regular fa-x"></i>
</div>
</div>
@ -1434,17 +1441,23 @@ sidebar_button.addEventListener("click", async () => {
} else {
sidebar.classList.add("shown");
sidebar_button.classList.add("rotated");
history.pushState({}, null, "/menu/");
add_url_to_history("/menu/");
}
window.scrollTo(0, 0);
});
function add_url_to_history(url) {
if (!window?.pywebview) {
history.pushState({}, null, url);
}
}
function open_settings() {
if (settings.classList.contains("hidden")) {
chat.classList.add("hidden");
sidebar.classList.remove("shown");
settings.classList.remove("hidden");
history.pushState({}, null, "/settings/");
add_url_to_history("/settings/");
} else {
settings.classList.add("hidden");
chat.classList.remove("hidden");
@ -1523,8 +1536,9 @@ const load_settings_storage = async () => {
} else {
element.value = value;
}
break;
default:
console.warn("Unresolved element type");
console.warn("`Unresolved element type:", element.type);
}
}
});
@ -1644,17 +1658,29 @@ window.addEventListener('DOMContentLoaded', async function() {
await on_load();
if (window.conversation_id == "{{chat_id}}") {
window.conversation_id = uuid();
} else {
await on_api();
}
});
window.addEventListener('pywebviewready', async function() {
await on_api();
});
async function on_load() {
count_input();
if (/\/chat\/.+/.test(window.location.href)) {
if (/\/chat\/[^?]+/.test(window.location.href)) {
load_conversation(window.conversation_id);
} else {
say_hello()
chatPrompt.value = document.getElementById("systemPrompt")?.value || "";
let chat_url = new URL(window.location.href)
let chat_params = new URLSearchParams(chat_url.search);
if (chat_params.get("prompt")) {
messageInput.value = chat_params.get("prompt");
await handle_ask();
} else {
say_hello()
}
}
load_conversations();
}
@ -1694,6 +1720,7 @@ const load_provider_option = (input, provider_name) => {
};
async function on_api() {
load_version();
let prompt_lock = false;
messageInput.addEventListener("keydown", async (evt) => {
if (prompt_lock) return;
@ -1718,82 +1745,75 @@ async function on_api() {
});
messageInput.focus();
let provider_options = [];
try {
models = await api("models");
models.forEach((model) => {
let option = document.createElement("option");
option.value = model.name;
option.text = model.name + (model.image ? " (Image Generation)" : "");
option.dataset.providers = model.providers.join(" ");
modelSelect.appendChild(option);
});
providers = await api("providers")
providers.sort((a, b) => a.label.localeCompare(b.label));
let login_urls = {};
providers.forEach((provider) => {
let option = document.createElement("option");
option.value = provider.name;
option.dataset.label = provider.label;
option.text = provider.label
+ (provider.vision ? " (Image Upload)" : "")
+ (provider.image ? " (Image Generation)" : "")
+ (provider.webdriver ? " (Webdriver)" : "")
+ (provider.auth ? " (Auth)" : "");
if (provider.parent)
option.dataset.parent = provider.parent;
providerSelect.appendChild(option);
models = await api("models");
models.forEach((model) => {
let option = document.createElement("option");
option.value = model.name;
option.text = model.name + (model.image ? " (Image Generation)" : "");
option.dataset.providers = model.providers.join(" ");
modelSelect.appendChild(option);
});
providers = await api("providers")
providers.sort((a, b) => a.label.localeCompare(b.label));
let login_urls = {};
providers.forEach((provider) => {
let option = document.createElement("option");
option.value = provider.name;
option.dataset.label = provider.label;
option.text = provider.label
+ (provider.vision ? " (Image Upload)" : "")
+ (provider.image ? " (Image Generation)" : "")
+ (provider.webdriver ? " (Webdriver)" : "")
+ (provider.auth ? " (Auth)" : "");
if (provider.parent)
option.dataset.parent = provider.parent;
providerSelect.appendChild(option);
if (provider.parent) {
if (!login_urls[provider.parent]) {
login_urls[provider.parent] = [provider.label, provider.login_url, [provider.name]];
} else {
login_urls[provider.parent][2].push(provider.name);
}
} else if (provider.login_url) {
if (!login_urls[provider.name]) {
login_urls[provider.name] = [provider.label, provider.login_url, []];
} else {
login_urls[provider.name][0] = provider.label;
login_urls[provider.name][1] = provider.login_url;
}
if (provider.parent) {
if (!login_urls[provider.parent]) {
login_urls[provider.parent] = [provider.label, provider.login_url, [provider.name]];
} else {
login_urls[provider.parent][2].push(provider.name);
}
});
for (let [name, [label, login_url, childs]] of Object.entries(login_urls)) {
if (!login_url) {
continue;
} else if (provider.login_url) {
if (!login_urls[provider.name]) {
login_urls[provider.name] = [provider.label, provider.login_url, []];
} else {
login_urls[provider.name][0] = provider.label;
login_urls[provider.name][1] = provider.login_url;
}
option = document.createElement("div");
option.classList.add("field", "box", "hidden");
childs = childs.map((child)=>`${child}-api_key`).join(" ");
option.innerHTML = `
<label for="${name}-api_key" class="label" title="">${label}:</label>
<input type="text" id="${name}-api_key" name="${name}[api_key]" class="${childs}" placeholder="api_key"/>
<a href="${login_url}" target="_blank" title="Login to ${label}">Get API key</a>
`;
settings.querySelector(".paper").appendChild(option);
}
providers.forEach((provider) => {
if (!provider.parent) {
option = document.createElement("div");
option.classList.add("field");
option.innerHTML = `
<span class="label">Enable ${provider.label}</span>
<input id="Provider${provider.name}" type="checkbox" name="Provider${provider.name}" value="${provider.name}" class="provider" checked="">
<label for="Provider${provider.name}" class="toogle" title="Remove provider from dropdown"></label>
`;
option.querySelector("input").addEventListener("change", (event) => load_provider_option(event.target, provider.name));
settings.querySelector(".paper").appendChild(option);
provider_options[provider.name] = option;
}
});
await load_provider_models(appStorage.getItem("provider"));
} catch (e) {
console.error(e)
// Redirect to show basic authenfication
if (document.location.pathname == "/chat/") {
//document.location.href = `/chat/error`;
});
for (let [name, [label, login_url, childs]] of Object.entries(login_urls)) {
if (!login_url) {
continue;
}
option = document.createElement("div");
option.classList.add("field", "box", "hidden");
childs = childs.map((child)=>`${child}-api_key`).join(" ");
option.innerHTML = `
<label for="${name}-api_key" class="label" title="">${label}:</label>
<input type="text" id="${name}-api_key" name="${name}[api_key]" class="${childs}" placeholder="api_key"/>
<a href="${login_url}" target="_blank" title="Login to ${label}">Get API key</a>
`;
settings.querySelector(".paper").appendChild(option);
}
providers.forEach((provider) => {
if (!provider.parent) {
option = document.createElement("div");
option.classList.add("field");
option.innerHTML = `
<span class="label">Enable ${provider.label}</span>
<input id="Provider${provider.name}" type="checkbox" name="Provider${provider.name}" value="${provider.name}" class="provider" checked="">
<label for="Provider${provider.name}" class="toogle" title="Remove provider from dropdown"></label>
`;
option.querySelector("input").addEventListener("change", (event) => load_provider_option(event.target, provider.name));
settings.querySelector(".paper").appendChild(option);
provider_options[provider.name] = option;
}
});
await load_provider_models(appStorage.getItem("provider"))
register_settings_storage();
await load_settings_storage()
Object.entries(provider_options).forEach(
@ -1870,7 +1890,6 @@ async function load_version() {
document.getElementById("version_text").innerHTML = text
setTimeout(load_version, 1000 * 60 * 60); // 1 hour
}
setTimeout(load_version, 100);
[imageInput, cameraInput].forEach((el) => {
el.addEventListener('click', async () => {
@ -1886,6 +1905,20 @@ fileInput.addEventListener('click', async (event) => {
fileInput.value = '';
});
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();
}
});
async function upload_cookies() {
const file = fileInput.files[0];
const formData = new FormData();
@ -2021,6 +2054,18 @@ function get_selected_model() {
}
async function api(ressource, args=null, files=null, message_id=null, scroll=true) {
if (window?.pywebview) {
if (args !== null) {
if (ressource == "conversation") {
return pywebview.api[`get_${ressource}`](args, message_id, scroll);
}
if (ressource == "models") {
ressource = "provider_models";
}
return pywebview.api[`get_${ressource}`](args);
}
return pywebview.api[`get_${ressource}`]();
}
const headers = {};
if (ressource == "models" && args) {
api_key = get_api_key_by_provider(args);
@ -2033,7 +2078,7 @@ async function api(ressource, args=null, files=null, message_id=null, scroll=tru
}
ressource = `${ressource}/${args}`;
}
const url = `/backend-api/v2/${ressource}`;
const url = new URL(`/backend-api/v2/${ressource}`, window?.location || "http://localhost:8080");
if (ressource == "conversation") {
let body = JSON.stringify(args);
headers.accept = 'text/event-stream';

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -88,10 +88,10 @@ class Api:
provider = json_data.get('provider')
messages = json_data.get('messages')
api_key = json_data.get("api_key")
if api_key is not None:
if api_key:
kwargs["api_key"] = api_key
api_base = json_data.get("api_base")
if api_base is not None:
if api_base:
kwargs["api_base"] = api_base
kwargs["tool_calls"] = [{
"function": {

95
g4f/gui/server/js_api.py Normal file
View file

@ -0,0 +1,95 @@
from __future__ import annotations
import json
import os.path
from typing import Iterator
from uuid import uuid4
from functools import partial
import webview
import platformdirs
from plyer import camera
from plyer import filechooser
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"]],
)
from .api import Api
class JsApi(Api):
def get_conversation(self, options: dict, message_id: str = None, scroll: bool = None, **kwargs) -> Iterator:
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"),
options.get('provider')
):
if window.evaluate_js(
f"""
is_stopped() ? true :
this.add_message_chunk({
json.dumps(message)
}, {
json.dumps(message_id)
}, {
json.dumps(options.get('provider'))
}, {
'true' if scroll else 'false'
}); is_stopped();
"""):
break
self.image = None
self.set_selected(None)
def choose_image(self):
user_select_image(
on_selection=self.on_image_selection
)
def take_picture(self):
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):
filename = filename[0] if isinstance(filename, list) and filename else filename
if filename 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 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(`.image-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 get_version(self):
return super().get_version()
def get_models(self):
return super().get_models()
def get_providers(self):
return super().get_providers()
def get_provider_models(self, provider: str, **kwargs):
return super().get_provider_models(provider, **kwargs)

View file

@ -10,6 +10,7 @@ except ImportError:
has_platformdirs = False
from g4f.gui.gui_parser import gui_parser
from g4f.gui.server.js_api import JsApi
import g4f.version
import g4f.debug
@ -30,6 +31,7 @@ def run_webview(
f"g4f - {g4f.version.utils.current_version}",
os.path.join(dirname, "client/index.html"),
text_select=True,
js_api=JsApi(),
)
if has_platformdirs and storage_path is None:
storage_path = user_config_dir("g4f-webview")