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

28
FAQ.md
View File

@ -389,6 +389,7 @@ Anything on this list is in random order and *might* be added in the near future
* [(187) Are colored stars synchronized across devices?](#user-content-faq187)
* [(188) Why is Google backup disabled?](#user-content-faq188)
* [(189) What is cloud sync?](#user-content-faq189)
* [(190) How do I use OpenAI (ChatGPT)?](#user-content-faq190)
[I have another question.](#user-content-get-support)
@ -5221,6 +5222,33 @@ using a 256 bit key derived from the username and password with [PBKDF2](https:/
Cloud sync is an experimental feature. It is not available for the Play Store version of the app, yet.
<br>
<a name="faq190"></a>
**(190) How do I use OpenAI (ChatGPT)?**
&#x1F30E; [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md%23user-content-faq180)
**Setup**
* Create an account [here](https://platform.openai.com/signup)
* Create an APIkey [here](https://platform.openai.com/account/api-keys)
* Copy the APIkey and paste it in the corresponding field of the miscellaneous settings tab page
* Enable the OpenAI switch
**Usage**
Tap on the conversation button in the top action bar of the message editor.
The selected text in the message editor and the first three paragraphs of the first three messages in the conversation will be used for [chat completion](https://platform.openai.com/docs/guides/chat/introduction).
For example: create a new draft and enter the text "*How far is the sun?*", and tap on the conversation button in the top action bar.
OpenAI isn't very fast, so be patient.
This feature is available in the GitHub version only and requires version 1.2052 or later.
<br>
<h2><a name="get-support"></a>Get support</h2>
&#x1F30E; [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md%23user-content-get-support)

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;
}
}
}

View File

@ -565,6 +565,62 @@
app:layout_constraintTop_toBottomOf="@id/etSend"
app:srcCompat="@drawable/twotone_info_24" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/swOpenAi"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/title_advanced_openai"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ibSend"
app:switchPadding="12dp" />
<TextView
android:id="@+id/tvOpenAiPrivacy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:drawableEnd="@drawable/twotone_open_in_new_12"
android:drawablePadding="6dp"
android:text="@string/title_privacy_policy"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:textColor="?android:attr/textColorLink"
app:drawableTint="?android:attr/textColorLink"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/swOpenAi" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilOpenAi"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="12dp"
app:endIconMode="password_toggle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/tvOpenAiPrivacy">
<com.google.android.material.textfield.TextInputEditText
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:autofillHints="password"
android:hint="API key"
android:inputType="textPassword"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/ibOpenAi"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:contentDescription="@string/title_info"
android:tooltipText="@string/title_info"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/tilOpenAi"
app:srcCompat="@drawable/twotone_info_24" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/swUpdates"
android:layout_width="0dp"
@ -576,7 +632,7 @@
android:text="@string/title_advanced_updates"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/ibSend"
app:layout_constraintTop_toBottomOf="@id/ibOpenAi"
app:switchPadding="12dp" />
<TextView
@ -809,6 +865,12 @@
android:layout_height="0dp"
app:constraint_referenced_ids="swSend,etSend,ibSend" />
<androidx.constraintlayout.widget.Group
android:id="@+id/grpOpenAi"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="swOpenAi,tvOpenAiPrivacy,tilOpenAi,ibOpenAi" />
<androidx.constraintlayout.widget.Group
android:id="@+id/grpUpdates"
android:layout_width="0dp"

View File

@ -13,6 +13,12 @@
android:title="@string/title_translate"
app:showAsAction="always" />
<item
android:id="@+id/menu_openai"
android:icon="@drawable/twotone_question_answer_24"
android:title="@string/title_openai"
app:showAsAction="always" />
<item
android:id="@+id/menu_zoom"
android:icon="@drawable/twotone_format_size_24"

View File

@ -793,6 +793,7 @@
<string name="title_advanced_deepl">DeepL integration</string>
<string name="title_advanced_virus_total">VirusTotal integration</string>
<string name="title_advanced_send">\'Send\' integration</string>
<string name="title_advanced_openai">OpenAI (ChatGPT) integration</string>
<string name="title_advanced_sdcard">I want to use an sdcard</string>
<string name="title_advanced_watchdog">Periodically check if FairEmail is still active</string>
<string name="title_advanced_updates">Check for GitHub updates</string>
@ -1599,6 +1600,7 @@
<string name="title_create_template">Create template</string>
<string name="title_select_default_identity">Select default address</string>
<string name="title_translate">Translate</string>
<string name="title_openai" translatable="false">OpenAI (ChatGPT)</string>
<string name="title_translate_configure">Configure &#8230;</string>
<string name="title_translate_key">Enter key</string>
<string name="title_translating">Translating &#8230;</string>