diff --git a/app/src/main/java/eu/faircode/email/AI.java b/app/src/main/java/eu/faircode/email/AI.java new file mode 100644 index 0000000000..98ba2ce307 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/AI.java @@ -0,0 +1,153 @@ +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 . + + Copyright 2018-2024 by Marcel Bokhorst (M66B) +*/ + +import android.content.Context; +import android.content.SharedPreferences; +import android.text.Spannable; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; + +import androidx.preference.PreferenceManager; + +import org.json.JSONException; +import org.jsoup.nodes.Document; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +public class AI { + static boolean isAvailable(Context context) { + return (OpenAI.isAvailable(context) || Gemini.isAvailable(context)); + } + + static String completeChat(Context context, long id, CharSequence body) throws JSONException, IOException { + if (body == null || body.length() == 0) + return null; + + if (OpenAI.isAvailable(context)) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String model = prefs.getString("openai_model", OpenAI.DEFAULT_MODEL); + float temperature = prefs.getFloat("openai_temperature", OpenAI.DEFAULT_TEMPERATURE); + + OpenAI.Message message; + if (body instanceof Spannable) + message = new OpenAI.Message(OpenAI.USER, OpenAI.Content.get((Spannable) body, id, context)); + else + message = new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ + new OpenAI.Content(OpenAI.CONTENT_TEXT, body.toString())}); + + OpenAI.Message[] completions = + OpenAI.completeChat(context, model, new OpenAI.Message[]{message}, temperature, 1); + + StringBuilder sb = new StringBuilder(); + for (OpenAI.Message completion : completions) + for (OpenAI.Content content : completion.getContent()) + if (OpenAI.CONTENT_TEXT.equals(content.getType())) { + if (sb.length() > 0) + sb.append('\n'); + sb.append(content.getContent() + .replaceAll("^\\n+", "").replaceAll("\\n+$", "")); + } + + return sb.toString(); + } else if (Gemini.isAvailable(context)) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String model = prefs.getString("gemini_model", Gemini.DEFAULT_MODEL); + float temperature = prefs.getFloat("gemini_temperature", Gemini.DEFAULT_TEMPERATURE); + + Gemini.Message message = new Gemini.Message(Gemini.USER, + new String[]{Gemini.truncateParagraphs(body.toString())}); + Gemini.Message[] completions = Gemini.generate(context, model, new Gemini.Message[]{message}, temperature, 1); + if (completions.length == 0) + return null; + + return TextUtils.join("\n", completions[0].getContent()) + .replaceAll("^\\n+", "").replaceAll("\\n+$", ""); + } else + return null; + } + + static String getSummarizePrompt(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (OpenAI.isAvailable(context)) + return prefs.getString("openai_summarize", OpenAI.DEFAULT_SUMMARY_PROMPT); + else if (Gemini.isAvailable(context)) + return prefs.getString("gemini_summarize", Gemini.DEFAULT_SUMMARY_PROMPT); + else + return context.getString(R.string.title_summarize); + } + + static String summarize(Context context, long id, String subject, Document d) throws JSONException, IOException { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (OpenAI.isAvailable(context)) { + String model = prefs.getString("openai_model", OpenAI.DEFAULT_MODEL); + float temperature = prefs.getFloat("openai_temperature", OpenAI.DEFAULT_TEMPERATURE); + String prompt = prefs.getString("openai_summarize", OpenAI.DEFAULT_SUMMARY_PROMPT); + + List input = new ArrayList<>(); + input.add(new OpenAI.Message(OpenAI.USER, + new OpenAI.Content[]{new OpenAI.Content(OpenAI.CONTENT_TEXT, prompt)})); + + if (!TextUtils.isEmpty(subject)) + input.add(new OpenAI.Message(OpenAI.USER, + new OpenAI.Content[]{new OpenAI.Content(OpenAI.CONTENT_TEXT, subject)})); + + SpannableStringBuilder ssb = HtmlHelper.fromDocument(context, d, null, null); + input.add(new OpenAI.Message(OpenAI.USER, + OpenAI.Content.get(ssb, id, context))); + + OpenAI.Message[] result = + OpenAI.completeChat(context, model, input.toArray(new OpenAI.Message[0]), temperature, 1); + + if (result.length == 0) + return null; + + StringBuilder sb = new StringBuilder(); + for (OpenAI.Message completion : result) + for (OpenAI.Content content : completion.getContent()) + if (OpenAI.CONTENT_TEXT.equals(content.getType())) { + if (sb.length() != 0) + sb.append('\n'); + sb.append(content.getContent()); + } + return sb.toString(); + } else if (Gemini.isAvailable(context)) { + String model = prefs.getString("gemini_model", Gemini.DEFAULT_MODEL); + float temperature = prefs.getFloat("gemini_temperature", Gemini.DEFAULT_TEMPERATURE); + String prompt = prefs.getString("gemini_summarize", Gemini.DEFAULT_SUMMARY_PROMPT); + + String text = d.text(); + if (TextUtils.isEmpty(text)) + return null; + Gemini.Message content = new Gemini.Message(Gemini.USER, new String[]{prompt, text}); + + Gemini.Message[] result = + Gemini.generate(context, model, new Gemini.Message[]{content}, temperature, 1); + + if (result.length == 0) + return null; + + return TextUtils.join("\n", result[0].getContent()); + } else + return null; + } +} diff --git a/app/src/main/java/eu/faircode/email/AdapterMessage.java b/app/src/main/java/eu/faircode/email/AdapterMessage.java index 2ded42c204..9b1ba11f8a 100644 --- a/app/src/main/java/eu/faircode/email/AdapterMessage.java +++ b/app/src/main/java/eu/faircode/email/AdapterMessage.java @@ -2398,7 +2398,7 @@ public class AdapterMessage extends RecyclerView.Adapter 0 || tos > 0) ? View.VISIBLE : View.GONE); ibTranslate.setVisibility(tools && !outbox && button_translate && DeepL.isAvailable(context) && message.content ? View.VISIBLE : View.GONE); - ibSummarize.setVisibility(tools && !outbox && button_summarize && (OpenAI.isAvailable(context) || Gemini.isAvailable(context)) && message.content ? View.VISIBLE : View.GONE); + ibSummarize.setVisibility(tools && !outbox && button_summarize && AI.isAvailable(context) && message.content ? View.VISIBLE : View.GONE); ibFullScreen.setVisibility(tools && full && button_full_screen && message.content ? View.VISIBLE : View.GONE); ibForceLight.setVisibility(tools && full && dark && button_force_light && message.content ? View.VISIBLE : View.GONE); ibForceLight.setImageLevel(!(canDarken || fake_dark) || force_light ? 1 : 0); @@ -6257,7 +6257,7 @@ public class AdapterMessage extends RecyclerView.Adapter= 0 && end > start); @@ -2652,7 +2650,7 @@ public class FragmentCompose extends FragmentBase { args.putLong("id", working); args.putCharSequence("body", body); - new SimpleTask() { + new SimpleTask() { @Override protected void onPreExecute(Bundle args) { chatting = true; @@ -2666,45 +2664,15 @@ public class FragmentCompose extends FragmentBase { } @Override - protected OpenAI.Message[] onExecute(Context context, Bundle args) throws Throwable { + protected String onExecute(Context context, Bundle args) throws Throwable { long id = args.getLong("id"); CharSequence body = args.getCharSequence("body"); - if (body == null || body.length() == 0) - return null; - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - String model = prefs.getString("openai_model", OpenAI.DEFAULT_MODEL); - float temperature = prefs.getFloat("openai_temperature", OpenAI.DEFAULT_TEMPERATURE); - - OpenAI.Message message; - if (body instanceof Spannable) - message = new OpenAI.Message(OpenAI.USER, OpenAI.Content.get((Spannable) body, id, context)); - else - message = new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ - new OpenAI.Content(OpenAI.CONTENT_TEXT, body.toString())}); - - OpenAI.Message[] completions = - OpenAI.completeChat(context, model, new OpenAI.Message[]{message}, temperature, 1); - - return completions; + return AI.completeChat(context, id, body); } @Override - protected void onExecuted(Bundle args, OpenAI.Message[] messages) { - if (messages == null || messages.length == 0) - return; - - StringBuilder sb = new StringBuilder(); - for (OpenAI.Message message : messages) - for (OpenAI.Content content : message.getContent()) - if (OpenAI.CONTENT_TEXT.equals(content.getType())) { - if (sb.length() > 0) - sb.append('\n'); - sb.append(content.getContent().replaceAll("^\\n+", "").replaceAll("\\n+$", "")); - } - - + protected void onExecuted(Bundle args, String completion) { Editable edit = etBody.getText(); int start = etBody.getSelectionStart(); int end = etBody.getSelectionEnd(); @@ -2721,10 +2689,10 @@ public class FragmentCompose extends FragmentBase { if (index > 0 && edit.charAt(index - 1) != '\n') edit.insert(index++, "\n"); - edit.insert(index, sb + "\n"); - etBody.setSelection(index + sb.length() + 1); + edit.insert(index, completion + "\n"); + etBody.setSelection(index + completion.length() + 1); - StyleHelper.markAsInserted(edit, index, index + sb.length() + 1); + StyleHelper.markAsInserted(edit, index, index + completion.length() + 1); if (args.containsKey("used") && args.containsKey("granted")) { double used = args.getDouble("used"); @@ -2737,84 +2705,7 @@ public class FragmentCompose extends FragmentBase { protected void onException(Bundle args, Throwable ex) { Log.unexpectedError(getParentFragmentManager(), ex, !(ex instanceof IOException)); } - }.serial().execute(this, args, "openai"); - } - - private void onGemini() { - int start = etBody.getSelectionStart(); - int end = etBody.getSelectionEnd(); - boolean selection = (start >= 0 && end > start); - Editable edit = etBody.getText(); - 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) { - chatting = true; - invalidateOptionsMenu(); - } - - @Override - protected void onPostExecute(Bundle args) { - chatting = false; - invalidateOptionsMenu(); - } - - @Override - protected Gemini.Message[] onExecute(Context context, Bundle args) throws Throwable { - long id = args.getLong("id"); - String body = args.getString("body"); - boolean selection = args.getBoolean("selection"); - - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - String model = prefs.getString("gemini_model", Gemini.DEFAULT_MODEL); - float temperature = prefs.getFloat("gemini_temperature", Gemini.DEFAULT_TEMPERATURE); - - Gemini.Message message = new Gemini.Message(Gemini.USER, new String[]{Gemini.truncateParagraphs(body)}); - - return Gemini.generate(context, model, new Gemini.Message[]{message}, temperature, 1); - } - - @Override - protected void onExecuted(Bundle args, Gemini.Message[] messages) { - if (messages == null || messages.length == 0) - return; - - String text = TextUtils.join("\n", 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 = etBody.length(); - - 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 - protected void onException(Bundle args, Throwable ex) { - Log.unexpectedError(getParentFragmentManager(), ex, !(ex instanceof IOException)); - } - }.serial().execute(this, args, "gemini"); + }.serial().execute(this, args, "AI"); } private void onTranslate(View anchor) { diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogButtons.java b/app/src/main/java/eu/faircode/email/FragmentDialogButtons.java index 8a6d18683e..bef574fbd1 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogButtons.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogButtons.java @@ -71,7 +71,7 @@ public class FragmentDialogButtons extends FragmentDialogBase { final CheckBox cbAnswer = dview.findViewById(R.id.cbAnswer); cbTranslate.setVisibility(DeepL.isAvailable(context) ? View.VISIBLE : View.GONE); - cbSummarize.setVisibility(OpenAI.isAvailable(context) || Gemini.isAvailable(context) ? View.VISIBLE : View.GONE); + cbSummarize.setVisibility(AI.isAvailable(context) ? View.VISIBLE : View.GONE); cbPin.setVisibility(Shortcuts.can(context) ? View.VISIBLE : View.GONE); cbSeen.setChecked(prefs.getBoolean("button_seen", false)); diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogQuickActions.java b/app/src/main/java/eu/faircode/email/FragmentDialogQuickActions.java index 40a76a9859..acb35dff33 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogQuickActions.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogQuickActions.java @@ -65,8 +65,7 @@ public class FragmentDialogQuickActions extends FragmentDialogBase { final CheckBox cbInbox = dview.findViewById(R.id.cbInbox); final CheckBox cbClear = dview.findViewById(R.id.cbClear); - boolean hasAi = (OpenAI.isAvailable(context) || Gemini.isAvailable(context)); - cbSummarize.setVisibility(hasAi ? View.VISIBLE : View.GONE); + cbSummarize.setVisibility(AI.isAvailable(context) ? View.VISIBLE : View.GONE); tvHint.setText(getString(R.string.title_quick_actions_hint, MAX_QUICK_ACTIONS)); diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogSummarize.java b/app/src/main/java/eu/faircode/email/FragmentDialogSummarize.java index bfe34e5983..96e7fcf1e7 100644 --- a/app/src/main/java/eu/faircode/email/FragmentDialogSummarize.java +++ b/app/src/main/java/eu/faircode/email/FragmentDialogSummarize.java @@ -23,8 +23,6 @@ import android.app.Dialog; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; -import android.text.SpannableStringBuilder; -import android.text.TextUtils; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; @@ -39,9 +37,7 @@ import androidx.preference.PreferenceManager; import org.jsoup.nodes.Document; import java.io.File; -import java.util.ArrayList; import java.util.Date; -import java.util.List; public class FragmentDialogSummarize extends FragmentDialogBase { private static final int MAX_SUMMARIZE_TEXT_SIZE = 10 * 1024; @@ -63,20 +59,13 @@ public class FragmentDialogSummarize extends FragmentDialogBase { boolean compact = prefs.getBoolean("compact", false); int zoom = prefs.getInt("view_zoom", compact ? 0 : 1); int message_zoom = prefs.getInt("message_zoom", 100); - String prompt; - if (OpenAI.isAvailable(context)) - prompt = prefs.getString("openai_summarize", OpenAI.DEFAULT_SUMMARY_PROMPT); - else if (Gemini.isAvailable(context)) - prompt = prefs.getString("gemini_summarize", Gemini.DEFAULT_SUMMARY_PROMPT); - else - prompt = getString(R.string.title_summarize); float textSize = Helper.getTextSize(context, zoom) * message_zoom / 100f; tvSummary.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize); Bundle args = getArguments(); - tvCaption.setText(prompt); + tvCaption.setText(AI.getSummarizePrompt(context)); tvFrom.setText(args.getString("from")); tvSubject.setText(args.getString("subject")); @@ -120,67 +109,16 @@ public class FragmentDialogSummarize extends FragmentDialogBase { HtmlHelper.truncate(d, MAX_SUMMARIZE_TEXT_SIZE); - if (OpenAI.isAvailable(context)) { - String model = prefs.getString("openai_model", OpenAI.DEFAULT_MODEL); - float temperature = prefs.getFloat("openai_temperature", OpenAI.DEFAULT_TEMPERATURE); - String prompt = prefs.getString("openai_summarize", OpenAI.DEFAULT_SUMMARY_PROMPT); + long start = new Date().getTime(); + String summary = AI.summarize(context, id, message.subject, d); + args.putLong("elapsed", new Date().getTime() - start); - List input = new ArrayList<>(); - input.add(new OpenAI.Message(OpenAI.USER, - new OpenAI.Content[]{new OpenAI.Content(OpenAI.CONTENT_TEXT, prompt)})); - - if (!TextUtils.isEmpty(message.subject)) - input.add(new OpenAI.Message(OpenAI.USER, - new OpenAI.Content[]{new OpenAI.Content(OpenAI.CONTENT_TEXT, message.subject)})); - - SpannableStringBuilder ssb = HtmlHelper.fromDocument(context, d, null, null); - input.add(new OpenAI.Message(OpenAI.USER, - OpenAI.Content.get(ssb, id, context))); - - long start = new Date().getTime(); - OpenAI.Message[] result = - OpenAI.completeChat(context, model, input.toArray(new OpenAI.Message[0]), temperature, 1); - args.putLong("elapsed", new Date().getTime() - start); - - if (result.length == 0) - return null; - - StringBuilder sb = new StringBuilder(); - for (OpenAI.Message completion : result) - for (OpenAI.Content content : completion.getContent()) - if (OpenAI.CONTENT_TEXT.equals(content.getType())) { - if (sb.length() != 0) - sb.append('\n'); - sb.append(content.getContent()); - } - return sb.toString(); - } else if (Gemini.isAvailable(context)) { - String model = prefs.getString("gemini_model", Gemini.DEFAULT_MODEL); - float temperature = prefs.getFloat("gemini_temperature", Gemini.DEFAULT_TEMPERATURE); - String prompt = prefs.getString("gemini_summarize", Gemini.DEFAULT_SUMMARY_PROMPT); - - String text = d.text(); - if (TextUtils.isEmpty(text)) - return null; - Gemini.Message content = new Gemini.Message(Gemini.USER, new String[]{prompt, text}); - - long start = new Date().getTime(); - Gemini.Message[] result = - Gemini.generate(context, model, new Gemini.Message[]{content}, temperature, 1); - args.putLong("elapsed", new Date().getTime() - start); - - if (result.length == 0) - return null; - - return TextUtils.join("\n", result[0].getContent()); - } - - return null; + return summary; } @Override - protected void onExecuted(Bundle args, String text) { - tvSummary.setText(text); + protected void onExecuted(Bundle args, String summary) { + tvSummary.setText(summary); tvSummary.setVisibility(View.VISIBLE); tvElapsed.setText(Helper.formatDuration(args.getLong("elapsed"))); tvElapsed.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/eu/faircode/email/FragmentMessages.java b/app/src/main/java/eu/faircode/email/FragmentMessages.java index 13d72d1dac..8a40d94618 100644 --- a/app/src/main/java/eu/faircode/email/FragmentMessages.java +++ b/app/src/main/java/eu/faircode/email/FragmentMessages.java @@ -3425,10 +3425,9 @@ public class FragmentMessages extends FragmentBase .setIcon(R.drawable.twotone_south_24) .setEnabled(!EntityMessage.PRIORITIY_LOW.equals(message.importance)); - if (OpenAI.isAvailable(context) || Gemini.isAvailable(context)) { + if (AI.isAvailable(context)) popupMenu.getMenu().add(Menu.NONE, R.string.title_summarize, order++, R.string.title_summarize) .setIcon(R.drawable.twotone_smart_toy_24); - } if (message.accountProtocol == EntityAccount.TYPE_IMAP) { popupMenu.getMenu().add(Menu.NONE, R.string.title_move, order++, R.string.title_move) @@ -3548,7 +3547,7 @@ public class FragmentMessages extends FragmentBase private void onSwipeSummarize(final @NonNull TupleMessageEx message) { final Context context = getContext(); - if (OpenAI.isAvailable(context) || Gemini.isAvailable(context)) + if (AI.isAvailable(context)) FragmentDialogSummarize.summarize(message, getParentFragmentManager()); else context.startActivity(new Intent(context, ActivitySetup.class) diff --git a/app/src/main/java/eu/faircode/email/FragmentPop.java b/app/src/main/java/eu/faircode/email/FragmentPop.java index e3dc720b34..8a53912b27 100644 --- a/app/src/main/java/eu/faircode/email/FragmentPop.java +++ b/app/src/main/java/eu/faircode/email/FragmentPop.java @@ -1120,7 +1120,7 @@ public class FragmentPop extends FragmentBase { importance.name = getString(R.string.title_set_importance); folders.add(importance); - if (OpenAI.isAvailable(context) || Gemini.isAvailable(context)) { + if (AI.isAvailable(context)) { EntityFolder summarize = new EntityFolder(); summarize.id = EntityMessage.SWIPE_ACTION_SUMMARIZE; summarize.name = context.getString(R.string.title_summarize); diff --git a/app/src/main/res/menu/menu_compose.xml b/app/src/main/res/menu/menu_compose.xml index ffbc644855..fa277879ab 100644 --- a/app/src/main/res/menu/menu_compose.xml +++ b/app/src/main/res/menu/menu_compose.xml @@ -2,7 +2,7 @@