diff --git a/FAQ.md b/FAQ.md index 753d9ea478..dbb6d02018 100644 --- a/FAQ.md +++ b/FAQ.md @@ -5238,14 +5238,16 @@ Cloud sync is an experimental feature. It is not available for the Play Store ve **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). +Tap on the robot button in the top action bar of the message editor. +The text in the message editor and the first part of the message being replied to (maximum of 1,000 characters) +will be used for [chat completion](https://platform.openai.com/docs/guides/chat/introduction). +If text is selected in the message editor, only the selected text will be used. -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. +For example: create a new draft and enter the text "*How far is the sun?*", and tap on the robot 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. +This feature is experimental and available in the GitHub version only and requires version 1.2052 or later.
diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index 874640c825..b26b42641c 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -277,7 +277,6 @@ public class FragmentCompose extends FragmentBase { private Group grpSignature; private Group grpReferenceHint; - private ImageButton ibOpenAi; private ContentResolver resolver; private AdapterAttachment adapter; @@ -300,6 +299,7 @@ public class FragmentCompose extends FragmentBase { private List last_attachments = null; private boolean saved = false; private String subject = null; + private boolean chatting = false; private Uri photoURI = null; @@ -1759,9 +1759,9 @@ public class FragmentCompose extends FragmentBase { }); menu.findItem(R.id.menu_translate).setActionView(ibTranslate); - ibOpenAi = (ImageButton) infl.inflate(R.layout.action_button, null); + ImageButton ibOpenAi = (ImageButton) infl.inflate(R.layout.action_button, null); ibOpenAi.setId(View.generateViewId()); - ibOpenAi.setImageResource(R.drawable.twotone_question_answer_24); + ibOpenAi.setImageResource(R.drawable.twotone_smart_toy_24); ibOpenAi.setContentDescription(getString(R.string.title_openai)); ibOpenAi.setOnClickListener(new View.OnClickListener() { @Override @@ -1797,7 +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).setEnabled(state == State.LOADED && !chatting); + ((ImageButton) menu.findItem(R.id.menu_openai).getActionView()).setEnabled(!chatting); 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); @@ -2564,97 +2565,101 @@ public class FragmentCompose extends FragmentBase { private void onOpenAi(View anchor) { int start = etBody.getSelectionStart(); int end = etBody.getSelectionEnd(); + boolean selection = (start >= 0 && end > start); Editable edit = etBody.getText(); - String body = (start >= 0 && end > start ? edit.subSequence(start, end) : edit) - .toString().trim(); + String body = (selection ? edit.subSequence(start, end) : edit).toString().trim(); Bundle args = new Bundle(); args.putLong("id", working); args.putString("body", body); + args.putBoolean("selection", selection); new SimpleTask() { @Override protected void onPreExecute(Bundle args) { - if (ibOpenAi != null) - ibOpenAi.setEnabled(false); + chatting = true; + invalidateOptionsMenu(); } @Override protected void onPostExecute(Bundle args) { - if (ibOpenAi != null) - ibOpenAi.setEnabled(true); + chatting = false; + invalidateOptionsMenu(); } @Override protected OpenAI.Message[] onExecute(Context context, Bundle args) throws Throwable { long id = args.getLong("id"); String body = args.getString("body"); + boolean selection = args.getBoolean("selection"); DB db = DB.getInstance(context); EntityMessage draft = db.message().getMessage(id); if (draft == null) return null; - List 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() { - @Override - public int compare(EntityMessage m1, EntityMessage m2) { - return Long.compare(m1.received, m2.received); - } - }); + List inreplyto; + if (selection || TextUtils.isEmpty(draft.inreplyto)) + inreplyto = new ArrayList<>(); + else + inreplyto = db.message().getMessagesByMsgId(draft.account, draft.inreplyto); - List messages = new ArrayList<>(); + List result = new ArrayList<>(); //messages.add(new OpenAI.Message("system", "You are a helpful assistant.")); - List 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); + if (inreplyto.size() > 0 && inreplyto.get(0).content) { + Document parsed = JsoupEx.parse(inreplyto.get(0).getFile(context)); + Document document = HtmlHelper.sanitizeView(context, parsed, false); + Spanned spanned = HtmlHelper.fromDocument(context, document, null, null); + String[] paragraphs = spanned.toString().split("[\\r\\n]+"); - String text = HtmlHelper.getFullText(message.getFile(context)); - String[] paragraphs = text.split("[\\r\\n]+"); + int i = 0; StringBuilder sb = new StringBuilder(); - for (int i = 0; i < 3 && i < paragraphs.length; i++) - sb.append(paragraphs[i]).append("\n"); - messages.add(new OpenAI.Message("assistant", sb.toString())); + while (i < paragraphs.length && + sb.length() + paragraphs[i].length() + 1 < 1000) + sb.append(paragraphs[i++]).append('\n'); - if (msgids.size() >= 3) - break; + String role = (MessageHelper.equalEmail(draft.from, inreplyto.get(0).from) ? "assistant" : "user"); + result.add(new OpenAI.Message(role, sb.toString())); } if (!TextUtils.isEmpty(body)) - messages.add(new OpenAI.Message("user", body)); + result.add(new OpenAI.Message("assistant", body)); - if (messages.size() == 0) + if (result.size() == 0) return null; - return OpenAI.complete(context, messages.toArray(new OpenAI.Message[0]), 1); + return OpenAI.completeChat(context, result.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); - } + if (messages == null || messages.length == 0) + return; + + String text = messages[0].getContent() + .replaceAll("^\\n+", "").replaceAll("\\n+$", ""); + + Editable edit = etBody.getText(); + int start = etBody.getSelectionStart(); + int end = etBody.getSelectionEnd(); + + int index; + if (etBody.hasSelection()) { + edit.delete(start, end); + index = start; + } else + index = end; + + if (index < 0) + index = 0; + if (index > 0 && edit.charAt(index - 1) != '\n') + edit.insert(index++, "\n"); + + edit.insert(index, text + "\n"); + etBody.setSelection(index + text.length() + 1); + + StyleHelper.markAsInserted(edit, index, index + text.length() + 1); } @Override diff --git a/app/src/main/java/eu/faircode/email/OpenAI.java b/app/src/main/java/eu/faircode/email/OpenAI.java index 6ae8f68310..5aedcaa58e 100644 --- a/app/src/main/java/eu/faircode/email/OpenAI.java +++ b/app/src/main/java/eu/faircode/email/OpenAI.java @@ -40,7 +40,7 @@ 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 + private static final int TIMEOUT = 30; // seconds static boolean isAvailable(Context context) { if (BuildConfig.PLAY_STORE_RELEASE) @@ -53,7 +53,7 @@ public class OpenAI { return (enabled && !TextUtils.isEmpty(apikey)); } - static Message[] complete(Context context, Message[] messages, int n) throws JSONException, IOException { + static Message[] completeChat(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 diff --git a/app/src/main/res/drawable/twotone_smart_toy_24.xml b/app/src/main/res/drawable/twotone_smart_toy_24.xml new file mode 100644 index 0000000000..8f81a0f830 --- /dev/null +++ b/app/src/main/res/drawable/twotone_smart_toy_24.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/menu/menu_compose.xml b/app/src/main/res/menu/menu_compose.xml index d908c94183..dca879e7c2 100644 --- a/app/src/main/res/menu/menu_compose.xml +++ b/app/src/main/res/menu/menu_compose.xml @@ -15,7 +15,7 @@