From a3b8bd98de4ab8d562ed95508fc7cd1e6a2b631c Mon Sep 17 00:00:00 2001 From: M66B Date: Tue, 14 Jan 2025 18:11:12 +0100 Subject: [PATCH] Improved answer AI --- app/src/main/java/eu/faircode/email/AI.java | 86 +++---- .../eu/faircode/email/ExpressionHelper.java | 2 +- .../eu/faircode/email/FragmentCompose.java | 218 ++++++++---------- .../eu/faircode/email/FragmentDialogAI.java | 199 ++++++++++++++++ app/src/main/res/layout/dialog_ai.xml | 90 ++++++++ app/src/main/res/values/strings.xml | 4 + 6 files changed, 416 insertions(+), 183 deletions(-) create mode 100644 app/src/main/java/eu/faircode/email/FragmentDialogAI.java create mode 100644 app/src/main/res/layout/dialog_ai.xml diff --git a/app/src/main/java/eu/faircode/email/AI.java b/app/src/main/java/eu/faircode/email/AI.java index a03614e8b1..a340ce79a9 100644 --- a/app/src/main/java/eu/faircode/email/AI.java +++ b/app/src/main/java/eu/faircode/email/AI.java @@ -31,7 +31,6 @@ import androidx.preference.PreferenceManager; import org.json.JSONException; import org.jsoup.nodes.Document; -import org.jsoup.select.Elements; import java.io.File; import java.io.IOException; @@ -39,51 +38,14 @@ import java.util.ArrayList; import java.util.List; public class AI { - private static final int MAX_SUMMARIZE_TEXT_SIZE = 4 * 1024; + static final int MAX_SUMMARIZE_TEXT_SIZE = 4 * 1024; static boolean isAvailable(Context context) { return (OpenAI.isAvailable(context) || Gemini.isAvailable(context)); } - static Spanned completeChat(Context context, long id, CharSequence body, long template) throws JSONException, IOException { - if (body == null) - body = ""; - - String reply = null; - if (body.length() == 0 || template < 0L /* Default */) { - File file = EntityMessage.getFile(context, id); - if (file.exists()) { - Document d = JsoupEx.parse(file); - Elements ref = d.select("div[fairemail=reference]"); - if (!ref.isEmpty()) { - d = Document.createShell(""); - d.appendChildren(ref); - - HtmlHelper.removeSignatures(d); - HtmlHelper.truncate(d, MAX_SUMMARIZE_TEXT_SIZE); - - reply = d.text(); - if (TextUtils.isEmpty(reply.trim())) - reply = null; - } - } - } - - String templatePrompt = null; - if (template > 0L) { - DB db = DB.getInstance(context); - EntityAnswer t = db.answer().getAnswer(template); - if (t != null) { - String html = t.getData(context, null).getHtml(); - templatePrompt = JsoupEx.parse(html).body().text(); - } - } - - return completeChat(context, id, body, reply, templatePrompt); - } - @NonNull - static Spanned completeChat(Context context, long id, CharSequence body, String reply, String prompt) throws JSONException, IOException { + static Spanned completeChat(Context context, long id, boolean system, CharSequence body, String reply, String prompt) throws JSONException, IOException { StringBuilder sb = new StringBuilder(); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (OpenAI.isAvailable(context)) { @@ -95,28 +57,24 @@ public class AI { List messages = new ArrayList<>(); - if (!TextUtils.isEmpty(systemPrompt)) + if (system && !TextUtils.isEmpty(systemPrompt)) messages.add(new OpenAI.Message(OpenAI.SYSTEM, new OpenAI.Content[]{ new OpenAI.Content(OpenAI.CONTENT_TEXT, systemPrompt)})); - if (reply == null) { - if (prompt != null) - messages.add(new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ - new OpenAI.Content(OpenAI.CONTENT_TEXT, prompt)})); + messages.add(new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ + new OpenAI.Content(OpenAI.CONTENT_TEXT, prompt == null ? defaultPrompt : prompt)})); + + if (!TextUtils.isEmpty(body)) if (body instanceof Spannable && multimodal) messages.add(new OpenAI.Message(OpenAI.USER, OpenAI.Content.get((Spannable) body, id, context))); else messages.add(new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ new OpenAI.Content(OpenAI.CONTENT_TEXT, body.toString())})); - } else { - if (prompt == null && body.length() > 0) - prompt = body.toString(); - messages.add(new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ - new OpenAI.Content(OpenAI.CONTENT_TEXT, prompt == null ? defaultPrompt : prompt)})); + + if (!TextUtils.isEmpty(reply)) messages.add(new OpenAI.Message(OpenAI.USER, new OpenAI.Content[]{ new OpenAI.Content(OpenAI.CONTENT_TEXT, reply)})); - } OpenAI.Message[] completions = OpenAI.completeChat(context, model, messages.toArray(new OpenAI.Message[0]), temperature, 1); @@ -137,17 +95,15 @@ public class AI { List messages = new ArrayList<>(); - if (reply == null) { - if (prompt != null) - messages.add(new Gemini.Message(Gemini.USER, new String[]{prompt})); + messages.add(new Gemini.Message(Gemini.USER, new String[]{prompt == null ? defaultPrompt : prompt})); + + if (!TextUtils.isEmpty(body)) messages.add(new Gemini.Message(Gemini.USER, new String[]{Gemini.truncateParagraphs(body.toString())})); - } else { - if (prompt == null && body.length() > 0) - prompt = body.toString(); - messages.add(new Gemini.Message(Gemini.USER, new String[]{prompt == null ? defaultPrompt : prompt})); - messages.add(new Gemini.Message(Gemini.USER, new String[]{reply})); - } + + if (!TextUtils.isEmpty(reply)) + messages.add(new Gemini.Message(Gemini.USER, + new String[]{Gemini.truncateParagraphs(reply)})); Gemini.Message[] completions = Gemini.generate(context, model, messages.toArray(new Gemini.Message[0]), temperature, 1); @@ -168,6 +124,16 @@ public class AI { return HtmlHelper.fromDocument(context, d, null, null); } + static String getDefaultPrompt(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (OpenAI.isAvailable(context)) + return prefs.getString("openai_answer", OpenAI.DEFAULT_ANSWER_PROMPT); + else if (Gemini.isAvailable(context)) + return prefs.getString("gemini_answer", Gemini.DEFAULT_ANSWER_PROMPT); + else + return null; + } + static String getSummarizePrompt(Context context) { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (OpenAI.isAvailable(context)) diff --git a/app/src/main/java/eu/faircode/email/ExpressionHelper.java b/app/src/main/java/eu/faircode/email/ExpressionHelper.java index ae90994418..e972e9292d 100644 --- a/app/src/main/java/eu/faircode/email/ExpressionHelper.java +++ b/app/src/main/java/eu/faircode/email/ExpressionHelper.java @@ -478,7 +478,7 @@ public class ExpressionHelper { if (doc != null && parameterValues.length == 1) { String prompt = parameterValues[0].getStringValue(); if (!TextUtils.isEmpty(prompt)) { - result = AI.completeChat(context, -1L, doc.text(), null, prompt).toString(); + result = AI.completeChat(context, -1L, true, doc.text(), null, prompt).toString(); EntityLog.log(context, EntityLog.Type.Rules, message, "AI result=" + result); } } diff --git a/app/src/main/java/eu/faircode/email/FragmentCompose.java b/app/src/main/java/eu/faircode/email/FragmentCompose.java index 3b0c6f03ff..b2b8962fc3 100644 --- a/app/src/main/java/eu/faircode/email/FragmentCompose.java +++ b/app/src/main/java/eu/faircode/email/FragmentCompose.java @@ -353,6 +353,7 @@ public class FragmentCompose extends FragmentBase { static final int REQUEST_EDIT_ATTACHMENT = 17; private static final int REQUEST_REMOVE_ATTACHMENTS = 18; private static final int REQUEST_EDIT_IMAGE = 19; + private static final int REQUEST_AI = 20; ActivityResultLauncher pickImages; @@ -1975,8 +1976,15 @@ public class FragmentCompose extends FragmentBase { ibAI.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - if (AI.isAvailable(context)) - onAI(view); + if (AI.isAvailable(context)) { + Bundle args = new Bundle(); + args.putBoolean("has_body", !TextUtils.isEmpty(etBody.getText().toString().trim())); + args.putBoolean("has_reply", tvReference.getVisibility() == View.VISIBLE); + FragmentDialogAI fragment = new FragmentDialogAI(); + fragment.setArguments(args); + fragment.setTargetFragment(FragmentCompose.this, REQUEST_AI); + fragment.show(getParentFragmentManager(), "do:ai"); + } } }); ibAI.setOnLongClickListener(new View.OnLongClickListener() { @@ -2772,126 +2780,6 @@ public class FragmentCompose extends FragmentBase { }.serial().execute(this, args, "compose:print"); } - private void onAI(View anchor) { - new SimpleTask>() { - @Override - protected List onExecute(Context context, Bundle args) throws Throwable { - DB db = DB.getInstance(context); - return db.answer().getAiPrompts(); - } - - @Override - protected void onExecuted(Bundle args, List prompts) { - if (prompts == null || prompts.isEmpty()) - _onAi(null); - else { - PopupMenuLifecycle popupMenu = new PopupMenuLifecycle(getContext(), getViewLifecycleOwner(), anchor); - - String title = getString(etBody.length() == 0 - ? R.string.title_advanced_default_prompt - : R.string.title_advanced_entered_text); - SpannableStringBuilder ssb = new SpannableStringBuilderEx(title); - ssb.setSpan(new RelativeSizeSpan(HtmlHelper.FONT_SMALL), 0, ssb.length(), 0); - popupMenu.getMenu() - .add(Menu.NONE, 1, 1, ssb) - .setIntent(new Intent().putExtra("id", -1L)); - - for (int i = 0; i < prompts.size(); i++) { - EntityAnswer prompt = prompts.get(i); - popupMenu.getMenu() - .add(Menu.NONE, i + 2, i + 2, prompt.name) - .setIntent(new Intent().putExtra("id", prompt.id)); - } - - popupMenu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - long id = item.getIntent().getLongExtra("id", -1L); - _onAi(id); - return true; - } - }); - - popupMenu.show(); - } - } - - @Override - protected void onException(Bundle args, Throwable ex) { - Log.unexpectedError(getParentFragmentManager(), ex); - } - }.execute(this, new Bundle(), "AI:template"); - } - - private void _onAi(Long template) { - int start = etBody.getSelectionStart(); - int end = etBody.getSelectionEnd(); - boolean selection = (start >= 0 && end > start); - Editable edit = etBody.getText(); - CharSequence body = (selection ? edit.subSequence(start, end) : edit); - - Bundle args = new Bundle(); - args.putLong("id", working); - args.putCharSequence("body", body); - args.putLong("template", template == null ? 0L : template); - - new SimpleTask() { - @Override - protected void onPreExecute(Bundle args) { - chatting = true; - invalidateOptionsMenu(); - } - - @Override - protected void onPostExecute(Bundle args) { - chatting = false; - invalidateOptionsMenu(); - } - - @Override - protected Spanned onExecute(Context context, Bundle args) throws Throwable { - long id = args.getLong("id"); - CharSequence body = args.getCharSequence("body"); - long template = args.getLong("template"); - - return AI.completeChat(context, id, body, template); - } - - @Override - protected void onExecuted(Bundle args, Spanned completion) { - if (completion == null) - return; - - 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, "\n"); - edit.insert(index, completion); - etBody.setSelection(index + completion.length() + 1); - - StyleHelper.markAsInserted(edit, index, index + completion.length() + 1); - } - - @Override - protected void onException(Bundle args, Throwable ex) { - Log.unexpectedError(getParentFragmentManager(), ex, !(ex instanceof IOException)); - } - }.serial().execute(this, args, "AI:run"); - } - private void onTranslate(View anchor) { final Context context = anchor.getContext(); @@ -3523,6 +3411,11 @@ public class FragmentCompose extends FragmentBase { if (resultCode == RESULT_OK && data != null) onEditImage(data.getBundleExtra("args")); break; + + case REQUEST_AI: + if (resultCode == RESULT_OK && data != null) + onAI(data.getBundleExtra("args")); + break; } } catch (Throwable ex) { Log.e(ex); @@ -5237,6 +5130,87 @@ public class FragmentCompose extends FragmentBase { }.execute(this, args, "update:image"); } + private void onAI(Bundle args) { + args.putLong("id", working); + args.putCharSequence("body", etBody.getText()); + + new SimpleTask() { + @Override + protected void onPreExecute(Bundle args) { + chatting = true; + invalidateOptionsMenu(); + } + + @Override + protected void onPostExecute(Bundle args) { + chatting = false; + invalidateOptionsMenu(); + } + + @Override + protected Spanned onExecute(Context context, Bundle args) throws Throwable { + long id = args.getLong("id"); + boolean input_system = args.getBoolean("input_system"); + boolean input_body = args.getBoolean("input_body"); + boolean input_reply = args.getBoolean("input_reply"); + String prompt = args.getString("prompt"); + + CharSequence body = null; + if (input_body) + body = args.getCharSequence("body"); + + String reply = null; + if (input_reply) { + File file = EntityMessage.getFile(context, id); + if (file.exists()) { + Document d = JsoupEx.parse(file); + Elements ref = d.select("div[fairemail=reference]"); + if (!ref.isEmpty()) { + d = Document.createShell(""); + d.appendChildren(ref); + + HtmlHelper.removeSignatures(d); + HtmlHelper.truncate(d, AI.MAX_SUMMARIZE_TEXT_SIZE); + + reply = d.text(); + if (TextUtils.isEmpty(reply.trim())) + reply = null; + } + } + } + + return AI.completeChat(context, id, input_system, body, reply, prompt); + } + + @Override + protected void onExecuted(Bundle args, Spanned completion) { + if (completion == null) + return; + + Editable edit = etBody.getText(); + if (edit == null) + return; + + int index = etBody.getSelectionEnd(); + if (index < 0) + index = 0; + if (index > 0 && edit.charAt(index - 1) != '\n') + edit.insert(index++, "\n"); + + edit.insert(index, "\n"); + edit.insert(index, completion); + etBody.setSelection(index + completion.length() + 1); + + StyleHelper.markAsInserted(edit, index, index + completion.length() + 1); + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex, !(ex instanceof IOException)); + } + }.serial().execute(this, args, "AI:run"); + } + private void onExit() { if (state == State.LOADED) { state = State.NONE; diff --git a/app/src/main/java/eu/faircode/email/FragmentDialogAI.java b/app/src/main/java/eu/faircode/email/FragmentDialogAI.java new file mode 100644 index 0000000000..40d612ddd3 --- /dev/null +++ b/app/src/main/java/eu/faircode/email/FragmentDialogAI.java @@ -0,0 +1,199 @@ +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-2025 by Marcel Bokhorst (M66B) +*/ + +import static android.app.Activity.RESULT_OK; + +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.TextUtils; +import android.util.Pair; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.Spinner; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.preference.PreferenceManager; + +import java.util.ArrayList; +import java.util.List; + +public class FragmentDialogAI extends FragmentDialogBase { + private Spinner spPrompt; + private EditText etPrompt; + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final Context context = getContext(); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + String systemPrompt = prefs.getString("openai_system", null); + + Bundle args = getArguments(); + boolean has_body = args.getBoolean("has_body"); + boolean has_reply = args.getBoolean("has_reply"); + boolean has_system = (OpenAI.isAvailable(context) && !TextUtils.isEmpty(systemPrompt)); + + final View view = LayoutInflater.from(context).inflate(R.layout.dialog_ai, null); + spPrompt = view.findViewById(R.id.spPrompt); + etPrompt = view.findViewById(R.id.etPrompt); + final CheckBox cbInputSystem = view.findViewById(R.id.cbInputSystem); + final CheckBox cbInputBody = view.findViewById(R.id.cbInputBody); + final CheckBox cbInputReply = view.findViewById(R.id.cbInputReply); + final ContentLoadingProgressBar pbWait = view.findViewById(R.id.pbWait); + + ArrayAdapter> adapter = new ArrayAdapter>(context, android.R.layout.simple_spinner_item, android.R.id.text1) { + @NonNull + @Override + public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + return _getView(position, super.getView(position, convertView, parent)); + } + + @Override + public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { + return _getView(position, super.getDropDownView(position, convertView, parent)); + } + + private View _getView(int position, View view) { + Pair prompt = getItem(position); + TextView tv = view.findViewById(android.R.id.text1); + tv.setText(prompt == null ? null : prompt.first); + return view; + } + + }; + adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + spPrompt.setAdapter(adapter); + + spPrompt.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View view, int position, long id) { + Object tag = spPrompt.getTag(); + if (tag != null && tag.equals(position)) + return; + spPrompt.setTag(position); + Pair selected = adapter.getItem(position); + etPrompt.setText(selected == null ? null : selected.second); + } + + @Override + public void onNothingSelected(AdapterView parent) { + etPrompt.setText(null); + } + }); + + cbInputSystem.setChecked(has_system); + cbInputBody.setChecked(has_body); + + if (BuildConfig.DEBUG) { + cbInputSystem.setEnabled(has_system); + cbInputBody.setEnabled(has_body); + cbInputReply.setEnabled(has_reply); + } else { + cbInputSystem.setVisibility(has_system ? View.VISIBLE : View.GONE); + cbInputBody.setVisibility(has_body ? View.VISIBLE : View.GONE); + cbInputReply.setVisibility(has_reply ? View.VISIBLE : View.GONE); + } + + new SimpleTask>>() { + @Override + protected void onPreExecute(Bundle args) { + spPrompt.setEnabled(false); + etPrompt.setEnabled(false); + pbWait.setVisibility(View.VISIBLE); + } + + @Override + protected void onPostExecute(Bundle args) { + spPrompt.setEnabled(true); + etPrompt.setEnabled(true); + pbWait.setVisibility(View.GONE); + } + + @Override + protected List> onExecute(Context context, Bundle args) throws Throwable { + DB db = DB.getInstance(context); + + List> prompts = new ArrayList<>(); + List answers = db.answer().getAiPrompts(); + if (answers != null) + for (EntityAnswer answer : answers) { + String html = answer.getData(context, null).getHtml(); + prompts.add(new Pair<>(answer.name, JsoupEx.parse(html).body().text())); + } + + return prompts; + } + + @Override + protected void onExecuted(Bundle args, List> prompts) { + prompts.add(0, new Pair<>(context.getString(R.string.title_advanced_default_prompt), AI.getDefaultPrompt(getContext()))); + adapter.addAll(prompts); + + if (savedInstanceState != null) { + int prompt = savedInstanceState.getInt("fair:prompt"); + spPrompt.setTag(prompt); + spPrompt.setSelection(prompt); + } + } + + @Override + protected void onException(Bundle args, Throwable ex) { + Log.unexpectedError(getParentFragmentManager(), ex); + } + + }.execute(this, new Bundle(), "ai:prompts"); + + AlertDialog.Builder builder = new AlertDialog.Builder(context) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + Bundle args = getArguments(); + args.putString("prompt", etPrompt.getText().toString()); + args.putBoolean("input_system", has_system && cbInputSystem.isChecked()); + args.putBoolean("input_body", has_body && cbInputBody.isChecked()); + args.putBoolean("input_reply", has_reply && cbInputReply.isChecked()); + sendResult(RESULT_OK); + } + }); + + return builder.create(); + } + + @Override + public void onSaveInstanceState(@NonNull Bundle outState) { + outState.putInt("fair:prompt", spPrompt.getSelectedItemPosition()); + super.onSaveInstanceState(outState); + } +} diff --git a/app/src/main/res/layout/dialog_ai.xml b/app/src/main/res/layout/dialog_ai.xml new file mode 100644 index 0000000000..416a855faf --- /dev/null +++ b/app/src/main/res/layout/dialog_ai.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a04de61882..584607ce43 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -919,6 +919,10 @@ Answer prompt System instructions Default prompt + AI + Input system prompt + Input the text of your message + Input the text of the replied message Entered text I want to use an SD card Periodically check if FairEmail is still active