mirror of https://github.com/M66B/FairEmail.git
Proof of concept: OpenAI integration
This commit is contained in:
parent
9c1292e28f
commit
4bc0a09f41
28
FAQ.md
28
FAQ.md
|
@ -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)
|
* [(187) Are colored stars synchronized across devices?](#user-content-faq187)
|
||||||
* [(188) Why is Google backup disabled?](#user-content-faq188)
|
* [(188) Why is Google backup disabled?](#user-content-faq188)
|
||||||
* [(189) What is cloud sync?](#user-content-faq189)
|
* [(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)
|
[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.
|
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)?**
|
||||||
|
|
||||||
|
🌎 [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>
|
<h2><a name="get-support"></a>Get support</h2>
|
||||||
|
|
||||||
🌎 [Google Translate](https://translate.google.com/translate?sl=en&u=https://github.com/M66B/FairEmail/blob/master/FAQ.md%23user-content-get-support)
|
🌎 [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
|
@ -277,6 +277,7 @@ public class FragmentCompose extends FragmentBase {
|
||||||
private Group grpSignature;
|
private Group grpSignature;
|
||||||
private Group grpReferenceHint;
|
private Group grpReferenceHint;
|
||||||
|
|
||||||
|
private ImageButton ibOpenAi;
|
||||||
private ContentResolver resolver;
|
private ContentResolver resolver;
|
||||||
private AdapterAttachment adapter;
|
private AdapterAttachment adapter;
|
||||||
|
|
||||||
|
@ -1749,7 +1750,7 @@ public class FragmentCompose extends FragmentBase {
|
||||||
ImageButton ibTranslate = (ImageButton) infl.inflate(R.layout.action_button, null);
|
ImageButton ibTranslate = (ImageButton) infl.inflate(R.layout.action_button, null);
|
||||||
ibTranslate.setId(View.generateViewId());
|
ibTranslate.setId(View.generateViewId());
|
||||||
ibTranslate.setImageResource(R.drawable.twotone_translate_24);
|
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() {
|
ibTranslate.setOnClickListener(new View.OnClickListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onClick(View view) {
|
public void onClick(View view) {
|
||||||
|
@ -1758,6 +1759,18 @@ public class FragmentCompose extends FragmentBase {
|
||||||
});
|
});
|
||||||
menu.findItem(R.id.menu_translate).setActionView(ibTranslate);
|
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);
|
ImageButton ibZoom = (ImageButton) infl.inflate(R.layout.action_button, null);
|
||||||
ibZoom.setId(View.generateViewId());
|
ibZoom.setId(View.generateViewId());
|
||||||
ibZoom.setImageResource(R.drawable.twotone_format_size_24);
|
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_encrypt).setEnabled(state == State.LOADED);
|
||||||
menu.findItem(R.id.menu_translate).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_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_zoom).setEnabled(state == State.LOADED);
|
||||||
menu.findItem(R.id.menu_style).setEnabled(state == State.LOADED);
|
menu.findItem(R.id.menu_style).setEnabled(state == State.LOADED);
|
||||||
menu.findItem(R.id.menu_media).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);
|
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) {
|
private void onLanguageTool(int start, int end, boolean silent) {
|
||||||
etBody.clearComposingText();
|
etBody.clearComposingText();
|
||||||
|
|
||||||
|
|
|
@ -141,6 +141,10 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
|
||||||
private SwitchCompat swSend;
|
private SwitchCompat swSend;
|
||||||
private EditText etSend;
|
private EditText etSend;
|
||||||
private ImageButton ibSend;
|
private ImageButton ibSend;
|
||||||
|
private SwitchCompat swOpenAi;
|
||||||
|
private TextView tvOpenAiPrivacy;
|
||||||
|
private TextInputLayout tilOpenAi;
|
||||||
|
private ImageButton ibOpenAi;
|
||||||
private SwitchCompat swUpdates;
|
private SwitchCompat swUpdates;
|
||||||
private TextView tvGithubPrivacy;
|
private TextView tvGithubPrivacy;
|
||||||
private ImageButton ibChannelUpdated;
|
private ImageButton ibChannelUpdated;
|
||||||
|
@ -244,6 +248,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
|
||||||
|
|
||||||
private Group grpVirusTotal;
|
private Group grpVirusTotal;
|
||||||
private Group grpSend;
|
private Group grpSend;
|
||||||
|
private Group grpOpenAi;
|
||||||
private Group grpUpdates;
|
private Group grpUpdates;
|
||||||
private Group grpBitbucket;
|
private Group grpBitbucket;
|
||||||
private Group grpAnnouncements;
|
private Group grpAnnouncements;
|
||||||
|
@ -263,6 +268,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
|
||||||
"deepl_enabled",
|
"deepl_enabled",
|
||||||
"vt_enabled", "vt_apikey",
|
"vt_enabled", "vt_apikey",
|
||||||
"send_enabled", "send_host",
|
"send_enabled", "send_host",
|
||||||
|
"openai_enabled", "openai_apikey",
|
||||||
"updates", "weekly", "beta", "show_changelog", "announcements",
|
"updates", "weekly", "beta", "show_changelog", "announcements",
|
||||||
"crash_reports", "cleanup_attachments",
|
"crash_reports", "cleanup_attachments",
|
||||||
"watchdog", "experiments", "main_log", "main_log_memory", "protocol", "log_level", "debug", "leak_canary",
|
"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);
|
swSend = view.findViewById(R.id.swSend);
|
||||||
etSend = view.findViewById(R.id.etSend);
|
etSend = view.findViewById(R.id.etSend);
|
||||||
ibSend = view.findViewById(R.id.ibSend);
|
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);
|
swUpdates = view.findViewById(R.id.swUpdates);
|
||||||
tvGithubPrivacy = view.findViewById(R.id.tvGithubPrivacy);
|
tvGithubPrivacy = view.findViewById(R.id.tvGithubPrivacy);
|
||||||
ibChannelUpdated = view.findViewById(R.id.ibChannelUpdated);
|
ibChannelUpdated = view.findViewById(R.id.ibChannelUpdated);
|
||||||
|
@ -468,6 +478,7 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
|
||||||
|
|
||||||
grpVirusTotal = view.findViewById(R.id.grpVirusTotal);
|
grpVirusTotal = view.findViewById(R.id.grpVirusTotal);
|
||||||
grpSend = view.findViewById(R.id.grpSend);
|
grpSend = view.findViewById(R.id.grpSend);
|
||||||
|
grpOpenAi = view.findViewById(R.id.grpOpenAi);
|
||||||
grpUpdates = view.findViewById(R.id.grpUpdates);
|
grpUpdates = view.findViewById(R.id.grpUpdates);
|
||||||
grpBitbucket = view.findViewById(R.id.grpBitbucket);
|
grpBitbucket = view.findViewById(R.id.grpBitbucket);
|
||||||
grpAnnouncements = view.findViewById(R.id.grpAnnouncements);
|
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() {
|
swUpdates.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
|
||||||
@Override
|
@Override
|
||||||
public void onCheckedChanged(CompoundButton compoundButton, boolean checked) {
|
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);
|
grpVirusTotal.setVisibility(BuildConfig.PLAY_STORE_RELEASE ? View.GONE : View.VISIBLE);
|
||||||
grpSend.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 &&
|
grpUpdates.setVisibility(!BuildConfig.DEBUG &&
|
||||||
(Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext()))
|
(Helper.isPlayStoreInstall() || !Helper.hasValidFingerprint(getContext()))
|
||||||
|
@ -2056,7 +2111,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
|
||||||
"lt_user".equals(key) ||
|
"lt_user".equals(key) ||
|
||||||
"lt_key".equals(key) ||
|
"lt_key".equals(key) ||
|
||||||
"vt_apikey".equals(key) ||
|
"vt_apikey".equals(key) ||
|
||||||
"send_host".equals(key))
|
"send_host".equals(key) ||
|
||||||
|
"openai_apikey".equals(key))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ("global_keywords".equals(key))
|
if ("global_keywords".equals(key))
|
||||||
|
@ -2221,6 +2277,8 @@ public class FragmentOptionsMisc extends FragmentBase implements SharedPreferenc
|
||||||
tilVirusTotal.getEditText().setText(prefs.getString("vt_apikey", null));
|
tilVirusTotal.getEditText().setText(prefs.getString("vt_apikey", null));
|
||||||
swSend.setChecked(prefs.getBoolean("send_enabled", false));
|
swSend.setChecked(prefs.getBoolean("send_enabled", false));
|
||||||
etSend.setText(prefs.getString("send_host", null));
|
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));
|
swUpdates.setChecked(prefs.getBoolean("updates", true));
|
||||||
swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext())));
|
swCheckWeekly.setChecked(prefs.getBoolean("weekly", Helper.hasPlayStore(getContext())));
|
||||||
swCheckWeekly.setEnabled(swUpdates.isChecked());
|
swCheckWeekly.setEnabled(swUpdates.isChecked());
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -565,6 +565,62 @@
|
||||||
app:layout_constraintTop_toBottomOf="@id/etSend"
|
app:layout_constraintTop_toBottomOf="@id/etSend"
|
||||||
app:srcCompat="@drawable/twotone_info_24" />
|
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
|
<androidx.appcompat.widget.SwitchCompat
|
||||||
android:id="@+id/swUpdates"
|
android:id="@+id/swUpdates"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
@ -576,7 +632,7 @@
|
||||||
android:text="@string/title_advanced_updates"
|
android:text="@string/title_advanced_updates"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/ibSend"
|
app:layout_constraintTop_toBottomOf="@id/ibOpenAi"
|
||||||
app:switchPadding="12dp" />
|
app:switchPadding="12dp" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
|
@ -809,6 +865,12 @@
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
app:constraint_referenced_ids="swSend,etSend,ibSend" />
|
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
|
<androidx.constraintlayout.widget.Group
|
||||||
android:id="@+id/grpUpdates"
|
android:id="@+id/grpUpdates"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|
|
@ -13,6 +13,12 @@
|
||||||
android:title="@string/title_translate"
|
android:title="@string/title_translate"
|
||||||
app:showAsAction="always" />
|
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
|
<item
|
||||||
android:id="@+id/menu_zoom"
|
android:id="@+id/menu_zoom"
|
||||||
android:icon="@drawable/twotone_format_size_24"
|
android:icon="@drawable/twotone_format_size_24"
|
||||||
|
|
|
@ -793,6 +793,7 @@
|
||||||
<string name="title_advanced_deepl">DeepL integration</string>
|
<string name="title_advanced_deepl">DeepL integration</string>
|
||||||
<string name="title_advanced_virus_total">VirusTotal integration</string>
|
<string name="title_advanced_virus_total">VirusTotal integration</string>
|
||||||
<string name="title_advanced_send">\'Send\' 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_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_watchdog">Periodically check if FairEmail is still active</string>
|
||||||
<string name="title_advanced_updates">Check for GitHub updates</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_create_template">Create template</string>
|
||||||
<string name="title_select_default_identity">Select default address</string>
|
<string name="title_select_default_identity">Select default address</string>
|
||||||
<string name="title_translate">Translate</string>
|
<string name="title_translate">Translate</string>
|
||||||
|
<string name="title_openai" translatable="false">OpenAI (ChatGPT)</string>
|
||||||
<string name="title_translate_configure">Configure …</string>
|
<string name="title_translate_configure">Configure …</string>
|
||||||
<string name="title_translate_key">Enter key</string>
|
<string name="title_translate_key">Enter key</string>
|
||||||
<string name="title_translating">Translating …</string>
|
<string name="title_translating">Translating …</string>
|
||||||
|
|
Loading…
Reference in New Issue