Proof of concept: OpenAI integration

This commit is contained in:
M66B
2023-03-07 10:17:39 +01:00
parent 9c1292e28f
commit 4bc0a09f41
8 changed files with 6914 additions and 3 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -277,6 +277,7 @@ public class FragmentCompose extends FragmentBase {
private Group grpSignature;
private Group grpReferenceHint;
private ImageButton ibOpenAi;
private ContentResolver resolver;
private AdapterAttachment adapter;
@@ -1749,7 +1750,7 @@ public class FragmentCompose extends FragmentBase {
ImageButton ibTranslate = (ImageButton) infl.inflate(R.layout.action_button, null);
ibTranslate.setId(View.generateViewId());
ibTranslate.setImageResource(R.drawable.twotone_translate_24);
ib.setContentDescription(getString(R.string.title_translate));
ibTranslate.setContentDescription(getString(R.string.title_translate));
ibTranslate.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
@@ -1758,6 +1759,18 @@ public class FragmentCompose extends FragmentBase {
});
menu.findItem(R.id.menu_translate).setActionView(ibTranslate);
ibOpenAi = (ImageButton) infl.inflate(R.layout.action_button, null);
ibOpenAi.setId(View.generateViewId());
ibOpenAi.setImageResource(R.drawable.twotone_question_answer_24);
ibOpenAi.setContentDescription(getString(R.string.title_openai));
ibOpenAi.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
onOpenAi(vwAnchorMenu);
}
});
menu.findItem(R.id.menu_openai).setActionView(ibOpenAi);
ImageButton ibZoom = (ImageButton) infl.inflate(R.layout.action_button, null);
ibZoom.setId(View.generateViewId());
ibZoom.setImageResource(R.drawable.twotone_format_size_24);
@@ -1784,6 +1797,8 @@ public class FragmentCompose extends FragmentBase {
menu.findItem(R.id.menu_encrypt).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_translate).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_translate).setVisible(DeepL.isAvailable(context));
menu.findItem(R.id.menu_openai).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_openai).setVisible(OpenAI.isAvailable(context));
menu.findItem(R.id.menu_zoom).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_style).setEnabled(state == State.LOADED);
menu.findItem(R.id.menu_media).setEnabled(state == State.LOADED);
@@ -2546,6 +2561,110 @@ public class FragmentCompose extends FragmentBase {
popupMenu.showWithIcons(context, anchor);
}
private void onOpenAi(View anchor) {
int start = etBody.getSelectionStart();
int end = etBody.getSelectionEnd();
Editable edit = etBody.getText();
String body = (start >= 0 && end > start ? edit.subSequence(start, end) : edit)
.toString().trim();
Bundle args = new Bundle();
args.putLong("id", working);
args.putString("body", body);
new SimpleTask<OpenAI.Message[]>() {
@Override
protected void onPreExecute(Bundle args) {
if (ibOpenAi != null)
ibOpenAi.setEnabled(false);
}
@Override
protected void onPostExecute(Bundle args) {
if (ibOpenAi != null)
ibOpenAi.setEnabled(true);
}
@Override
protected OpenAI.Message[] onExecute(Context context, Bundle args) throws Throwable {
long id = args.getLong("id");
String body = args.getString("body");
DB db = DB.getInstance(context);
EntityMessage draft = db.message().getMessage(id);
if (draft == null)
return null;
List<EntityMessage> conversation = db.message().getMessagesByThread(draft.account, draft.thread, null, null);
if (conversation == null)
return null;
if (TextUtils.isEmpty(body) && conversation.size() == 0)
return null;
EntityFolder sent = db.folder().getFolderByType(draft.account, EntityFolder.SENT);
if (sent == null)
return null;
Collections.sort(conversation, new Comparator<EntityMessage>() {
@Override
public int compare(EntityMessage m1, EntityMessage m2) {
return Long.compare(m1.received, m2.received);
}
});
List<OpenAI.Message> messages = new ArrayList<>();
//messages.add(new OpenAI.Message("system", "You are a helpful assistant."));
List<String> msgids = new ArrayList<>();
for (EntityMessage message : conversation) {
if (Objects.equals(draft.msgid, message.msgid))
continue;
if (msgids.contains(message.msgid))
continue;
msgids.add(message.msgid);
String text = HtmlHelper.getFullText(message.getFile(context));
String[] paragraphs = text.split("[\\r\\n]+");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 3 && i < paragraphs.length; i++)
sb.append(paragraphs[i]).append("\n");
String role = (MessageHelper.equalEmail(draft.from, message.from) ? "assistant" : "user");
messages.add(new OpenAI.Message(role, sb.toString()));
if (msgids.size() >= 3)
break;
}
if (!TextUtils.isEmpty(body))
messages.add(new OpenAI.Message("assistant", body));
if (messages.size() == 0)
return null;
return OpenAI.complete(context, messages.toArray(new OpenAI.Message[0]), 1);
}
@Override
protected void onExecuted(Bundle args, OpenAI.Message[] messages) {
if (messages != null && messages.length > 0) {
int start = etBody.getSelectionEnd();
String content = messages[0].getContent();
Editable edit = etBody.getText();
edit.insert(start, content);
int end = start + content.length();
etBody.setSelection(end);
StyleHelper.markAsInserted(edit, start, end);
}
}
@Override
protected void onException(Bundle args, Throwable ex) {
Log.unexpectedError(getParentFragmentManager(), ex);
}
}.execute(this, args, "openai");
}
private void onLanguageTool(int start, int end, boolean silent) {
etBody.clearComposingText();

View File

@@ -141,6 +141,10 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
private SwitchCompat swSend;
private EditText etSend;
private ImageButton ibSend;
private SwitchCompat swOpenAi;
private TextView tvOpenAiPrivacy;
private TextInputLayout tilOpenAi;
private ImageButton ibOpenAi;
private SwitchCompat swUpdates;
private TextView tvGithubPrivacy;
private ImageButton ibChannelUpdated;
@@ -244,6 +248,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
private Group grpVirusTotal;
private Group grpSend;
private Group grpOpenAi;
private Group grpUpdates;
private Group grpBitbucket;
private Group grpAnnouncements;
@@ -263,6 +268,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
"deepl_enabled",
"vt_enabled", "vt_apikey",
"send_enabled", "send_host",
"openai_enabled", "openai_apikey",
"updates", "weekly", "beta", "show_changelog", "announcements",
"crash_reports", "cleanup_attachments",
"watchdog", "experiments", "main_log", "main_log_memory", "protocol", "log_level", "debug", "leak_canary",
@@ -365,6 +371,10 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
swSend = view.findViewById(R.id.swSend);
etSend = view.findViewById(R.id.etSend);
ibSend = view.findViewById(R.id.ibSend);
swOpenAi = view.findViewById(R.id.swOpenAi);
tvOpenAiPrivacy = view.findViewById(R.id.tvOpenAiPrivacy);
tilOpenAi = view.findViewById(R.id.tilOpenAi);
ibOpenAi = view.findViewById(R.id.ibOpenAi);
swUpdates = view.findViewById(R.id.swUpdates);
tvGithubPrivacy = view.findViewById(R.id.tvGithubPrivacy);
ibChannelUpdated = view.findViewById(R.id.ibChannelUpdated);
@@ -468,6 +478,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
grpVirusTotal = view.findViewById(R.id.grpVirusTotal);
grpSend = view.findViewById(R.id.grpSend);
grpOpenAi = view.findViewById(R.id.grpOpenAi);
grpUpdates = view.findViewById(R.id.grpUpdates);
grpBitbucket = view.findViewById(R.id.grpBitbucket);
grpAnnouncements = view.findViewById(R.id.grpAnnouncements);
@@ -870,6 +881,49 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
}
});
swOpenAi.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
prefs.edit().putBoolean("openai_enabled", checked).apply();
}
});
tvOpenAiPrivacy.getPaint().setUnderlineText(true);
tvOpenAiPrivacy.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Helper.view(v.getContext(), Uri.parse(OpenAI.URI_PRIVACY), true);
}
});
tilOpenAi.getEditText().addTextChangedListener(new TextWatcher() {
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) {
// Do nothing
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count) {
// Do nothing
}
@Override
public void afterTextChanged(Editable s) {
String apikey = s.toString().trim();
if (TextUtils.isEmpty(apikey))
prefs.edit().remove("openai_apikey").apply();
else
prefs.edit().putString("openai_apikey", apikey).apply();
}
});
ibOpenAi.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Helper.viewFAQ(v.getContext(), 190);
}
});
swUpdates.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
@Override
public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
@@ -1959,6 +2013,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
grpVirusTotal.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
grpSend.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
grpOpenAi.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
grpUpdates.setVisibility(!BuildConfig.DEBUG &&
(Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext()))
@@ -2056,7 +2111,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
"lt_user".equals(key) ||
"lt_key".equals(key) ||
"vt_apikey".equals(key) ||
"send_host".equals(key))
"send_host".equals(key) ||
"openai_apikey".equals(key))
return;
if ("global_keywords".equals(key))
@@ -2221,6 +2277,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
tilVirusTotal.getEditText().setText(prefs.getString("vt_apikey", null));
swSend.setChecked(prefs.getBoolean("send_enabled", false));
etSend.setText(prefs.getString("send_host", null));
swOpenAi.setChecked(prefs.getBoolean("openai_enabled", false));
tilOpenAi.getEditText().setText(prefs.getString("openai_apikey", null));
swUpdates.setChecked(prefs.getBoolean("updates", true));
swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext())));
swCheckWeekly.setEnabled(swUpdates.isChecked());

View File

@@ -0,0 +1,158 @@
package eu.faircode.email;
/*
This file is part of FairEmail.
FairEmail is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FairEmail is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FairEmail. If not, see <http://www.gnu.org/licenses/>.
Copyright 2018-2023 by Marcel Bokhorst (M66B)
*/
import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;
import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
public class OpenAI {
static final String URI_ENDPOINT = "https://api.openai.com/";
static final String URI_PRIVACY = "https://openai.com/policies/privacy-policy";
private static final int TIMEOUT = 20; // seconds
static boolean isAvailable(Context context) {
if (BuildConfig.PLAY_STORE_RELEASE)
return false;
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
boolean enabled = prefs.getBoolean("openai_enabled", false);
String apikey = prefs.getString("openai_apikey", null);
return (enabled && !TextUtils.isEmpty(apikey));
}
static Message[] complete(Context context, Message[] messages, int n) throws JSONException, IOException {
// https://platform.openai.com/docs/guides/chat/introduction
// https://platform.openai.com/docs/api-reference/chat/create
JSONArray jmessages = new JSONArray();
for (Message message : messages) {
JSONObject jmessage = new JSONObject();
jmessage.put("role", message.role);
jmessage.put("content", message.content);
jmessages.put(jmessage);
}
JSONObject jquestion = new JSONObject();
jquestion.put("model", "gpt-3.5-turbo");
jquestion.put("messages", jmessages);
jquestion.put("n", n);
JSONObject jresponse = call(context, "v1/chat/completions", jquestion);
JSONArray jchoices = jresponse.getJSONArray("choices");
Message[] choices = new Message[jchoices.length()];
for (int i = 0; i < jchoices.length(); i++) {
JSONObject jchoice = jchoices.getJSONObject(i);
JSONObject jmessage = jchoice.getJSONObject("message");
choices[i] = new Message(jmessage.getString("role"), jmessage.getString("content"));
}
return choices;
}
private static JSONObject call(Context context, String method, JSONObject args) throws JSONException, IOException {
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
String apikey = prefs.getString("openai_apikey", null);
// https://platform.openai.com/docs/api-reference/introduction
Uri uri = Uri.parse(URI_ENDPOINT).buildUpon().appendEncodedPath(method).build();
Log.i("OpenAI uri=" + uri);
String json = args.toString();
Log.i("OpenAI request=" + json);
URL url = new URL(uri.toString());
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setDoOutput(true);
connection.setDoInput(true);
connection.setReadTimeout(TIMEOUT * 1000);
connection.setConnectTimeout(TIMEOUT * 1000);
ConnectionHelper.setUserAgent(context, connection);
connection.setRequestProperty("Accept", "application/json");
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + apikey);
connection.connect();
try {
connection.getOutputStream().write(json.getBytes());
int status = connection.getResponseCode();
if (status != HttpURLConnection.HTTP_OK) {
// https://platform.openai.com/docs/guides/error-codes/api-errors
String error = "Error " + status + ": " + connection.getResponseMessage();
try {
InputStream is = connection.getErrorStream();
if (is != null)
error += "\n" + Helper.readStream(is);
} catch (Throwable ex) {
Log.w(ex);
}
throw new IOException(error);
}
String response = Helper.readStream(connection.getInputStream());
Log.i("OpenAI response=" + response);
return new JSONObject(response);
} finally {
connection.disconnect();
}
}
static class Message {
private final String role; // // system, user, assistant
private final String content;
public Message(String role, String content) {
this.role = role;
this.content = content;
}
public String getRole() {
return this.role;
}
public String getContent() {
return this.content;
}
@NonNull
@Override
public String toString() {
return this.role + ": " + this.content;
}
}
}