diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index d5f452355..81ef303ff 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -521,6 +521,11 @@ class ClipboardHistoryManager( fun getClipboardSuggestionView(editorInfo: EditorInfo?, parent: ViewGroup?): View? { clipboardSuggestionView = null + // Keyboard (and its icons set) may not be initialised yet (e.g. right after app start), + // and both the clipboard-content and screenshot views dereference it. Bail early to avoid + // an NPE instead of showing the suggestion. + if (latinIME.mKeyboardSwitcher.keyboard?.mIconsSet == null) return null + // check for screenshot first if enabled if (latinIME.mSettings.current.mSuggestScreenshots) { val screenshotView = getScreenshotSuggestionView(parent) @@ -583,6 +588,9 @@ class ClipboardHistoryManager( private fun getScreenshotSuggestionView(parent: ViewGroup?): View? { if (parent == null || dontShowCurrentSuggestion) return null + // Keyboard may not be initialised yet (e.g. right after app start); the view's close/paste + // icons come from its icons set, so bail out early rather than NPE. + if (latinIME.mKeyboardSwitcher.keyboard?.mIconsSet == null) return null val permission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { android.Manifest.permission.READ_MEDIA_IMAGES diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java index 691539c85..06a90bba8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java @@ -170,4 +170,30 @@ default Map getAllMainDictionaryWordsWithFrequency() { default void forEachMainDictionaryWord(java.util.function.BiConsumer consumer) { } + + /** + * Registers a callback that should be run when the AI next-word dictionary has produced new + * LLM candidates in the background. The implementer should trigger a suggestion-strip refresh + * (e.g. LatinIME handler {@code postUpdateSuggestionStrip}) so the freshly cached candidates + * surface without an extra keystroke. Default no-op; only next-word-capable locales override. + */ + default void setNextWordRefreshListener(@Nullable Runnable listener) { + } + + /** + * Supplies the latest bounded text before the cursor, captured by the IME just before a + * next-word suggestion fetch. Used by the AI next-word dictionary to build a richer prompt + * (the actual sentence/message context) than the last few n-gram words alone. Default no-op. + */ + default void setAINextWordContextText(@Nullable String text) { + } + + /** + * Supplies per-app metadata for the AI next-word dictionary: the current app package name + * (context buffer key), the app's preferred keyboard language code (e.g. "en", "pl"), whether + * the current field is private/incognito (noLearning), and the persisted per-app context text. + */ + default void setAINextWordAppInfo(@Nullable String packageName, @Nullable String language, + boolean noLearning, @Nullable String persistedContext) { + } } diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 379ec0183..713144cd2 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -21,6 +21,7 @@ import helium314.keyboard.latin.common.StringUtils import helium314.keyboard.latin.common.decapitalize import helium314.keyboard.latin.common.mightBeEmoji import helium314.keyboard.latin.common.splitOnWhitespace +import helium314.keyboard.latin.dictionary.AINextWordDictionary import helium314.keyboard.latin.dictionary.AppsBinaryDictionary import helium314.keyboard.latin.dictionary.ContactsBinaryDictionary import helium314.keyboard.latin.dictionary.Dictionary @@ -33,6 +34,7 @@ import helium314.keyboard.latin.personalization.SessionWordBoost import helium314.keyboard.latin.personalization.UserHistoryDictionary import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.settings.SettingsValuesForSuggestion +import helium314.keyboard.latin.utils.AINextWordEngineFactory import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SubtypeSettings import helium314.keyboard.latin.utils.SuggestionResults @@ -66,6 +68,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var mContext: Context? = null private var mEnabledDictionariesState: Map = emptyMap() private var mLoadedDownloadPrefs: Map = emptyMap() + private var mLoadedAINextWord: Boolean? = null private var dictionaryGroups = listOf(DictionaryGroup()) @Volatile @@ -99,6 +102,14 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // Limit parallelism to prevent excessive CPU usage during dictionary operations private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(2)) + // Optional AI next-word dictionary (LeanType). Null when the feature is off or the engine + // cannot be built (factory gates on the pref / model availability). + private var aiNextWordDict: AINextWordDictionary? = null + + // Runnable that LatinIME registers so we can ask it to redraw the suggestion strip once the + // AI next-word dictionary has cached fresh candidates (see setNextWordRefreshListener). + private var nextWordRefreshListener: (() -> Unit)? = null + override fun setValidSpellingWordReadCache(cache: LruCache) { mValidSpellingWordReadCache = cache } @@ -151,6 +162,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { if (currentPrefs != mEnabledDictionariesState || currentDownloadPrefs != mLoadedDownloadPrefs) { return false } + val aiNextWord = prefs.getBoolean(Settings.PREF_AI_NEXT_WORD, false) + if (aiNextWord != mLoadedAINextWord) { + return false + } } val ctx = mContext ?: return false val currentSuggestEmojis = Settings.getValues().mSuggestEmojis @@ -186,6 +201,9 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { .mapValues { it.value as? Boolean ?: true } mLoadedDownloadPrefs = prefs.all.filterKeys { it.startsWith("pref_dict_download_link_") } + // Track the AI next-word pref so the dictionary group is rebuilt when it is toggled. + mLoadedAINextWord = prefs.getBoolean(Settings.PREF_AI_NEXT_WORD, false) + // Initialize session word boost with context if not yet done if (sessionWordBoost == null) { sessionWordBoost = SessionWordBoost.getInstance(context) @@ -228,6 +246,42 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { mValidSpellingWordWriteCache?.evictAll() mValidSpellingWordReadCache?.evictAll() + + // AI next-word dictionary: rebuilt on every dictionary reset. Factory returns null when + // the feature is disabled or the engine cannot be built, making this a no-op by default. + aiNextWordDict = AINextWordEngineFactory.create(context)?.let { + AINextWordDictionary(it, scope).also { dict -> + // Fresh LLM candidates cached -> ask LatinIME to redraw the suggestion strip so + // they surface without another keystroke. + dict.onCandidatesReady = { nextWordRefreshListener?.invoke() } + } + } + Log.i(TAG, "resetDictionaries: AINextWord dict ${if (aiNextWordDict == null) "NOT created" else "created"} (pref=${prefs.getBoolean(Settings.PREF_AI_NEXT_WORD, false)})") + } + + override fun setNextWordRefreshListener(listener: Runnable?) { + nextWordRefreshListener = listener?.let { { it.run() } } + } + + @Volatile + private var aiNextWordContextText: String? = null + + override fun setAINextWordContextText(text: String?) { + aiNextWordContextText = text + } + + @Volatile private var aiNextWordAppPackage: String? = null + @Volatile private var aiNextWordAppLanguage: String? = null + @Volatile private var aiNextWordNoLearning: Boolean = false + @Volatile private var aiNextWordPersistedContext: String? = null + + override fun setAINextWordAppInfo( + packageName: String?, language: String?, noLearning: Boolean, persistedContext: String? + ) { + aiNextWordAppPackage = packageName + aiNextWordAppLanguage = language + aiNextWordNoLearning = noLearning + aiNextWordPersistedContext = persistedContext } /** creates dictionaryGroups for [newLocales] with given [newSubDictTypes], trying to re-use existing dictionaries. @@ -714,6 +768,42 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } } + // AI next-word candidates, only in prediction/next-word mode. The dictionary returns + // null (or filtered-away empties) unless it has cached LLM candidates, so it never + // blocks this pass. AOSP suggestions always come first (added above). + if (composedData.mTypedWord.isEmpty()) { + aiNextWordDict?.let { dict -> + dict.prevTextForPrompt = aiNextWordContextText + dict.appPackageName = aiNextWordAppPackage + dict.appLanguageHint = aiNextWordAppLanguage + dict.noLearning = aiNextWordNoLearning + dict.appContext = if (aiNextWordNoLearning) null else aiNextWordPersistedContext + val aiInfos = dict.getSuggestions( + composedData, ngramContext, proximityInfoHandle, settingsValuesForSuggestion, + sessionId, weightForLocale, weightOfLangModelVsSpatialModel + )?.filter { it.word.isNotEmpty() } + if (!aiInfos.isNullOrEmpty()) { + // Rank AI words BELOW every AOSP suggestion in this list. AOSP next-word scores + // vary wildly (history ~700-800, user dict can be millions), so a fixed base is + // unreliable — instead key off the actual AOSP scores present so AI words ALWAYS + // append as extra choices and can never displace or reorder the AOSP suggestions + // the user is about to tap. mScore is final, so rebuild the infos with lower + // (strictly decreasing, hence unique within the TreeSet) scores. + val floor = ((suggestions.minOfOrNull { it.mScore } ?: AI_NEXT_WORD_BASE_SCORE) - 1) + aiInfos.forEachIndexed { i, info -> + val score = floor - i + suggestions.add( + if (info.mScore == score) info + else SuggestedWordInfo( + info.mWord, info.mPrevWordsContext, score, info.mKindAndFlags, + info.mSourceDict, info.mIndexOfTouchPointOfSecondWord, + info.mAutoCommitFirstWordConfidence + ) + ) + } + } + } + } return suggestions } @@ -812,6 +902,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { companion object { private val TAG = DictionaryFacilitatorImpl::class.java.simpleName + // Fallback base when there are no AOSP suggestions to rank AI words below. + private const val AI_NEXT_WORD_BASE_SCORE = 1000 // HACK: This threshold is being used when adding a capitalized entry in the User History dictionary. private const val CAPITALIZED_FORM_MAX_PROBABILITY_FOR_INSERT = 140 diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 89cad3b4d..ae8a05d62 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -586,6 +586,17 @@ public void onCreate() { mClipboardHistoryManager.onCreate(); mHandler.onCreate(); + // When the AI next-word dictionary caches fresh candidates, ask the suggestion strip to + // redraw so they surface without another keystroke (see AINextWordDictionary.onCandidatesReady). + // Also evict the next-word suggestions cache first: it may hold the AOSP-only list built + // before the async AI response landed, so getNextWordSuggestions() would otherwise return + // that stale list and the fresh AI words would never reach the strip. + mDictionaryFacilitator.setNextWordRefreshListener( + () -> { + mInputLogic.getSuggest().clearNextWordSuggestionsCache(); + mHandler.postUpdateSuggestionStrip(SuggestedWords.INPUT_STYLE_TYPING); + }); + // Register to receive ringer mode change. final IntentFilter filter = new IntentFilter(); filter.addAction(AudioManager.RINGER_MODE_CHANGED_ACTION); @@ -1206,6 +1217,23 @@ void onFinishInputInternal() { super.onFinishInput(); Log.i(TAG, "onFinishInput"); + // Persist per-app context for the AI next-word feature. NEVER records in private/incognito + // or anonymous fields (noLearning / no-suggestions), covering password and incognito input. + try { + final InputAttributes attrs = mSettings.getCurrent().mInputAttributes; + if (attrs.mShouldShowSuggestions && !attrs.mNoLearning) { + final String pkg = attrs.mTargetApplicationPackageName; + if (pkg != null) { + final android.view.inputmethod.InputConnection ic = getCurrentInputConnection(); + if (ic != null) { + final CharSequence text = ic.getTextBeforeCursor(200, 0); + mSettings.appendAppContext(pkg, text); + } + } + } + } catch (Exception ignored) { + } + mDictionaryFacilitator.onFinishInput(); final MainKeyboardView mainKeyboardView = mKeyboardSwitcher.getMainKeyboardView(); if (mainKeyboardView != null) { diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt b/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt new file mode 100644 index 000000000..4777845dc --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/AINextWordDictionary.kt @@ -0,0 +1,229 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.dictionary + +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.NgramContext +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.common.ComposedData +import helium314.keyboard.latin.settings.SettingsValuesForSuggestion +import helium314.keyboard.latin.utils.AINextWordEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val TAG = "AINextWord" + +/** + * A [Dictionary] that adds LLM-driven next-word candidates supplied by an [AINextWordEngine]. + * + * - Only produces suggestions in prediction / next-word mode, i.e. when `composedData.mTypedWord` + * is empty. In completion mode it returns null and never interferes with the main dictionaries. + * - Never blocks the suggestion thread: getSuggestions() returns cached results synchronously or + * null, and kicks off an async fetch on its own [CoroutineScope]. + * - When the engine is not ready (pref off / model not loaded) it behaves as an empty dictionary. + * + * This class is pure JVM (no Android runtime types) so it can be unit-tested without instrumented + * APIs; the result cache uses a plain LRU [LinkedHashMap] guarded by a monitor. + */ +class AINextWordDictionary( + private val engine: AINextWordEngine, + private val scope: CoroutineScope +) : Dictionary(Dictionary.TYPE_AI_NEXT_WORD, null) { + + /** + * Invoked on the [scope] dispatcher after the async fetch has filled the cache with new + * LLM candidates. The suggestion strip must re-run getSuggestions() for these to surface, + * so whoever owns this dictionary should wire this to a strip refresh (postUpdateSuggestionStrip). + */ + @Volatile + var onCandidatesReady: (() -> Unit)? = null + + /** + * Latest bounded text before the cursor (set by the IME before each next-word fetch via the + * DictionaryFacilitator). Gives the model the actual sentence/message context instead of only + * the last few n-gram words. Read synchronously in getSuggestions() to build the prompt. + */ + @Volatile + var prevTextForPrompt: String? = null + + /** Current app package name — key for the persisted per-app context buffer. */ + @Volatile + var appPackageName: String? = null + + /** The app's preferred keyboard language code (e.g. "en", "pl") to keep the model consistent. */ + @Volatile + var appLanguageHint: String? = null + + /** True when the current field is private/incognito — suppresses per-app context use. */ + @Volatile + var noLearning: Boolean = false + + /** Persisted recent text from this app (survives restarts); null when private. */ + @Volatile + var appContext: String? = null + + // accessOrder = true -> most-recently-used entries end up at the tail, so the head is LRU. + private val cache = LinkedHashMap>(32, 0.75f, true) + + override fun getSuggestions( + composedData: ComposedData, + ngramContext: NgramContext, + proximityInfoHandle: Long, + settingsValuesForSuggestion: SettingsValuesForSuggestion, + sessionId: Int, + weightForLocale: Float, + inOutWeightOfLangModelVsSpatialModel: FloatArray + ): ArrayList? { + // Only add AI candidates in next-word mode and when the engine is actually usable. + if (composedData.mTypedWord.isNotEmpty() || !engine.isReady()) { + Log.d(TAG, "getSuggestions SKIP typedWord='${composedData.mTypedWord}' engineReady=${engine.isReady()}") + return null + } + + // Prefer the real text-before-cursor (whole sentence/message context) when available; + // fall back to the local n-gram window otherwise. Prepend any persisted per-app context + // when present and the field is not private. + val base = prevTextForPrompt?.takeIf { it.isNotBlank() } + ?: ngramContext.extractPrevWordsContext() + val fullContext = if (!noLearning && !appContext.isNullOrBlank()) "${appContext!!.trim()} $base" else base + // If the text before the cursor already ends a sentence (".", "!", "?"), the next word + // starts a brand-new sentence -> capitalise every AI continuation so it displays and + // inserts correctly at the start of a sentence. + val capitaliseNext = endsSentence(fullContext) + val prompt = buildPrompt(fullContext, appLanguageHint) + getCached(prompt)?.let { + Log.d(TAG, "getSuggestions CACHE-HIT prompt='$prompt' -> ${it.size} words") + return it + } + Log.d(TAG, "getSuggestions CACHE-MISS, launching async fetch prompt='$prompt'") + + // Tokenise the context so we can drop candidate repeats (issue 2): never suggest a word + // that already appears in the current sentence/context (e.g. type "dog" -> don't re-suggest "dog"). + val contextWordSet = wordTokenSet(fullContext) + + // Trigger an async fetch; we return nothing to this pass so the suggestion thread is never + // blocked by network / on-device inference. + scope.launch { + val candidates = try { + engine.suggestNextWords(prompt) + } catch (e: Exception) { + Log.e(TAG, "suggestNextWords threw", e) + emptyList() + } + val parsed = parseCandidates(candidates.joinToString(" ")) + .filterNot { contextWordSet.contains(it.lowercase()) } + Log.d(TAG, "async fetch done: engineCandidates=$candidates parsed=$parsed (repeats filtered from context words)") + // Capitalise each continuation at the start of a sentence (after ".", "!", "?"). + val toShow = if (capitaliseNext) + parsed.map { it.replaceFirstChar { c -> c.uppercase() } } + else parsed + if (toShow.isNotEmpty()) { + putCached(prompt, wrapSuggestions(toShow, ngramContext.extractPrevWordsContext())) + // Notify the owner that fresh candidates are available so the suggestion strip + // can re-run getSuggestions() and surface them without another keystroke. + onCandidatesReady?.invoke() + } + } + return null + } + + override fun isInDictionary(word: String): Boolean = false + + override fun isInitialized(): Boolean = engine.isReady() + + private fun getCached(prompt: String): ArrayList? = + synchronized(cache) { cache[prompt] } + + private fun putCached(prompt: String, value: ArrayList) { + synchronized(cache) { + cache[prompt] = value + while (cache.size > MAX_CACHED_PROMPTS) { + val eldest = cache.keys.firstOrNull() ?: break + cache.remove(eldest) + } + } + } + + private fun wrapSuggestions(words: List, prevContext: String): ArrayList = + ArrayList(words.size).apply { + words.forEachIndexed { i, word -> + add( + SuggestedWordInfo( + word, + prevContext, + BASE_SCORE + i, + SuggestedWordInfo.KIND_PREDICTION, + this@AINextWordDictionary, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + ) + } + } + + companion object { + private const val MAX_CACHED_PROMPTS = 64 + // Base score for AI next-word candidates. Kept modest so AI words offer EXTRA choices that + // rank BELOW good AOSP next-word suggestions, instead of barging to the front and displacing + // the suggestion the user is about to tap. (SuggestionResults is a TreeSet sorted by score.) + private const val BASE_SCORE = 1000 + } +} + +/** + * True when [text] already ends with an end-of-sentence mark (".", "!", "?") after trimming — + * i.e. the very next word starts a new sentence and should be capitalised. Pure function so it can + * be unit-tested without Android. + */ +internal fun endsSentence(text: String): Boolean = + text.trim().let { it.isNotEmpty() && it.last() in ".!?" } + +/** + * Builds the plain-text prompt handed to the LLM from the text before the cursor (or the flat + * previous-words context from [NgramContext.extractPrevWordsContext] as a fallback). Pure + * function so it can be unit-tested without Android. Deliberately directive so a small instruct + * model replies with ONE natural continuation word rather than a whole sentence. + */ +internal fun buildPrompt(context: String, language: String? = null): String { + val trimmed = context.trim() + // Keep the model consistent with the app's keyboard language, but only when it's a real + // language hint (not the default "en"). + val langLine = if (!language.isNullOrBlank() && !language.equals("en", ignoreCase = true)) " Continue in $language." else "" + if (trimmed.isEmpty() || trimmed == BEGINNING_OF_SENTENCE_TAG) { + return "I am starting a new message. Output only a single likely next word.$langLine" + } + return "Continue this text with exactly one natural next word, outputting only that one word:$langLine\n\"$trimmed\"\nNext word:" +} + +/** + * Splits raw LLM output into candidate words: tokenises, trims, drops empties/duplicates and + * caps the list at [MAX_CANDIDATES]. Pure function so it can be unit-tested without Android. + */ +internal fun parseCandidates(raw: String): List { + if (raw.isBlank()) return emptyList() + val result = LinkedHashSet() + // Split on whitespace and common punctuation bound; keep letters/apostrophes/hyphens. + for (token in raw.split(Regex("[\\s,;:!?\\.]+"))) { + val word = token.trim().trim('\'', '"', '(', ')', '[', ']') + if (word.isEmpty()) continue + if (word.any { it.isLetter() }) result.add(word) + if (result.size >= MAX_CANDIDATES) break + } + return result.toList() +} + +private const val BEGINNING_OF_SENTENCE_TAG = "" +private const val MAX_CANDIDATES = 3 + +/** Tokenises [text] into its lowercase word set (letters/digits/apostrophes), used to drop + * candidate repeats that already appear in the context. Pure function for unit testing. */ +internal fun wordTokenSet(text: String): Set { + val out = HashSet() + for (token in text.split(Regex("[^\\p{L}\\p{N}']+"))) { + val w = token.trim().lowercase() + if (w.isNotEmpty()) out.add(w) + } + return out +} diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java b/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java index 3af1b590d..3d95293b8 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/Dictionary.java @@ -55,6 +55,9 @@ public abstract class Dictionary { public static final String TYPE_USER = "user"; // User history dictionary internal to LatinIME. public static final String TYPE_USER_HISTORY = "history"; + // AI next-word prediction dictionary (LeanType). Only contributes suggestions in + // prediction/next-word mode and is a no-op when the feature is disabled. + public static final String TYPE_AI_NEXT_WORD = "ai_next_word"; public static final String TYPE_EMOJI = "emoji"; public final String mDictType; // The locale for this dictionary. May be null if unknown (phony dictionary for example). diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 50f2041cd..ca844d6e2 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -51,6 +51,7 @@ import helium314.keyboard.latin.common.StringUtilsKt; import helium314.keyboard.latin.common.SuggestionSpanUtilsKt; import helium314.keyboard.latin.define.DebugFlags; +import helium314.keyboard.latin.InputAttributes; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.settings.SpacingAndPunctuations; @@ -3367,6 +3368,28 @@ && isInlineEmojiSearchAction()) { mWordComposer.adviseCapitalizedModeBeforeFetchingSuggestions( getActualCapsMode(settingsValues, KeyboardSwitcher.getInstance().getKeyboardShiftMode())); try { + // Snapshot the current text before the cursor so the AI next-word dictionary can build + // its prompt from real sentence/message context (bounded) rather than only the last few + // n-gram words. ~1000 chars covers the current sentence + a buffer on any practical edit. + final CharSequence aiContext = mConnection.getTextBeforeCursor(1000, 0); + mDictionaryFacilitator.setAINextWordContextText( + aiContext == null ? null : aiContext.toString()); + // Per-app metadata for the AI dict: package (context key), language, private gate, and + // the persisted per-app context buffer. Skip enrichment entirely on private fields. + final InputAttributes attrs = settingsValues.mInputAttributes; + final String pkg = attrs.mTargetApplicationPackageName; + final boolean noLearning = attrs.mNoLearning || !attrs.mShouldShowSuggestions; + String language = null; + String persistedContext = null; + if (pkg != null && !noLearning) { + try { + final Locale loc = mDictionaryFacilitator.getMainLocale(); + if (loc != null) language = loc.getLanguage(); + persistedContext = Settings.getInstance().getAppContext(pkg); + } catch (Exception ignored) { + } + } + mDictionaryFacilitator.setAINextWordAppInfo(pkg, language, noLearning, persistedContext); final SuggestedWords suggestedWords = mSuggest.getSuggestedWords(mWordComposer, getNgramContextFromNthPreviousWordForSuggestion( settingsValues.mSpacingAndPunctuations, diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 54a21a39f..26a5d2e3f 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -198,6 +198,7 @@ object Defaults { const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French" const val PREF_OFFLINE_KEEP_MODEL_LOADED = false const val PREF_AI_ALLOW_INSECURE_CONNECTIONS = false + const val PREF_AI_NEXT_WORD = false const val PREF_ENABLE_CLIPBOARD_HISTORY = true const val PREF_CLIPBOARD_HISTORY_RETENTION_TIME = 15 // minutes const val PREF_CLIPBOARD_HISTORY_PINNED_FIRST = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 092b5399e..292cf55c2 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -195,6 +195,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_OFFLINE_MAX_TOKENS = "offline_max_tokens"; public static final String PREF_OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded"; public static final String PREF_AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections"; + public static final String PREF_AI_NEXT_WORD = "pref_ai_next_word"; public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; @@ -691,6 +692,32 @@ public RichInputMethodSubtype getSubtypeForApp(String packageName) { return subtype != null ? RichInputMethodSubtype.Companion.get(subtype) : null; } + private static final String PREF_APP_CONTEXT_PREFIX = "ai_app_context_"; + private static final int MAX_APP_CONTEXT_SEGMENT_CHARS = 200; + private static final int MAX_APP_CONTEXT_TOTAL_CHARS = 600; + + /** Appends [text] (e.g. the last field the user left in [packageName]) to that app's AI + * next-word context buffer. Persisted to prefs so it survives process and device restarts. + * Callers MUST gate this on the field not being private (noLearning). */ + public void appendAppContext(String packageName, CharSequence text) { + if (packageName == null || text == null) return; + String segment = text.toString().trim(); + if (segment.length() < 4) return; // too trivial to be useful context + if (segment.length() > MAX_APP_CONTEXT_SEGMENT_CHARS) + segment = segment.substring(segment.length() - MAX_APP_CONTEXT_SEGMENT_CHARS); + String prev = mPrefs.getString(PREF_APP_CONTEXT_PREFIX + packageName, ""); + String combined = prev.isEmpty() ? segment : prev + " " + segment; + if (combined.length() > MAX_APP_CONTEXT_TOTAL_CHARS) + combined = combined.substring(combined.length() - MAX_APP_CONTEXT_TOTAL_CHARS); + mPrefs.edit().putString(PREF_APP_CONTEXT_PREFIX + packageName, combined).apply(); + } + + /** Returns the persisted AI next-word context buffer for [packageName], or empty string. */ + public String getAppContext(String packageName) { + if (packageName == null) return ""; + return mPrefs.getString(PREF_APP_CONTEXT_PREFIX + packageName, ""); + } + private boolean isSubtypePerApp() { return mPrefs.getBoolean(PREF_SAVE_SUBTYPE_PER_APP, Defaults.PREF_SAVE_SUBTYPE_PER_APP); } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt b/app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt new file mode 100644 index 000000000..86e2453d7 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/AINextWordEngine.kt @@ -0,0 +1,23 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +/** + * Provider-agnostic contract for the AI next-word suggestion source. + * + * Implementations live per-flavor (standard = cloud, offline = on-device GGUF, + * offlinelite = stub) via [AINextWordEngineFactory]. + */ +interface AINextWordEngine { + /** Whether the engine is ready to produce candidate words right now. */ + fun isReady(): Boolean + + /** + * Produces the next-word continuation for the given plain-text prompt. + * Called from the AI next-word dictionary's own coroutine scope, never from + * the suggestion thread. Should return an empty list on any failure. + */ + suspend fun suggestNextWords(prompt: String): List +} diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index d91e8127e..359860b05 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -176,6 +176,7 @@ object SettingsWithoutKey { const val CUSTOM_AI_KEYS = "custom_ai_keys" const val OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded" const val AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections" + const val AI_NEXT_WORD = "pref_ai_next_word" const val TRANSLATION_ENGINE = "pref_translation_method" const val BACKGROUND_SERVICES = "background_services" diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index 7f30ac2cd..0f21077fc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -59,6 +59,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { // Always show provider selection add(SettingsWithoutKey.AI_PROVIDER) add(SettingsWithoutKey.TRANSLATION_ENGINE) + add(SettingsWithoutKey.AI_NEXT_WORD) // Custom AI Keys are only shown in the standard flavor (guaranteed by caller) add(SettingsWithoutKey.CUSTOM_AI_KEYS) @@ -99,7 +100,8 @@ private fun OfflineAIIntegrationScreen(onClickBack: () -> Unit) { val items = listOf( SettingsWithoutKey.CUSTOM_AI_KEYS, SettingsWithoutKey.OFFLINE_MODEL_PATH, - SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED + SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED, + SettingsWithoutKey.AI_NEXT_WORD ) SearchSettingsScreen( diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 4b0213996..8ea0f30c3 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -482,6 +482,9 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( Setting(context, SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS, R.string.ai_allow_insecure_connections_title, R.string.ai_allow_insecure_connections_summary) { setting -> SwitchPreference(setting, Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS) }, + Setting(context, SettingsWithoutKey.AI_NEXT_WORD, R.string.ai_next_word_title, R.string.ai_next_word_summary) { setting -> + SwitchPreference(setting, Defaults.PREF_AI_NEXT_WORD) + }, Setting(context, SettingsWithoutKey.TRANSLATION_ENGINE, R.string.translation_engine_title, R.string.translation_engine_summary) { setting -> ListPreference( setting = setting, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22c958700..39b6b51f6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -543,6 +543,8 @@ Insecure HTTP connection blocked. Enable \'Allow Insecure Connections\' in AI settings. Allow insecure connections Allow HTTP connections and ignore SSL certificate errors. Warning: exposes input to local network eavesdropping. + AI next-word suggestions + Add AI-generated next-word candidates after an online or on-device model is configured. Default off; AOSP suggestions always come first. Layouts Theme & Custom Backgrounds Dictionaries & Typing History diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt new file mode 100644 index 000000000..84a1ec169 --- /dev/null +++ b/app/src/offline/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -0,0 +1,175 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.flow.takeWhile +import org.nehuatl.llamacpp.LlamaHelper +import helium314.keyboard.latin.settings.Defaults +import helium314.keyboard.latin.settings.Settings + +private const val TAG = "AINextWord" + +/** + * Offline-flavor AI next-word engine factory. Produces causal next-word continuations using the + * same on-device GGUF LlamaHelper runtime that [ProofreadService.ModelHolder] manages for + * proofreading, so a model already loaded by the proofread UI is reused — no network. + */ +object AINextWordEngineFactory { + + fun create(context: Context): AINextWordEngine? { + if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { + return null + } + // Engine is created whenever the pref is on. Readiness is gated dynamically via + // isReady() (which reflects ModelHolder.isModelLoaded), so the AI dictionary exists from + // the toggle and becomes live the moment a GGUF model is loaded for proofreading — no + // dependency on model being loaded at dict-build time. + return OfflineNextWordEngine(context.applicationContext) + } +} + +private class OfflineNextWordEngine(private val context: Context) : AINextWordEngine { + + // Guards against launching two llama completions on the same native context at once (which + // crashes the process). Only one next-word completion runs at a time; concurrent ones are + // dropped, not queued. + private val completionInFlight = java.util.concurrent.atomic.AtomicBoolean(false) + + override fun isReady(): Boolean = ProofreadService.ModelHolder.isModelLoaded + + override suspend fun suggestNextWords(prompt: String): List = withContext(Dispatchers.IO) { + Log.i(TAG, "AINextWord: suggestNextWords prompt='$prompt' modelLoaded=${ProofreadService.ModelHolder.isModelLoaded} path=${ProofreadService.ModelHolder.currentModelPath}") + // Serialize completions: two launchCompletion calls on the SAME native LlamaHelper context + // concurrently crash the process. If a previous next-word completion is still running, drop + // this request instead of colliding with it. + if (!completionInFlight.compareAndSet(false, true)) { + Log.w(TAG, "AINextWord: previous completion still running, dropping request") + return@withContext emptyList() + } + try { + val helper = ProofreadService.ModelHolder.llamaHelper ?: run { + Log.w(TAG, "AINextWord: llamaHelper is null, returning empty") + return@withContext emptyList() + } + val result = try { + val completionText = completeWithParams(helper, prompt) + val words = splitCandidates(completionText) + Log.i(TAG, "AINextWord: completion='$completionText' -> candidates=$words") + words + } catch (e: Exception) { + Log.e(TAG, "AINextWord: completion failed", e) + emptyList() + } + Log.i(TAG, "AINextWord: returning ${result.size} candidates") + // Keep the model warm/per policy just like proofreading does. + ProofreadService.ModelHolder.scheduleUnload(context) + result + } finally { + completionInFlight.set(false) + } + } + + /** Mirrors ProofreadService.predictWithParams + flow collection for proofreading. */ + private suspend fun completeWithParams(helper: LlamaHelper, prompt: String): String { + val currentContextField = + LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + val currentContext = currentContextField.get(helper) as? Int ?: return "" + + val llamaField = + LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + // Initialise the native wrapper on THIS (calling) thread, mirroring ProofreadService + // predictWithParams. Touching .value lazily on a worker coroutine thread can mis-init + // the native llama context and crash. + val llama = (llamaField.get(helper) as Lazy).value + + val tokenCountField = + LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + tokenCountField.set(helper, 0) + val allTextField = + LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + allTextField.set(helper, "") + + val params = mutableMapOf( + "prompt" to prompt, + "emit_partial_completion" to true, + "temperature" to 0.2, + "top_p" to 0.9, + "top_k" to 40, + "min_p" to 0.05, + "n_predict" to 16, + "stop" to listOf("\n", ". ", ".") + ) + + // Emit Started so the collected flow carries the terminal event (mirrors + // ProofreadService.predictWithParams, which emits Started before launching). + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Started(prompt)) + + val completionJobField = + LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + val job = helper.scope.launch { + val startTime = System.currentTimeMillis() + try { + // Serialize against every other native completion (proofreading + next-word + // share this one LlamaHelper context). Concurrent generation on the same + // context corrupts the native heap / null-derefs inside doCompletion. + synchronized(ProofreadService.ModelHolder.completionLock) { + llama.launchCompletion(currentContext, params) + } + } catch (e: Throwable) { + helper.sharedFlow.tryEmit( + LlamaHelper.LLMEvent.Error("Next word completion failed: ${e.message}") + ) + return@launch + } + val allText = allTextField.get(helper) as String + val tokenCount = tokenCountField.get(helper) as Int + helper.sharedFlow.tryEmit( + LlamaHelper.LLMEvent.Done(allText, tokenCount, System.currentTimeMillis() - startTime) + ) + } + completionJobField.set(helper, job) + + val generated = StringBuilder() + val start = System.currentTimeMillis() + val finished = withTimeoutOrNull(NEXT_WORD_TIMEOUT_MS) { + ProofreadService.ModelHolder.llmFlow.takeWhile { event -> + when (event) { + is LlamaHelper.LLMEvent.Ongoing -> { + generated.append(event.word) + true + } + is LlamaHelper.LLMEvent.Done -> false + is LlamaHelper.LLMEvent.Error -> false + else -> true + } + }.collect { } + } != null + val elapsed = System.currentTimeMillis() - start + if (!finished) { + Log.w(TAG, "AINextWord: completion TIMED OUT after ${elapsed}ms (max $NEXT_WORD_TIMEOUT_MS), partial='$generated'") + } else { + Log.i(TAG, "AINextWord: completion finished in ${elapsed}ms, text='$generated'") + } + return generated.toString() + } + + private fun splitCandidates(raw: String): List { + val out = LinkedHashSet() + for (token in raw.split(Regex("[\\s,;:!?\\.]+"))) { + val word = token.trim().trim('\'', '"') + if (word.isNotEmpty() && word.any { it.isLetter() }) out.add(word) + if (out.size >= 3) break + } + return out.toList() + } +} + +private const val NEXT_WORD_TIMEOUT_MS = 30000L diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 286371d5c..5d04ca629 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -56,6 +56,14 @@ class ProofreadService(private val context: Context) { private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes private val loadMutex = Mutex() + // Guards ALL native llama completions (proofreading + AI next-word) so two can never + // run concurrently on the single shared LlamaHelper context, and unload can never run + // while a completion is mid-flight. llama.cpp is NOT thread-safe for simultaneous + // generation on the same context: concurrent access corrupts the native heap + // ("Scudo invalid chunk state" SIGABRT) and null-derefs (SIGSEGV) inside + // doCompletion — exactly the crashes seen on the AI next-word path. + val completionLock = Any() + // Flow for LLM events val llmFlow = MutableSharedFlow( extraBufferCapacity = 64, @@ -90,7 +98,10 @@ class ProofreadService(private val context: Context) { @Synchronized fun unloadModel() { try { - llamaHelper?.release() + // Don't release the native context while a completion is running on it. + synchronized(completionLock) { + llamaHelper?.release() + } } catch (e: Exception) { Log.w(TAG, "Error unloading llama model", e) } @@ -572,7 +583,11 @@ class ProofreadService(private val context: Context) { val job = helper.scope.launch { val startTime = System.currentTimeMillis() try { - llama.launchCompletion(currentContext, params) + // Serialize the native generation against every other completion + // (proofreading + next-word share this one context). + synchronized(ModelHolder.completionLock) { + llama.launchCompletion(currentContext, params) + } } catch (e: Throwable) { Log.e(TAG, "Completion failed", e) helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Completion failed: ${e.message}")) diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt new file mode 100644 index 000000000..4deaa07c6 --- /dev/null +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -0,0 +1,28 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +import android.content.Context +import helium314.keyboard.latin.settings.Defaults +import helium314.keyboard.latin.settings.Settings + +/** + * Offlinelite-flavor AI next-word engine factory. This flavor ships no AI, so the factory returns + * null. Kept with the same FQCN as the other flavors so the main source set compiles in all three. + */ +object AINextWordEngineFactory { + + fun create(context: Context): AINextWordEngine? { + if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { + return null + } + return NoopNextWordEngine + } +} + +private object NoopNextWordEngine : AINextWordEngine { + override fun isReady(): Boolean = true + override suspend fun suggestNextWords(prompt: String): List = emptyList() +} diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt b/app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt new file mode 100644 index 000000000..12d400b34 --- /dev/null +++ b/app/src/standard/java/helium314/keyboard/latin/utils/AINextWordEngineFactory.kt @@ -0,0 +1,111 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.utils + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import java.io.OutputStreamWriter +import java.net.HttpURLConnection +import java.net.URL +import helium314.keyboard.latin.settings.Defaults +import helium314.keyboard.latin.settings.Settings + +/** + * Standard-flavor AI next-word engine factory. Uses the OpenAI-compatible chat-completion HTTP + * shape already used by the standard proofreading service (endpoint / token / model come from + * the same proofreading configuration) to produce a short next-word continuation. No network + * access happens on the suggestion thread. + */ +object AINextWordEngineFactory { + + fun create(context: Context): AINextWordEngine? { + if (!context.prefs().getBoolean(Settings.PREF_AI_NEXT_WORD, Defaults.PREF_AI_NEXT_WORD)) { + return null + } + return StandardNextWordEngine(context.applicationContext) + } +} + +private class StandardNextWordEngine(private val context: Context) : AINextWordEngine { + + private val service: ProofreadService by lazy { ProofreadService(context) } + + override fun isReady(): Boolean { + val provider = service.getProvider() + return when (provider) { + ProofreadService.AIProvider.GROQ -> !service.getGroqToken().isNullOrBlank() && + !service.getGroqModel().isBlank() + ProofreadService.AIProvider.OPENAI -> !service.getHuggingFaceToken().isNullOrBlank() && + !service.getHuggingFaceModel().isBlank() + else -> false + } + } + + override suspend fun suggestNextWords(prompt: String): List = withContext(Dispatchers.IO) { + if (!isReady()) return@withContext emptyList() + val isGroq = service.getProvider() == ProofreadService.AIProvider.GROQ + val model = if (isGroq) service.getGroqModel() else service.getHuggingFaceModel() + val token = if (isGroq) service.getGroqToken() else service.getHuggingFaceToken() + val endpoint = service.getHuggingFaceEndpoint() + try { + val url = URL(endpoint) + val connection = url.openConnection() as HttpURLConnection + try { + connection.requestMethod = "POST" + connection.setRequestProperty("Content-Type", "application/json") + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("User-Agent", "LeanType/1.0") + connection.doOutput = true + connection.connectTimeout = 5000 + connection.readTimeout = 10000 + + val messages = JSONArray().put( + JSONObject().put("role", "user").put("content", prompt) + ) + val body = JSONObject() + .put("model", model) + .put("messages", messages) + .put("temperature", 0.2) + .put("max_tokens", 16) + OutputStreamWriter(connection.outputStream).use { it.write(body.toString()) } + + if (connection.responseCode != HttpURLConnection.HTTP_OK) { + return@withContext emptyList() + } + val content = parseContent(connection.inputStream.bufferedReader().use { it.readText() }) + splitCandidates(content) + } finally { + connection.disconnect() + } + } catch (e: Exception) { + emptyList() + } + } + + private fun parseContent(response: String): String { + return try { + val json = JSONObject(response) + val choices = json.optJSONArray("choices") + if (choices != null && choices.length() > 0) { + choices.getJSONObject(0).optJSONObject("message")?.optString("content", "") ?: "" + } else "" + } catch (e: Exception) { + "" + } + } + + private fun splitCandidates(raw: String): List { + val out = LinkedHashSet() + for (token in raw.split(Regex("[\\s,;:!?\\.]+"))) { + val word = token.trim().trim('\'', '"') + if (word.isNotEmpty() && word.any { it.isLetter() }) out.add(word) + if (out.size >= 3) break + } + return out.toList() + } +} diff --git a/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt b/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt new file mode 100644 index 000000000..ef4ae8546 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/dictionary/AINextWordDictionaryTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2026 LeanBitLab + * SPDX-License-Identifier: GPL-3.0-only + */ +package helium314.keyboard.latin.dictionary + +import android.text.TextUtils +import android.util.Log +import helium314.keyboard.latin.NgramContext +import helium314.keyboard.latin.common.ComposedData +import helium314.keyboard.latin.common.InputPointers +import helium314.keyboard.latin.settings.SettingsValuesForSuggestion +import helium314.keyboard.latin.utils.AINextWordEngine +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.Mockito.mockStatic +import org.mockito.junit.MockitoJUnitRunner +import java.util.concurrent.atomic.AtomicInteger + +@RunWith(MockitoJUnitRunner::class) +class AINextWordDictionaryTest { + + private fun fakeEngine(): AINextWordEngine = object : AINextWordEngine { + override fun isReady(): Boolean = true + override suspend fun suggestNextWords(prompt: String): List = + listOf("world", "friend") + } + + private fun newDictionary() = + AINextWordDictionary(fakeEngine(), CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + + // ---------- buildPrompt ---------- + + @Test + fun buildPrompt_emptyContext_hasGenericInstruction() { + assertTrue(buildPrompt("").isNotBlank()) + assertTrue(buildPrompt("").contains("word")) + assertTrue(buildPrompt("").contains("word")) + } + + @Test + fun buildPrompt_withWords_isDirectiveSingleWord() { + val prompt = buildPrompt("I took the dog for a") + assertTrue(prompt.contains("I took the dog for a")) + assertTrue(prompt.contains("one")) // "exactly one natural next word" + } + + @Test + fun buildPrompt_withWords_embedsTheWords() { + val prompt = buildPrompt("the quick") + assertTrue(prompt.contains("the")) + assertTrue(prompt.contains("quick")) + } + + // ---------- parseCandidates ---------- + + @Test + fun parseCandidates_splitsAndCleans() { + val result = parseCandidates("hello world, again. \"friend\"") + assertEquals(listOf("hello", "world", "again"), result) + } + + @Test + fun parseCandidates_emptyInput_returnsEmpty() { + assertTrue(parseCandidates("").isEmpty()) + assertTrue(parseCandidates(" ").isEmpty()) + } + + @Test + fun parseCandidates_dedupes() { + val result = parseCandidates("the the the the") + assertEquals(1, result.size) + assertEquals("the", result[0]) + } + + @Test + fun parseCandidates_capsAtThree() { + val result = parseCandidates("one two three four five") + assertEquals(3, result.size) + assertEquals(listOf("one", "two", "three"), result) + } + + @Test + fun parseCandidates_dropsNonLetterTokens() { + val result = parseCandidates("123 !!! ...") + assertTrue(result.isEmpty()) + } + + @Test + fun wordTokenSet_tokenizesToLowercaseWords() { + assertEquals( + setOf("i", "took", "the", "dog", "for", "a", "big"), + wordTokenSet("I took the dog for a — big!") + ) + assertFalse(wordTokenSet("hello world").contains("dog")) + } + + // ---------- sanity ---------- + + @Test + fun dictionary_isNotReadyWhenEngineNotReady() { + val engine = object : AINextWordEngine { + override fun isReady(): Boolean = false + override suspend fun suggestNextWords(prompt: String): List = emptyList() + } + val dict = AINextWordDictionary(engine, CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)) + assertFalse(dict.isInitialized()) + assertFalse(dict.isInDictionary("hello")) + } + + // ---------- refresh signal ---------- + + @Test + fun getSuggestions_inPredictionMode_firesOnCandidatesReadyWhenCandidatesCached() { + val callbacks = AtomicInteger(0) + val dict = newDictionary() + dict.onCandidatesReady = { callbacks.incrementAndGet() } + + // Mock android Log + TextUtils, both unmocked statics in plain JVM tests. + Mockito.mockStatic(Log::class.java).use { _ -> + Mockito.mockStatic(TextUtils::class.java).use { textUtils -> + textUtils.`when` { + TextUtils.join( + Mockito.any(), + Mockito.anyList>() + ) + }.thenReturn("the quick") + + // Prediction mode: empty typed word + a real previous-words context. + val composed = ComposedData(InputPointers(4), false /* isBatchMode */, "" /* mTypedWord */) + val ngram = NgramContext(NgramContext.WordInfo("the"), NgramContext.WordInfo("quick")) + val settings = SettingsValuesForSuggestion(false, false, "TOUCH") + + val results = dict.getSuggestions( + composed, ngram, 0L /* proximityInfoHandle */, settings, + 0 /* sessionId */, 1.0f /* weightForLocale */, floatArrayOf(0.5f, 0.5f) + ) + + // First pass is a cache miss (returns null) but kicks off the async fetch, which caches + // the fake engine's candidates and must have fired the refresh callback. + assertTrue(results == null || results.isEmpty()) + } + } + // With Dispatchers.Unconfined the fetch completes synchronously, so the callback fired. + assertEquals(1, callbacks.get()) + } + + // ---------- sentence-start capitalisation ---------- + + @Test + fun endsSentence_afterFullStop_isTrue() { + assertTrue(endsSentence("Take the dog for a walk.")) + assertTrue(endsSentence("Why?\n")) + assertTrue(endsSentence("Wow! ")) + } + + @Test + fun endsSentence_midSentence_isFalse() { + assertFalse(endsSentence("Take the dog for a")) + assertFalse(endsSentence("take the dog for a walk")) + assertFalse(endsSentence("")) + assertFalse(endsSentence(" ")) + } +}