From 81ad303623880e4bc3ad24f9618f7cb78bd69db8 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Thu, 14 May 2026 18:12:55 +0200 Subject: [PATCH 1/9] feat(ai): add Gemma 3 OpenVINO GenAI integration --- ai/build.gradle.kts | 46 ++++++ ai/scripts/prepare_gemma3_openvino_model.py | 68 ++++++++ ai/src/main/cpp/CMakeLists.txt | 40 +++++ ai/src/main/cpp/llm_bridge_jni.cpp | 79 +++++++++ ai/src/main/cpp/llm_engine.cpp | 62 ++++++++ ai/src/main/cpp/llm_engine.h | 22 +++ .../java/com/itlab/ai/GemmaPromptBuilder.kt | 40 +++++ .../java/com/itlab/ai/LlmInferenceBackend.kt | 22 +++ .../main/java/com/itlab/ai/NativeLlmBridge.kt | 24 +++ .../java/com/itlab/ai/OnDeviceLlmConfig.kt | 30 ++++ .../main/java/com/itlab/ai/OpenVinoEngine.kt | 24 ++- .../java/com/itlab/ai/OpenVinoGenAiBackend.kt | 104 ++++++++++++ .../com/itlab/ai/OpenVinoNoteAiService.kt | 29 ++-- ai/src/main/java/com/itlab/ai/di/AiModule.kt | 33 ++++ .../java/com/itlab/ai/OpenVinoAiLayerTest.kt | 65 +++++++- .../java/com/itlab/notes/NotesApplication.kt | 3 +- .../main/java/com/itlab/notes/di/AppModule.kt | 12 ++ .../main/java/com/itlab/notes/ui/NotesApp.kt | 7 + .../com/itlab/notes/ui/NotesUiContract.kt | 15 ++ .../java/com/itlab/notes/ui/NotesUseCases.kt | 8 + .../java/com/itlab/notes/ui/NotesViewModel.kt | 150 +++++++++++++++++- .../com/itlab/notes/ui/editor/EditorScreen.kt | 119 ++++++++++++++ .../itlab/notes/ui/editor/EditorViewModel.kt | 6 + .../com/itlab/notes/ui/notes/NoteItemUi.kt | 2 + gradle/libs.versions.toml | 2 + 25 files changed, 979 insertions(+), 33 deletions(-) create mode 100644 ai/scripts/prepare_gemma3_openvino_model.py create mode 100644 ai/src/main/cpp/CMakeLists.txt create mode 100644 ai/src/main/cpp/llm_bridge_jni.cpp create mode 100644 ai/src/main/cpp/llm_engine.cpp create mode 100644 ai/src/main/cpp/llm_engine.h create mode 100644 ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt create mode 100644 ai/src/main/java/com/itlab/ai/LlmInferenceBackend.kt create mode 100644 ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt create mode 100644 ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt create mode 100644 ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt create mode 100644 ai/src/main/java/com/itlab/ai/di/AiModule.kt diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts index 2218887b..ff6b71da 100644 --- a/ai/build.gradle.kts +++ b/ai/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.android.library) } +val openvinoGenAiAndroidDir = providers.gradleProperty("openvinoGenAiAndroidDir") + android { namespace = "com.itlab.ai" compileSdk { @@ -17,6 +19,26 @@ android { consumerProguardFiles("consumer-rules.pro") } + if (openvinoGenAiAndroidDir.isPresent) { + defaultConfig { + externalNativeBuild { + cmake { + arguments += + listOf( + "-DOPENVINO_GENAI_ANDROID_DIR=${openvinoGenAiAndroidDir.get()}", + "-DANDROID_STL=c++_shared", + ) + } + } + } + + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } + } + buildTypes { release { isMinifyEnabled = false @@ -42,8 +64,32 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.material) + implementation(libs.kotlinx.coroutines.android) + implementation(libs.koin.android) implementation(project(":domain")) testImplementation(libs.junit) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) } + +tasks.register("prepareGemma3OpenVinoModel") { + group = "ai" + description = "Export google/gemma-3-270m-it to an INT4 OpenVINO GenAI model bundle." + + val outputDir = layout.buildDirectory.dir("gemma3/gemma3-270m-it-openvino") + commandLine( + "python3", + "scripts/prepare_gemma3_openvino_model.py", + "--output", + outputDir.get().asFile.absolutePath, + ) +} + +tasks.register("stageGemma3OpenVinoAssets") { + group = "ai" + description = "Copy the prepared Gemma 3 OpenVINO model into app assets for local packaging." + dependsOn("prepareGemma3OpenVinoModel") + + from(layout.buildDirectory.dir("gemma3/gemma3-270m-it-openvino")) + into(layout.projectDirectory.dir("src/main/assets/models/gemma3-270m-it-openvino")) +} diff --git a/ai/scripts/prepare_gemma3_openvino_model.py b/ai/scripts/prepare_gemma3_openvino_model.py new file mode 100644 index 00000000..1759e3bc --- /dev/null +++ b/ai/scripts/prepare_gemma3_openvino_model.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Export Gemma 3 270M IT to an OpenVINO GenAI-ready INT4 bundle.""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + + +DEFAULT_MODEL_ID = "google/gemma-3-270m-it" + + +def run(command: list[str]) -> None: + subprocess.check_call(command) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--model-id", default=DEFAULT_MODEL_ID) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--weight-format", default="int4", choices=("int4", "int8", "fp16")) + parser.add_argument( + "--install-deps", + action="store_true", + help="Install/upgrade host-side OpenVINO export dependencies before running optimum-cli.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + + if args.install_deps: + run( + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + "optimum-intel[openvino]", + "openvino-genai", + "openvino-tokenizers", + "huggingface_hub", + ], + ) + + run( + [ + "optimum-cli", + "export", + "openvino", + "--model", + args.model_id, + "--weight-format", + args.weight_format, + str(args.output), + ], + ) + print(f"Exported {args.model_id} to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ai/src/main/cpp/CMakeLists.txt b/ai/src/main/cpp/CMakeLists.txt new file mode 100644 index 00000000..1137a8a8 --- /dev/null +++ b/ai/src/main/cpp/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.22.1) + +project(notes_llm) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +if (NOT DEFINED OPENVINO_GENAI_ANDROID_DIR) + message(FATAL_ERROR "Set -DOPENVINO_GENAI_ANDROID_DIR to an Android OpenVINO GenAI package root.") +endif () + +add_library( + notes_llm + SHARED + llm_bridge_jni.cpp + llm_engine.cpp +) + +target_include_directories( + notes_llm + PRIVATE + "${OPENVINO_GENAI_ANDROID_DIR}/include" +) + +target_link_directories( + notes_llm + PRIVATE + "${OPENVINO_GENAI_ANDROID_DIR}/lib/${ANDROID_ABI}" + "${OPENVINO_GENAI_ANDROID_DIR}/${ANDROID_ABI}/lib" +) + +find_library(log_lib log) + +target_link_libraries( + notes_llm + PRIVATE + openvino_genai + openvino + ${log_lib} +) diff --git a/ai/src/main/cpp/llm_bridge_jni.cpp b/ai/src/main/cpp/llm_bridge_jni.cpp new file mode 100644 index 00000000..b867cda1 --- /dev/null +++ b/ai/src/main/cpp/llm_bridge_jni.cpp @@ -0,0 +1,79 @@ +#include "llm_engine.h" + +#include + +#include +#include +#include + +namespace { + +notes::ai::LlmEngine g_engine; +std::mutex g_engine_mutex; + +std::string to_string(JNIEnv* env, jstring value) { + if (value == nullptr) { + return ""; + } + + const char* chars = env->GetStringUTFChars(value, nullptr); + if (chars == nullptr) { + return ""; + } + + std::string result(chars); + env->ReleaseStringUTFChars(value, chars); + return result; +} + +void throw_illegal_state(JNIEnv* env, const std::string& message) { + jclass exception_class = env->FindClass("java/lang/IllegalStateException"); + if (exception_class != nullptr) { + env->ThrowNew(exception_class, message.c_str()); + } +} + +} // namespace + +extern "C" JNIEXPORT void JNICALL +Java_com_itlab_ai_NativeLlmBridge_init( + JNIEnv* env, + jobject /*thiz*/, + jstring model_dir, + jstring cache_dir, + jstring device +) { + try { + std::lock_guard lock(g_engine_mutex); + g_engine.init(to_string(env, model_dir), to_string(env, cache_dir), to_string(env, device)); + } catch (const std::exception& error) { + throw_illegal_state(env, error.what()); + } +} + +extern "C" JNIEXPORT jstring JNICALL +Java_com_itlab_ai_NativeLlmBridge_generate( + JNIEnv* env, + jobject /*thiz*/, + jstring prompt, + jint max_new_tokens +) { + try { + std::lock_guard lock(g_engine_mutex); + const std::string result = g_engine.generate(to_string(env, prompt), max_new_tokens); + return env->NewStringUTF(result.c_str()); + } catch (const std::exception& error) { + throw_illegal_state(env, error.what()); + return nullptr; + } +} + +extern "C" JNIEXPORT void JNICALL +Java_com_itlab_ai_NativeLlmBridge_close(JNIEnv* env, jobject /*thiz*/) { + try { + std::lock_guard lock(g_engine_mutex); + g_engine.close(); + } catch (const std::exception& error) { + throw_illegal_state(env, error.what()); + } +} diff --git a/ai/src/main/cpp/llm_engine.cpp b/ai/src/main/cpp/llm_engine.cpp new file mode 100644 index 00000000..77b0f8e7 --- /dev/null +++ b/ai/src/main/cpp/llm_engine.cpp @@ -0,0 +1,62 @@ +#include "llm_engine.h" + +#include + +#include "openvino/genai/llm_pipeline.hpp" + +#include +#include +#include + +namespace notes::ai { + +struct LlmEngine::Impl { + explicit Impl(std::unique_ptr llm_pipeline) + : pipeline(std::move(llm_pipeline)) {} + + std::unique_ptr pipeline; + std::mutex mutex; +}; + +LlmEngine::LlmEngine() = default; + +LlmEngine::~LlmEngine() = default; + +void LlmEngine::init( + const std::string& model_dir, + const std::string& cache_dir, + const std::string& device +) { + if (model_dir.empty()) { + throw std::invalid_argument("Gemma model directory is empty."); + } + + const std::string target_device = device.empty() ? "CPU" : device; + ov::AnyMap pipeline_config; + if (!cache_dir.empty()) { + pipeline_config.insert({ov::cache_dir(cache_dir)}); + } + + auto pipeline = std::make_unique(model_dir, target_device, pipeline_config); + impl_ = std::make_unique(std::move(pipeline)); +} + +std::string LlmEngine::generate(const std::string& prompt, int max_new_tokens) { + if (!impl_ || !impl_->pipeline) { + throw std::logic_error("OpenVINO GenAI pipeline is not initialized."); + } + if (prompt.empty()) { + return ""; + } + + std::lock_guard lock(impl_->mutex); + ov::genai::GenerationConfig generation_config = impl_->pipeline->get_generation_config(); + generation_config.max_new_tokens = static_cast(max_new_tokens); + return impl_->pipeline->generate(prompt, generation_config); +} + +void LlmEngine::close() { + impl_.reset(); +} + +} // namespace notes::ai diff --git a/ai/src/main/cpp/llm_engine.h b/ai/src/main/cpp/llm_engine.h new file mode 100644 index 00000000..ce68c86b --- /dev/null +++ b/ai/src/main/cpp/llm_engine.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace notes::ai { + +class LlmEngine { +public: + LlmEngine(); + ~LlmEngine(); + + void init(const std::string& model_dir, const std::string& cache_dir, const std::string& device); + std::string generate(const std::string& prompt, int max_new_tokens); + void close(); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace notes::ai diff --git a/ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt b/ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt new file mode 100644 index 00000000..84e0c6a5 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt @@ -0,0 +1,40 @@ +package com.itlab.ai + +class GemmaPromptBuilder( + private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.gemma3SmallIt(), +) { + fun summaryPrompt(text: String): String = + gemmaUserTurn( + """ + Summarize the note in 1-2 concise sentences. + Return only the summary, without markdown or a preamble. + + Note: + ${trimInput(text)} + """.trimIndent(), + ) + + fun tagsPrompt(text: String): String = + gemmaUserTurn( + """ + Suggest up to ${config.maxTags} short tags for the note. + Return only a comma-separated tag list. Use lowercase tags. + + Note: + ${trimInput(text)} + """.trimIndent(), + ) + + private fun gemmaUserTurn(instruction: String): String = + """ + user + $instruction + + model + """.trimIndent() + + private fun trimInput(text: String): String = + text + .trim() + .take(config.maxInputChars) +} diff --git a/ai/src/main/java/com/itlab/ai/LlmInferenceBackend.kt b/ai/src/main/java/com/itlab/ai/LlmInferenceBackend.kt new file mode 100644 index 00000000..baf6ab73 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/LlmInferenceBackend.kt @@ -0,0 +1,22 @@ +package com.itlab.ai + +interface LlmInferenceBackend { + fun generate( + prompt: String, + maxNewTokens: Int, + ): String +} + +class MissingLlmRuntimeException( + message: String, + cause: Throwable? = null, +) : IllegalStateException(message, cause) + +class UnavailableLlmBackend( + private val reason: String = "OpenVINO GenAI backend is not configured.", +) : LlmInferenceBackend { + override fun generate( + prompt: String, + maxNewTokens: Int, + ): String = throw MissingLlmRuntimeException(reason) +} diff --git a/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt b/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt new file mode 100644 index 00000000..ed531584 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt @@ -0,0 +1,24 @@ +package com.itlab.ai + +class NativeLlmBridge private constructor() : AutoCloseable { + external fun init( + modelDir: String, + cacheDir: String, + device: String, + ) + + external fun generate( + prompt: String, + maxNewTokens: Int, + ): String + + external override fun close() + + companion object { + fun load(libraryName: String): Result = + runCatching { + System.loadLibrary(libraryName) + NativeLlmBridge() + } + } +} diff --git a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt new file mode 100644 index 00000000..098c6329 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt @@ -0,0 +1,30 @@ +package com.itlab.ai + +data class OnDeviceLlmConfig( + val modelId: String, + val assetModelDir: String, + val modelDirName: String, + val device: String, + val nativeLibraryName: String, + val cacheDirName: String, + val maxInputChars: Int, + val summaryMaxNewTokens: Int, + val tagsMaxNewTokens: Int, + val maxTags: Int, +) { + companion object { + fun gemma3SmallIt(): OnDeviceLlmConfig = + OnDeviceLlmConfig( + modelId = "google/gemma-3-270m-it", + assetModelDir = "models/gemma3-270m-it-openvino", + modelDirName = "gemma3-270m-it-openvino", + device = "CPU", + nativeLibraryName = "notes_llm", + cacheDirName = "openvino-genai-cache", + maxInputChars = 6_000, + summaryMaxNewTokens = 96, + tagsMaxNewTokens = 48, + maxTags = 6, + ) + } +} diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt b/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt index 9f55841a..44592f13 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt @@ -1,9 +1,23 @@ package com.itlab.ai -class OpenVinoEngine { - fun runLlmSummary(text: String): String = text +class OpenVinoEngine( + private val llmBackend: LlmInferenceBackend = UnavailableLlmBackend(), + private val promptBuilder: GemmaPromptBuilder = GemmaPromptBuilder(), + private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.gemma3SmallIt(), +) { + fun runLlmSummary(text: String): String { + if (text.isBlank()) return "" + return llmBackend.generate( + prompt = promptBuilder.summaryPrompt(text), + maxNewTokens = config.summaryMaxNewTokens, + ) + } - fun runLlmTagging(text: String): String = text - - fun runYoloTagging(imageSource: String): String = imageSource + fun runLlmTagging(text: String): String { + if (text.isBlank()) return "" + return llmBackend.generate( + prompt = promptBuilder.tagsPrompt(text), + maxNewTokens = config.tagsMaxNewTokens, + ) + } } diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt new file mode 100644 index 00000000..61a3b697 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt @@ -0,0 +1,104 @@ +package com.itlab.ai + +import android.content.Context +import java.io.File +import java.io.IOException + +class OpenVinoGenAiBackend( + context: Context, + private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.gemma3SmallIt(), +) : LlmInferenceBackend, + AutoCloseable { + private val appContext = context.applicationContext + private var bridge: NativeLlmBridge? = null + + @Synchronized + override fun generate( + prompt: String, + maxNewTokens: Int, + ): String { + val activeBridge = bridge ?: createBridge() + return activeBridge.generate(prompt, maxNewTokens) + } + + @Synchronized + override fun close() { + bridge?.close() + bridge = null + } + + private fun createBridge(): NativeLlmBridge { + val modelDir = ensureModelDirectory() + val cacheDir = + File(appContext.cacheDir, config.cacheDirName) + .apply { mkdirs() } + + val loaded = + NativeLlmBridge + .load(config.nativeLibraryName) + .getOrElse { cause -> + throw MissingLlmRuntimeException( + "OpenVINO GenAI native library '${config.nativeLibraryName}' is not packaged.", + cause, + ) + } + + loaded.init( + modelDir = modelDir.absolutePath, + cacheDir = cacheDir.absolutePath, + device = config.device, + ) + bridge = loaded + return loaded + } + + private fun ensureModelDirectory(): File { + val targetDir = File(appContext.filesDir, "models/${config.modelDirName}") + if (targetDir.exists() && !targetDir.list().isNullOrEmpty()) { + return targetDir + } + + if (!assetDirectoryExists(config.assetModelDir)) { + throw MissingLlmRuntimeException( + "Gemma 3 OpenVINO model assets are missing at assets/${config.assetModelDir}. " + + "Run :ai:stageGemma3OpenVinoAssets before packaging a local GenAI build.", + ) + } + + targetDir.mkdirs() + copyAssetDirectory(config.assetModelDir, targetDir) + return targetDir + } + + private fun assetDirectoryExists(assetPath: String): Boolean = + try { + !appContext.assets.list(assetPath).isNullOrEmpty() + } catch (_: IOException) { + false + } + + private fun copyAssetDirectory( + assetPath: String, + targetDir: File, + ) { + val children = + appContext.assets.list(assetPath) + ?: throw MissingLlmRuntimeException("Unable to list model asset directory: $assetPath") + + children.forEach { child -> + val childAssetPath = "$assetPath/$child" + val childTarget = File(targetDir, child) + val nestedChildren = appContext.assets.list(childAssetPath) + if (nestedChildren.isNullOrEmpty()) { + appContext.assets.open(childAssetPath).use { input -> + childTarget.outputStream().use { output -> + input.copyTo(output) + } + } + } else { + childTarget.mkdirs() + copyAssetDirectory(childAssetPath, childTarget) + } + } + } +} diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt b/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt index 299f6a3b..de3741f0 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt @@ -1,24 +1,27 @@ package com.itlab.ai import com.itlab.domain.ai.NoteAiService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class OpenVinoNoteAiService( private val engine: OpenVinoEngine, private val processor: ResultProcessor, ) : NoteAiService { - override suspend fun summarize(text: String): String { - val llmResult = engine.runLlmSummary(text) - return processor.normalizeSummary(llmResult) - } + override suspend fun summarize(text: String): String = + withContext(Dispatchers.Default) { + val llmResult = engine.runLlmSummary(text) + processor.normalizeSummary(llmResult) + } - override suspend fun tagTXT(text: String): Set { - val llmResult = engine.runLlmTagging(text) - return processor.normalizeTags(llmResult) - } + override suspend fun tagTXT(text: String): Set = + withContext(Dispatchers.Default) { + val llmResult = engine.runLlmTagging(text) + processor.normalizeTags(llmResult) + } - override suspend fun tagIMGs(img: List): Set = - img - .map { source -> engine.runYoloTagging(source) } - .flatMap { result -> processor.normalizeTags(result) } - .toSet() + override suspend fun tagIMGs(img: List): Set { + // Gemma 3 is a text LLM path. Image tagging stays in a separate AI direction. + return emptySet() + } } diff --git a/ai/src/main/java/com/itlab/ai/di/AiModule.kt b/ai/src/main/java/com/itlab/ai/di/AiModule.kt new file mode 100644 index 00000000..6b1decc6 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/di/AiModule.kt @@ -0,0 +1,33 @@ +package com.itlab.ai.di + +import com.itlab.ai.GemmaPromptBuilder +import com.itlab.ai.LlmInferenceBackend +import com.itlab.ai.OnDeviceLlmConfig +import com.itlab.ai.OpenVinoEngine +import com.itlab.ai.OpenVinoGenAiBackend +import com.itlab.ai.OpenVinoNoteAiService +import com.itlab.ai.ResultProcessor +import com.itlab.domain.ai.NoteAiService +import org.koin.android.ext.koin.androidContext +import org.koin.dsl.module + +val aiModule = + module { + single { OnDeviceLlmConfig.gemma3SmallIt() } + single { GemmaPromptBuilder(get()) } + single { OpenVinoGenAiBackend(androidContext(), get()) } + single { ResultProcessor() } + single { + OpenVinoEngine( + llmBackend = get(), + promptBuilder = get(), + config = get(), + ) + } + single { + OpenVinoNoteAiService( + engine = get(), + processor = get(), + ) + } + } diff --git a/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt b/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt index 452421e3..76a71470 100644 --- a/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt +++ b/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt @@ -2,6 +2,7 @@ package com.itlab.ai import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class OpenVinoAiLayerTest { @@ -35,30 +36,80 @@ class OpenVinoAiLayerTest { @Test fun summarize_returnsTrimmedSummary() = runBlocking { - val service = OpenVinoNoteAiService(OpenVinoEngine(), ResultProcessor()) + val backend = RecordingLlmBackend(" Summary text ") + val service = + OpenVinoNoteAiService( + OpenVinoEngine(llmBackend = backend), + ResultProcessor(), + ) - val result = service.summarize(" Summary text ") + val result = service.summarize("Long note") assertEquals("Summary text", result) + assertEquals(OnDeviceLlmConfig.gemma3SmallIt().summaryMaxNewTokens, backend.lastMaxNewTokens) + assertTrue(backend.lastPrompt.orEmpty().contains("user")) + assertTrue(backend.lastPrompt.orEmpty().contains("Summarize the note")) + assertTrue(backend.lastPrompt.orEmpty().contains("Long note")) } @Test fun tagTXT_normalizesCaseAndSeparators() = runBlocking { - val service = OpenVinoNoteAiService(OpenVinoEngine(), ResultProcessor()) + val backend = RecordingLlmBackend(" Kotlin, Notes\nAI ") + val service = + OpenVinoNoteAiService( + OpenVinoEngine(llmBackend = backend), + ResultProcessor(), + ) - val result = service.tagTXT(" Kotlin, Notes\nAI ") + val result = service.tagTXT("OpenVINO note") assertEquals(setOf("kotlin", "notes", "ai"), result) + assertEquals(OnDeviceLlmConfig.gemma3SmallIt().tagsMaxNewTokens, backend.lastMaxNewTokens) + assertTrue(backend.lastPrompt.orEmpty().contains("Suggest up to")) + assertTrue(backend.lastPrompt.orEmpty().contains("OpenVINO note")) } @Test - fun tagIMGs_aggregatesAndDeduplicatesTags() = + fun tagIMGs_returnsEmptySetBecauseVisionIsSeparateFromGemmaLlm() = runBlocking { - val service = OpenVinoNoteAiService(OpenVinoEngine(), ResultProcessor()) + val service = + OpenVinoNoteAiService( + OpenVinoEngine(llmBackend = RecordingLlmBackend("unused")), + ResultProcessor(), + ) val result = service.tagIMGs(listOf("Cat, Pet", "pet, animal", " CAT")) - assertEquals(setOf("cat", "pet", "animal"), result) + assertEquals(emptySet(), result) } + + @Test + fun gemmaPromptBuilder_trimsLargeInput() { + val config = OnDeviceLlmConfig.gemma3SmallIt().copy(maxInputChars = 5) + val builder = GemmaPromptBuilder(config) + + val prompt = builder.summaryPrompt("123456789") + + assertTrue(prompt.contains("12345")) + assertTrue(!prompt.contains("123456")) + } + + private class RecordingLlmBackend( + private val response: String, + ) : LlmInferenceBackend { + var lastPrompt: String? = null + private set + var lastMaxNewTokens: Int? = null + private set + + override fun generate( + prompt: String, + maxNewTokens: Int, + ): String { + lastPrompt = prompt + lastMaxNewTokens = maxNewTokens + return response + } + } } diff --git a/app/src/main/java/com/itlab/notes/NotesApplication.kt b/app/src/main/java/com/itlab/notes/NotesApplication.kt index 3f4764b3..5d5b7542 100644 --- a/app/src/main/java/com/itlab/notes/NotesApplication.kt +++ b/app/src/main/java/com/itlab/notes/NotesApplication.kt @@ -1,6 +1,7 @@ package com.itlab.notes import android.app.Application +import com.itlab.ai.di.aiModule import com.itlab.data.di.dataModule import com.itlab.notes.di.appModule import org.koin.android.ext.koin.androidContext @@ -11,7 +12,7 @@ class NotesApplication : Application() { super.onCreate() startKoin { androidContext(this@NotesApplication) - modules(listOf(appModule, dataModule)) + modules(listOf(appModule, dataModule, aiModule)) } } } diff --git a/app/src/main/java/com/itlab/notes/di/AppModule.kt b/app/src/main/java/com/itlab/notes/di/AppModule.kt index 3edfbe55..aee2ffcc 100644 --- a/app/src/main/java/com/itlab/notes/di/AppModule.kt +++ b/app/src/main/java/com/itlab/notes/di/AppModule.kt @@ -1,10 +1,14 @@ package com.itlab.notes.di +import com.itlab.domain.usecase.aiusecase.SuggestSummaryUseCase +import com.itlab.domain.usecase.aiusecase.SuggestTagsUseCase import com.itlab.domain.usecase.folderusecase.CreateFolderUseCase import com.itlab.domain.usecase.folderusecase.DeleteFolderUseCase import com.itlab.domain.usecase.folderusecase.GetFolderUseCase import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase +import com.itlab.domain.usecase.noteusecase.ApplySummaryUseCase +import com.itlab.domain.usecase.noteusecase.ApplyTagsUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase @@ -29,6 +33,10 @@ val appModule = factory { ObserveFoldersUseCase(get()) } factory { MoveNoteToFolderUseCase(get(), get()) } factory { ObserveNotesUseCase(get()) } + factory { SuggestSummaryUseCase(get(), get()) } + factory { SuggestTagsUseCase(get(), get()) } + factory { ApplySummaryUseCase(get()) } + factory { ApplyTagsUseCase(get()) } factory { NotesUseCases( createFolderUseCase = get(), @@ -42,6 +50,10 @@ val appModule = getFolderUseCase = get(), moveNoteToFolderUseCase = get(), observeNotesUseCase = get(), + suggestSummaryUseCase = get(), + suggestTagsUseCase = get(), + applySummaryUseCase = get(), + applyTagsUseCase = get(), ) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 2abb3c45..211d14ac 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -60,10 +60,17 @@ fun notesApp() { editorScreen( directoryName = screen.directory.name, note = screen.note, + aiState = state.aiState, onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectoryNotes) }, onSave = { updated -> viewModel.onEvent(NotesUiEvent.SaveNote(updated)) }, + onSuggestSummary = { updated -> + viewModel.onEvent(NotesUiEvent.SuggestSummary(updated)) + }, + onSuggestTags = { updated -> + viewModel.onEvent(NotesUiEvent.SuggestTags(updated)) + }, ) } } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt index 054570f4..2dbbd9ba 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUiContract.kt @@ -24,6 +24,13 @@ data class NotesUiState( val screen: NotesUiScreen = NotesUiScreen.Directories, val directories: List = emptyList(), val notes: List = emptyList(), + val aiState: AiUiState = AiUiState(), +) + +data class AiUiState( + val isGeneratingSummary: Boolean = false, + val isGeneratingTags: Boolean = false, + val errorMessage: String? = null, ) sealed interface NotesUiEvent { @@ -53,6 +60,14 @@ sealed interface NotesUiEvent { val note: NoteItemUi, ) : NotesUiEvent + data class SuggestSummary( + val note: NoteItemUi, + ) : NotesUiEvent + + data class SuggestTags( + val note: NoteItemUi, + ) : NotesUiEvent + data class DeleteNote( val noteId: String, ) : NotesUiEvent diff --git a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt index d43af7ae..52f919a6 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesUseCases.kt @@ -1,10 +1,14 @@ package com.itlab.notes.ui +import com.itlab.domain.usecase.aiusecase.SuggestSummaryUseCase +import com.itlab.domain.usecase.aiusecase.SuggestTagsUseCase import com.itlab.domain.usecase.folderusecase.CreateFolderUseCase import com.itlab.domain.usecase.folderusecase.DeleteFolderUseCase import com.itlab.domain.usecase.folderusecase.GetFolderUseCase import com.itlab.domain.usecase.folderusecase.ObserveFoldersUseCase import com.itlab.domain.usecase.folderusecase.UpdateFolderUseCase +import com.itlab.domain.usecase.noteusecase.ApplySummaryUseCase +import com.itlab.domain.usecase.noteusecase.ApplyTagsUseCase import com.itlab.domain.usecase.noteusecase.CreateNoteUseCase import com.itlab.domain.usecase.noteusecase.DeleteNoteUseCase import com.itlab.domain.usecase.noteusecase.MoveNoteToFolderUseCase @@ -24,4 +28,8 @@ data class NotesUseCases( val getFolderUseCase: GetFolderUseCase, val moveNoteToFolderUseCase: MoveNoteToFolderUseCase, val observeNotesUseCase: ObserveNotesUseCase, + val suggestSummaryUseCase: SuggestSummaryUseCase, + val suggestTagsUseCase: SuggestTagsUseCase, + val applySummaryUseCase: ApplySummaryUseCase, + val applyTagsUseCase: ApplyTagsUseCase, ) diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 5a42af0b..8cabdb3c 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -68,6 +68,8 @@ class NotesViewModel( } NotesUiEvent.BackToDirectoryNotes -> backToDirectoryNotes() is NotesUiEvent.SaveNote -> saveNote(event.note) + is NotesUiEvent.SuggestSummary -> suggestSummary(event.note) + is NotesUiEvent.SuggestTags -> suggestTags(event.note) is NotesUiEvent.DeleteNote -> { viewModelScope.launch { useCases.deleteNoteUseCase(event.noteId) @@ -100,6 +102,7 @@ class NotesViewModel( uiState.copy( screen = NotesUiScreen.DirectoryNotes(directory = directory), notes = emptyList(), + aiState = AiUiState(), ) notesJob?.cancel() val isAll = directory.id == "all" @@ -113,13 +116,19 @@ class NotesViewModel( } flow.collect { notes -> + val updatedDirectory = directory.copy(noteCount = notes.size) + val currentScreen = uiState.screen uiState = uiState.copy( notes = notes.map { it.toUi() }, screen = - NotesUiScreen.DirectoryNotes( - directory = directory.copy(noteCount = notes.size), - ), + if (currentScreen is NotesUiScreen.DirectoryNotes && + currentScreen.directory.id == directory.id + ) { + NotesUiScreen.DirectoryNotes(directory = updatedDirectory) + } else { + currentScreen + }, ) } } @@ -130,6 +139,7 @@ class NotesViewModel( uiState.copy( screen = NotesUiScreen.Directories, notes = emptyList(), + aiState = AiUiState(), ) } @@ -139,6 +149,7 @@ class NotesViewModel( uiState = uiState.copy( screen = NotesUiScreen.NoteEditor(directory = dir, note = note), + aiState = AiUiState(), ) } } @@ -151,6 +162,7 @@ class NotesViewModel( uiState = uiState.copy( screen = NotesUiScreen.NoteEditor(directory = dir, note = newNote), + aiState = AiUiState(), ) } } @@ -158,21 +170,137 @@ class NotesViewModel( private fun backToDirectoryNotes() { val editor = uiState.screen as? NotesUiScreen.NoteEditor if (editor != null) { - uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) + uiState = + uiState.copy( + screen = NotesUiScreen.DirectoryNotes(directory = editor.directory), + aiState = AiUiState(), + ) } } private fun saveNote(note: NoteItemUi) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { + upsertEditorNote(note, editor) + .onSuccess { + uiState = + uiState.copy( + screen = NotesUiScreen.DirectoryNotes(directory = editor.directory), + aiState = AiUiState(), + ) + }.onFailure { error -> + updateAiState { it.copy(errorMessage = error.userMessage("Unable to save note")) } + } + } + } + + private fun suggestSummary(note: NoteItemUi) { + val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return + viewModelScope.launch { + updateAiState { it.copy(isGeneratingSummary = true, errorMessage = null) } + val savedNote = + upsertEditorNote(note, editor) + .getOrElse { error -> + finishSummary(error) + return@launch + } + updateEditorNote(savedNote) + + val generated = + useCases + .suggestSummaryUseCase(savedNote.id) + .mapCatching { summary -> + useCases.applySummaryUseCase(savedNote.id, summary).getOrThrow() + savedNote.copy(summary = summary) + } + + generated + .onSuccess { updatedNote -> + updateEditorNote(updatedNote) + updateAiState { it.copy(isGeneratingSummary = false, errorMessage = null) } + }.onFailure { error -> + finishSummary(error) + } + } + } + + private fun suggestTags(note: NoteItemUi) { + val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return + viewModelScope.launch { + updateAiState { it.copy(isGeneratingTags = true, errorMessage = null) } + val savedNote = + upsertEditorNote(note, editor) + .getOrElse { error -> + finishTags(error) + return@launch + } + updateEditorNote(savedNote) + + val generated = + useCases + .suggestTagsUseCase(savedNote.id) + .mapCatching { tags -> + useCases.applyTagsUseCase(savedNote.id, tags).getOrThrow() + savedNote.copy(tags = tags) + } + + generated + .onSuccess { updatedNote -> + updateEditorNote(updatedNote) + updateAiState { it.copy(isGeneratingTags = false, errorMessage = null) } + }.onFailure { error -> + finishTags(error) + } + } + } + + private suspend fun upsertEditorNote( + note: NoteItemUi, + editor: NotesUiScreen.NoteEditor, + ): Result = + runCatching { val targetFolderId = note.folderId ?: editor.directory.id.asDomainFolderId() val existing = latestNotes.firstOrNull { it.id == note.id } if (existing != null) { - useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)) + useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)).getOrThrow() + note.copy(folderId = targetFolderId) } else { - useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)) + val savedId = useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)).getOrThrow() + note.copy(id = savedId, folderId = targetFolderId) } - uiState = uiState.copy(screen = NotesUiScreen.DirectoryNotes(directory = editor.directory)) + } + + private fun updateEditorNote(note: NoteItemUi) { + val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return + uiState = + uiState.copy( + screen = + NotesUiScreen.NoteEditor( + directory = editor.directory, + note = note, + ), + ) + } + + private fun updateAiState(update: (AiUiState) -> AiUiState) { + uiState = uiState.copy(aiState = update(uiState.aiState)) + } + + private fun finishSummary(error: Throwable) { + updateAiState { + it.copy( + isGeneratingSummary = false, + errorMessage = error.userMessage("Unable to generate summary"), + ) + } + } + + private fun finishTags(error: Throwable) { + updateAiState { + it.copy( + isGeneratingTags = false, + errorMessage = error.userMessage("Unable to suggest tags"), + ) } } @@ -219,6 +347,8 @@ internal fun Note.toUi(): NoteItemUi = .filterIsInstance() .joinToString("\n") { it.text }, folderId = folderId, + tags = tags, + summary = summary, ) internal fun NoteItemUi.toDomain(folderId: String?): Note = @@ -227,6 +357,8 @@ internal fun NoteItemUi.toDomain(folderId: String?): Note = title = title, folderId = folderId, contentItems = listOf(ContentItem.Text(content)), + tags = tags, + summary = summary, ) internal fun Note.applyUiUpdate( @@ -243,7 +375,11 @@ internal fun Note.applyUiUpdate( title = ui.title, folderId = targetFolderId, contentItems = if (updatedText != null) nonTextContent + updatedText else nonTextContent, + tags = ui.tags, + summary = ui.summary, ) } internal fun String.asDomainFolderId(): String? = if (this == "all") null else this + +private fun Throwable.userMessage(fallback: String): String = message ?: fallback diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index aa826b2a..d79a0563 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -1,17 +1,25 @@ package com.itlab.notes.ui.editor +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -22,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import com.itlab.notes.ui.AiUiState import com.itlab.notes.ui.notes.NoteItemUi @OptIn(ExperimentalMaterial3Api::class) @@ -29,8 +38,11 @@ import com.itlab.notes.ui.notes.NoteItemUi fun editorScreen( directoryName: String, note: NoteItemUi, + aiState: AiUiState, onBack: () -> Unit, onSave: (NoteItemUi) -> Unit, + onSuggestSummary: (NoteItemUi) -> Unit, + onSuggestTags: (NoteItemUi) -> Unit, ) { val colors = MaterialTheme.colorScheme val editorVm = remember(note.id) { EditorViewModel(initialNote = note) } @@ -55,6 +67,11 @@ fun editorScreen( content = editorVm.content, onTitleChange = editorVm::onTitleChange, onContentChange = editorVm::onContentChange, + summary = note.summary, + tags = note.tags, + aiState = aiState, + onSuggestSummary = { onSuggestSummary(editorVm.buildUpdatedNote()) }, + onSuggestTags = { onSuggestTags(editorVm.buildUpdatedNote()) }, modifier = Modifier.padding(paddingValues), ) } @@ -116,6 +133,11 @@ private fun editorContent( content: String, onTitleChange: (String) -> Unit, onContentChange: (String) -> Unit, + summary: String?, + tags: Set, + aiState: AiUiState, + onSuggestSummary: () -> Unit, + onSuggestTags: () -> Unit, modifier: Modifier = Modifier, ) { val colors = MaterialTheme.colorScheme @@ -135,6 +157,15 @@ private fun editorContent( onValueChange = onContentChange, modifier = Modifier.padding(top = 12.dp), ) + + editorAiPanel( + summary = summary, + tags = tags, + aiState = aiState, + onSuggestSummary = onSuggestSummary, + onSuggestTags = onSuggestTags, + modifier = Modifier.padding(top = 16.dp), + ) } } @@ -193,3 +224,91 @@ private fun editorContentField( ), ) } + +@Composable +private fun editorAiPanel( + summary: String?, + tags: Set, + aiState: AiUiState, + onSuggestSummary: () -> Unit, + onSuggestTags: () -> Unit, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.colorScheme + val isGenerating = aiState.isGeneratingSummary || aiState.isGeneratingTags + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = onSuggestSummary, + enabled = !isGenerating, + modifier = Modifier.weight(1f), + ) { + if (aiState.isGeneratingSummary) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colors.onPrimary, + ) + } else { + Text("Summarize") + } + } + + OutlinedButton( + onClick = onSuggestTags, + enabled = !isGenerating, + modifier = Modifier.weight(1f), + ) { + if (aiState.isGeneratingTags) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Suggest tags") + } + } + } + + aiState.errorMessage?.let { error -> + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + ) + } + + if (!summary.isNullOrBlank()) { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Summary", + color = colors.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = summary, + color = colors.onSurface, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } + + if (tags.isNotEmpty()) { + Text( + text = "Tags: ${tags.joinToString(", ")}", + color = colors.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt index 4e448ffb..a7dd1644 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorViewModel.kt @@ -9,6 +9,9 @@ class EditorViewModel( initialNote: NoteItemUi, ) { private val noteId: String = initialNote.id + private val folderId: String? = initialNote.folderId + private val tags: Set = initialNote.tags + private val summary: String? = initialNote.summary var title: String by mutableStateOf(initialNote.title) private set @@ -29,5 +32,8 @@ class EditorViewModel( id = noteId, title = title, content = content, + folderId = folderId, + tags = tags, + summary = summary, ) } diff --git a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt index 618a112f..90721ece 100644 --- a/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt +++ b/app/src/main/java/com/itlab/notes/ui/notes/NoteItemUi.kt @@ -5,4 +5,6 @@ data class NoteItemUi( val title: String, val content: String, val folderId: String? = null, + val tags: Set = emptySet(), + val summary: String? = null, ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8401d3c1..a16b5afa 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ timber = "5.0.1" kotlinxSerializationJson = "1.11.0" robolectric = "4.16.1" coroutinesTest = "1.11.0" +coroutines = "1.11.0" coreTesting = "2.2.0" lifecycleViewmodelKtx = "2.10.0" koin = "4.2.1" @@ -50,6 +51,7 @@ timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "tim androidx-compose-material-icons-core = { group = "androidx.compose.material", name = "material-icons-core" } androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutinesTest" } +kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } androidx-core-testing = { group = "androidx.arch.core", name = "core-testing", version.ref = "coreTesting" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" } androidx-lifecycle-viewmodel-ktx = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } From 51d2c9e4df92adeb792d54a2fc2cd48a3dfcf422 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Thu, 14 May 2026 18:38:51 +0200 Subject: [PATCH 2/9] refactor(ai): use generic on-device LLM setup --- ai/.gitignore | 3 +- ai/build.gradle.kts | 39 +++++-- ai/scripts/prepare_gemma3_openvino_model.py | 68 ----------- ai/scripts/prepare_openvino_llm_model.py | 108 ++++++++++++++++++ ai/src/main/cpp/llm_engine.cpp | 2 +- ...omptBuilder.kt => NoteLlmPromptBuilder.kt} | 19 +-- .../java/com/itlab/ai/OnDeviceLlmConfig.kt | 8 +- .../main/java/com/itlab/ai/OpenVinoEngine.kt | 4 +- .../java/com/itlab/ai/OpenVinoGenAiBackend.kt | 6 +- .../com/itlab/ai/OpenVinoNoteAiService.kt | 2 +- ai/src/main/java/com/itlab/ai/di/AiModule.kt | 6 +- .../java/com/itlab/ai/OpenVinoAiLayerTest.kt | 14 +-- 12 files changed, 171 insertions(+), 108 deletions(-) delete mode 100644 ai/scripts/prepare_gemma3_openvino_model.py create mode 100644 ai/scripts/prepare_openvino_llm_model.py rename ai/src/main/java/com/itlab/ai/{GemmaPromptBuilder.kt => NoteLlmPromptBuilder.kt} (67%) diff --git a/ai/.gitignore b/ai/.gitignore index 42afabfd..2223261a 100644 --- a/ai/.gitignore +++ b/ai/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +/src/main/assets/models/on-device-llm-openvino/ diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts index ff6b71da..17460ae9 100644 --- a/ai/build.gradle.kts +++ b/ai/build.gradle.kts @@ -5,6 +5,11 @@ plugins { } val openvinoGenAiAndroidDir = providers.gradleProperty("openvinoGenAiAndroidDir") +val onDeviceLlmModelId = "Qwen/Qwen2.5-0.5B-Instruct" +val onDeviceLlmWeightFormat = providers.gradleProperty("onDeviceLlmWeightFormat").orElse("int4") +val onDeviceLlmPythonVenvDir = layout.buildDirectory.dir("llm/python-venv") +val onDeviceLlmExportDir = layout.buildDirectory.dir("llm/on-device-llm-openvino") +val onDeviceLlmAssetDir = layout.projectDirectory.dir("src/main/assets/models/on-device-llm-openvino") android { namespace = "com.itlab.ai" @@ -72,24 +77,38 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) } -tasks.register("prepareGemma3OpenVinoModel") { +tasks.register("prepareOpenVinoLlmModel") { group = "ai" - description = "Export google/gemma-3-270m-it to an INT4 OpenVINO GenAI model bundle." + description = "Export the bundled on-device LLM to an OpenVINO GenAI model bundle." + + inputs.property("modelId", onDeviceLlmModelId) + inputs.property("weightFormat", onDeviceLlmWeightFormat) + outputs.dir(onDeviceLlmExportDir) - val outputDir = layout.buildDirectory.dir("gemma3/gemma3-270m-it-openvino") commandLine( "python3", - "scripts/prepare_gemma3_openvino_model.py", + "scripts/prepare_openvino_llm_model.py", + "--model-id", + onDeviceLlmModelId, + "--weight-format", + onDeviceLlmWeightFormat.get(), "--output", - outputDir.get().asFile.absolutePath, + onDeviceLlmExportDir.get().asFile.absolutePath, + "--venv", + onDeviceLlmPythonVenvDir.get().asFile.absolutePath, + "--install-deps", ) } -tasks.register("stageGemma3OpenVinoAssets") { +tasks.register("stageOpenVinoLlmAssets") { group = "ai" - description = "Copy the prepared Gemma 3 OpenVINO model into app assets for local packaging." - dependsOn("prepareGemma3OpenVinoModel") + description = "Copy the prepared OpenVINO LLM model into app assets for local packaging." + dependsOn("prepareOpenVinoLlmModel") + + from(onDeviceLlmExportDir) + into(onDeviceLlmAssetDir) +} - from(layout.buildDirectory.dir("gemma3/gemma3-270m-it-openvino")) - into(layout.projectDirectory.dir("src/main/assets/models/gemma3-270m-it-openvino")) +tasks.named("preBuild") { + dependsOn("stageOpenVinoLlmAssets") } diff --git a/ai/scripts/prepare_gemma3_openvino_model.py b/ai/scripts/prepare_gemma3_openvino_model.py deleted file mode 100644 index 1759e3bc..00000000 --- a/ai/scripts/prepare_gemma3_openvino_model.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -"""Export Gemma 3 270M IT to an OpenVINO GenAI-ready INT4 bundle.""" - -from __future__ import annotations - -import argparse -import subprocess -import sys -from pathlib import Path - - -DEFAULT_MODEL_ID = "google/gemma-3-270m-it" - - -def run(command: list[str]) -> None: - subprocess.check_call(command) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser() - parser.add_argument("--model-id", default=DEFAULT_MODEL_ID) - parser.add_argument("--output", required=True, type=Path) - parser.add_argument("--weight-format", default="int4", choices=("int4", "int8", "fp16")) - parser.add_argument( - "--install-deps", - action="store_true", - help="Install/upgrade host-side OpenVINO export dependencies before running optimum-cli.", - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - args.output.parent.mkdir(parents=True, exist_ok=True) - - if args.install_deps: - run( - [ - sys.executable, - "-m", - "pip", - "install", - "--upgrade", - "optimum-intel[openvino]", - "openvino-genai", - "openvino-tokenizers", - "huggingface_hub", - ], - ) - - run( - [ - "optimum-cli", - "export", - "openvino", - "--model", - args.model_id, - "--weight-format", - args.weight_format, - str(args.output), - ], - ) - print(f"Exported {args.model_id} to {args.output}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/ai/scripts/prepare_openvino_llm_model.py b/ai/scripts/prepare_openvino_llm_model.py new file mode 100644 index 00000000..8b76f557 --- /dev/null +++ b/ai/scripts/prepare_openvino_llm_model.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Export a Hugging Face causal LLM to an OpenVINO GenAI-ready bundle.""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import venv +from pathlib import Path + + +DEFAULT_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" +REQUIRED_EXPORT_FILES = ( + "openvino_model.xml", + "openvino_model.bin", +) + + +def run(command: list[str]) -> None: + subprocess.check_call(command) + + +def executable_in_venv(venv_dir: Path, executable: str) -> Path: + bin_dir = "Scripts" if os.name == "nt" else "bin" + suffix = ".exe" if os.name == "nt" else "" + return venv_dir / bin_dir / f"{executable}{suffix}" + + +def ensure_venv(venv_dir: Path) -> tuple[Path, Path]: + python = executable_in_venv(venv_dir, "python") + optimum_cli = executable_in_venv(venv_dir, "optimum-cli") + if not python.exists(): + venv.EnvBuilder(with_pip=True).create(venv_dir) + return python, optimum_cli + + +def export_is_complete(output: Path) -> bool: + marker = output / ".openvino_llm_export_complete" + return marker.exists() and all((output / name).exists() for name in REQUIRED_EXPORT_FILES) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--model-id", default=DEFAULT_MODEL_ID) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--weight-format", default="int4", choices=("int4", "int8", "fp16")) + parser.add_argument("--venv", type=Path) + parser.add_argument("--force", action="store_true") + parser.add_argument( + "--install-deps", + action="store_true", + help="Install/upgrade host-side OpenVINO export dependencies before running optimum-cli.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + args.output.parent.mkdir(parents=True, exist_ok=True) + if export_is_complete(args.output) and not args.force: + print(f"Reusing existing OpenVINO model bundle at {args.output}") + return 0 + + python = Path(sys.executable) + optimum_cli = Path("optimum-cli") + if args.venv is not None: + python, optimum_cli = ensure_venv(args.venv) + + if args.install_deps: + run( + [ + str(python), + "-m", + "pip", + "install", + "--upgrade", + "optimum-intel[openvino]", + "openvino-genai", + "openvino-tokenizers", + "huggingface_hub", + ], + ) + + run( + [ + str(optimum_cli), + "export", + "openvino", + "--model", + args.model_id, + "--weight-format", + args.weight_format, + str(args.output), + ], + ) + + (args.output / ".openvino_llm_export_complete").write_text( + f"model_id={args.model_id}\nweight_format={args.weight_format}\n", + encoding="utf-8", + ) + print(f"Exported {args.model_id} to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ai/src/main/cpp/llm_engine.cpp b/ai/src/main/cpp/llm_engine.cpp index 77b0f8e7..4e770a28 100644 --- a/ai/src/main/cpp/llm_engine.cpp +++ b/ai/src/main/cpp/llm_engine.cpp @@ -28,7 +28,7 @@ void LlmEngine::init( const std::string& device ) { if (model_dir.empty()) { - throw std::invalid_argument("Gemma model directory is empty."); + throw std::invalid_argument("OpenVINO LLM model directory is empty."); } const std::string target_device = device.empty() ? "CPU" : device; diff --git a/ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt b/ai/src/main/java/com/itlab/ai/NoteLlmPromptBuilder.kt similarity index 67% rename from ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt rename to ai/src/main/java/com/itlab/ai/NoteLlmPromptBuilder.kt index 84e0c6a5..e80f0825 100644 --- a/ai/src/main/java/com/itlab/ai/GemmaPromptBuilder.kt +++ b/ai/src/main/java/com/itlab/ai/NoteLlmPromptBuilder.kt @@ -1,10 +1,10 @@ package com.itlab.ai -class GemmaPromptBuilder( - private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.gemma3SmallIt(), +class NoteLlmPromptBuilder( + private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.defaultAndroid(), ) { fun summaryPrompt(text: String): String = - gemmaUserTurn( + chatPrompt( """ Summarize the note in 1-2 concise sentences. Return only the summary, without markdown or a preamble. @@ -15,7 +15,7 @@ class GemmaPromptBuilder( ) fun tagsPrompt(text: String): String = - gemmaUserTurn( + chatPrompt( """ Suggest up to ${config.maxTags} short tags for the note. Return only a comma-separated tag list. Use lowercase tags. @@ -25,12 +25,15 @@ class GemmaPromptBuilder( """.trimIndent(), ) - private fun gemmaUserTurn(instruction: String): String = + private fun chatPrompt(instruction: String): String = """ - user + <|im_start|>system + You are a concise assistant for a notes app. + <|im_end|> + <|im_start|>user $instruction - - model + <|im_end|> + <|im_start|>assistant """.trimIndent() private fun trimInput(text: String): String = diff --git a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt index 098c6329..8b0bfc96 100644 --- a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt +++ b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt @@ -13,11 +13,11 @@ data class OnDeviceLlmConfig( val maxTags: Int, ) { companion object { - fun gemma3SmallIt(): OnDeviceLlmConfig = + fun defaultAndroid(): OnDeviceLlmConfig = OnDeviceLlmConfig( - modelId = "google/gemma-3-270m-it", - assetModelDir = "models/gemma3-270m-it-openvino", - modelDirName = "gemma3-270m-it-openvino", + modelId = "Qwen/Qwen2.5-0.5B-Instruct", + assetModelDir = "models/on-device-llm-openvino", + modelDirName = "on-device-llm-openvino", device = "CPU", nativeLibraryName = "notes_llm", cacheDirName = "openvino-genai-cache", diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt b/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt index 44592f13..20e31b67 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoEngine.kt @@ -2,8 +2,8 @@ package com.itlab.ai class OpenVinoEngine( private val llmBackend: LlmInferenceBackend = UnavailableLlmBackend(), - private val promptBuilder: GemmaPromptBuilder = GemmaPromptBuilder(), - private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.gemma3SmallIt(), + private val promptBuilder: NoteLlmPromptBuilder = NoteLlmPromptBuilder(), + private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.defaultAndroid(), ) { fun runLlmSummary(text: String): String { if (text.isBlank()) return "" diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt index 61a3b697..f7f75cc8 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt @@ -6,7 +6,7 @@ import java.io.IOException class OpenVinoGenAiBackend( context: Context, - private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.gemma3SmallIt(), + private val config: OnDeviceLlmConfig = OnDeviceLlmConfig.defaultAndroid(), ) : LlmInferenceBackend, AutoCloseable { private val appContext = context.applicationContext @@ -60,8 +60,8 @@ class OpenVinoGenAiBackend( if (!assetDirectoryExists(config.assetModelDir)) { throw MissingLlmRuntimeException( - "Gemma 3 OpenVINO model assets are missing at assets/${config.assetModelDir}. " + - "Run :ai:stageGemma3OpenVinoAssets before packaging a local GenAI build.", + "OpenVINO LLM model assets are missing at assets/${config.assetModelDir}. " + + "Gradle should run :ai:stageOpenVinoLlmAssets during preBuild.", ) } diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt b/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt index de3741f0..cdf5313d 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoNoteAiService.kt @@ -21,7 +21,7 @@ class OpenVinoNoteAiService( } override suspend fun tagIMGs(img: List): Set { - // Gemma 3 is a text LLM path. Image tagging stays in a separate AI direction. + // This is a text LLM path. Image tagging stays in a separate AI direction. return emptySet() } } diff --git a/ai/src/main/java/com/itlab/ai/di/AiModule.kt b/ai/src/main/java/com/itlab/ai/di/AiModule.kt index 6b1decc6..abff8a0b 100644 --- a/ai/src/main/java/com/itlab/ai/di/AiModule.kt +++ b/ai/src/main/java/com/itlab/ai/di/AiModule.kt @@ -1,7 +1,7 @@ package com.itlab.ai.di -import com.itlab.ai.GemmaPromptBuilder import com.itlab.ai.LlmInferenceBackend +import com.itlab.ai.NoteLlmPromptBuilder import com.itlab.ai.OnDeviceLlmConfig import com.itlab.ai.OpenVinoEngine import com.itlab.ai.OpenVinoGenAiBackend @@ -13,8 +13,8 @@ import org.koin.dsl.module val aiModule = module { - single { OnDeviceLlmConfig.gemma3SmallIt() } - single { GemmaPromptBuilder(get()) } + single { OnDeviceLlmConfig.defaultAndroid() } + single { NoteLlmPromptBuilder(get()) } single { OpenVinoGenAiBackend(androidContext(), get()) } single { ResultProcessor() } single { diff --git a/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt b/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt index 76a71470..9a6ccdd4 100644 --- a/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt +++ b/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt @@ -46,8 +46,8 @@ class OpenVinoAiLayerTest { val result = service.summarize("Long note") assertEquals("Summary text", result) - assertEquals(OnDeviceLlmConfig.gemma3SmallIt().summaryMaxNewTokens, backend.lastMaxNewTokens) - assertTrue(backend.lastPrompt.orEmpty().contains("user")) + assertEquals(OnDeviceLlmConfig.defaultAndroid().summaryMaxNewTokens, backend.lastMaxNewTokens) + assertTrue(backend.lastPrompt.orEmpty().contains("<|im_start|>user")) assertTrue(backend.lastPrompt.orEmpty().contains("Summarize the note")) assertTrue(backend.lastPrompt.orEmpty().contains("Long note")) } @@ -65,13 +65,13 @@ class OpenVinoAiLayerTest { val result = service.tagTXT("OpenVINO note") assertEquals(setOf("kotlin", "notes", "ai"), result) - assertEquals(OnDeviceLlmConfig.gemma3SmallIt().tagsMaxNewTokens, backend.lastMaxNewTokens) + assertEquals(OnDeviceLlmConfig.defaultAndroid().tagsMaxNewTokens, backend.lastMaxNewTokens) assertTrue(backend.lastPrompt.orEmpty().contains("Suggest up to")) assertTrue(backend.lastPrompt.orEmpty().contains("OpenVINO note")) } @Test - fun tagIMGs_returnsEmptySetBecauseVisionIsSeparateFromGemmaLlm() = + fun tagIMGs_returnsEmptySetBecauseVisionIsSeparateFromTextLlm() = runBlocking { val service = OpenVinoNoteAiService( @@ -85,9 +85,9 @@ class OpenVinoAiLayerTest { } @Test - fun gemmaPromptBuilder_trimsLargeInput() { - val config = OnDeviceLlmConfig.gemma3SmallIt().copy(maxInputChars = 5) - val builder = GemmaPromptBuilder(config) + fun noteLlmPromptBuilder_trimsLargeInput() { + val config = OnDeviceLlmConfig.defaultAndroid().copy(maxInputChars = 5) + val builder = NoteLlmPromptBuilder(config) val prompt = builder.summaryPrompt("123456789") From 8d5de627b8a1f356c24c623dac30bc21a8244d92 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Fri, 15 May 2026 17:58:16 +0200 Subject: [PATCH 3/9] feat(ai): integrate Qwen OpenVINO GenAI runtime --- .github/workflows/codeql.yml | 1 + .github/workflows/quality.yml | 1 + .github/workflows/release.yml | 1 + .gitignore | 2 + ai/.gitignore | 2 + ai/build.gradle.kts | 173 +++++++++++++-- ai/scripts/download_openvino_prebuild.py | 198 ++++++++++++++++++ ai/scripts/prepare_openvino_llm_model.py | 136 ++++++++++-- ai/src/main/cpp/CMakeLists.txt | 33 ++- ai/src/main/cpp/llm_engine.cpp | 14 +- .../main/java/com/itlab/ai/NativeLlmBridge.kt | 10 +- .../java/com/itlab/ai/OnDeviceLlmConfig.kt | 2 +- .../java/com/itlab/ai/OpenVinoGenAiBackend.kt | 36 +++- .../com/itlab/ai/OpenVinoNativeRuntime.kt | 153 ++++++++++++++ app/build.gradle.kts | 5 + .../notes/OpenVinoLlmInstrumentedTest.kt | 25 +++ 16 files changed, 739 insertions(+), 53 deletions(-) create mode 100644 ai/scripts/download_openvino_prebuild.py create mode 100644 ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt create mode 100644 app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0a5f0549..fa853984 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -6,6 +6,7 @@ on: env: ANDROID_API_LEVEL: "37.0" ANDROID_BUILD_TOOLS: "37.0.0" + GITHUB_TOKEN: ${{ github.token }} JAVA_VERSION: "21" jobs: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 653c13bc..5348a4ac 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -6,6 +6,7 @@ on: env: ANDROID_API_LEVEL: "37.0" ANDROID_BUILD_TOOLS: "37.0.0" + GITHUB_TOKEN: ${{ github.token }} JAVA_VERSION: "21" jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1acfc167..b5aa88e5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,7 @@ on: env: ANDROID_API_LEVEL: "37.0" ANDROID_BUILD_TOOLS: "37.0.0" + GITHUB_TOKEN: ${{ github.token }} JAVA_VERSION: "21" jobs: diff --git a/.gitignore b/.gitignore index 5c915a53..9ccb348b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ .cxx local.properties .idea/ +__pycache__/ +/tmp/ diff --git a/ai/.gitignore b/ai/.gitignore index 2223261a..a602e597 100644 --- a/ai/.gitignore +++ b/ai/.gitignore @@ -1,2 +1,4 @@ /build +/libs/ +/src/main/jniLibs/ /src/main/assets/models/on-device-llm-openvino/ diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts index 17460ae9..a9662636 100644 --- a/ai/build.gradle.kts +++ b/ai/build.gradle.kts @@ -5,7 +5,28 @@ plugins { } val openvinoGenAiAndroidDir = providers.gradleProperty("openvinoGenAiAndroidDir") -val onDeviceLlmModelId = "Qwen/Qwen2.5-0.5B-Instruct" +val openvinoAndroidPrebuildRepo = + providers.gradleProperty("openvinoAndroidPrebuildRepo").orElse("embedded-dev-research/openvino-notes") +val openvinoAndroidPrebuildRunId = providers.gradleProperty("openvinoAndroidPrebuildRunId").orElse("25926372315") +val openvinoAndroidPrebuildArtifactName = + providers.gradleProperty("openvinoAndroidPrebuildArtifactName").orElse("openvino-android-arm64-v8a-master.zip") +val openvinoAndroidPrebuildPackageName = + providers.gradleProperty("openvinoAndroidPrebuildPackageName").orElse("openvino-android-arm64-v8a-master") +val openvinoAndroidPrebuildDownloadDir = + layout.buildDirectory.dir("openvino/prebuild/download/${openvinoAndroidPrebuildRunId.get()}") +val openvinoAndroidPrebuildExtractDir = + layout.buildDirectory.dir("openvino/prebuild/extracted/${openvinoAndroidPrebuildRunId.get()}") +val openvinoAndroidPrebuildArchive = + openvinoAndroidPrebuildDownloadDir.map { it.file(openvinoAndroidPrebuildArtifactName.get()) } +val openvinoAndroidPrebuildPackageDir = + openvinoAndroidPrebuildExtractDir.map { it.dir(openvinoAndroidPrebuildPackageName.get()) } +val resolvedOpenvinoGenAiAndroidDir = + openvinoGenAiAndroidDir.orElse(openvinoAndroidPrebuildPackageDir.map { it.asFile.absolutePath }) +val openvinoAndroidAbi = "arm64-v8a" +val openvinoRuntimeAssetRootDir = layout.buildDirectory.dir("generated/openvinoRuntimeAssets") +val openvinoRuntimeAssetDir = openvinoRuntimeAssetRootDir.map { it.dir("openvino-runtime") } +val onDeviceLlmModelId = + providers.gradleProperty("onDeviceLlmModelId").orElse("OpenVINO/Qwen3-0.6B-int4-ov") val onDeviceLlmWeightFormat = providers.gradleProperty("onDeviceLlmWeightFormat").orElse("int4") val onDeviceLlmPythonVenvDir = layout.buildDirectory.dir("llm/python-venv") val onDeviceLlmExportDir = layout.buildDirectory.dir("llm/on-device-llm-openvino") @@ -22,28 +43,40 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles("consumer-rules.pro") - } - if (openvinoGenAiAndroidDir.isPresent) { - defaultConfig { - externalNativeBuild { - cmake { - arguments += - listOf( - "-DOPENVINO_GENAI_ANDROID_DIR=${openvinoGenAiAndroidDir.get()}", - "-DANDROID_STL=c++_shared", - ) - } - } + ndk { + abiFilters += openvinoAndroidAbi } externalNativeBuild { cmake { - path = file("src/main/cpp/CMakeLists.txt") + arguments += + listOf( + "-DOPENVINO_GENAI_ANDROID_DIR=${resolvedOpenvinoGenAiAndroidDir.get()}", + "-DANDROID_STL=c++_shared", + ) } } } + externalNativeBuild { + cmake { + path = file("src/main/cpp/CMakeLists.txt") + } + } + + sourceSets { + getByName("main") { + jniLibs.directories.clear() + jniLibs.directories.add( + file(resolvedOpenvinoGenAiAndroidDir.get()) + .resolve("android-jni") + .absolutePath, + ) + assets.directories.add(openvinoRuntimeAssetRootDir.get().asFile.absolutePath) + } + } + buildTypes { release { isMinifyEnabled = false @@ -53,6 +86,13 @@ android { ) } } + + packaging { + jniLibs { + pickFirsts += "lib/**/libc++_shared.so" + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 @@ -77,6 +117,107 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) } +val downloadOpenVinoAndroidPrebuild by tasks.registering(Exec::class) { + group = "ai" + description = "Download the Android OpenVINO prebuild artifact from PR 89." + + onlyIf { !openvinoGenAiAndroidDir.isPresent && !openvinoAndroidPrebuildArchive.get().asFile.isFile } + outputs.file(openvinoAndroidPrebuildArchive) + + doFirst { + openvinoAndroidPrebuildDownloadDir.get().asFile.mkdirs() + } + + commandLine( + "python3", + "scripts/download_openvino_prebuild.py", + "--repo", + openvinoAndroidPrebuildRepo.get(), + "--run-id", + openvinoAndroidPrebuildRunId.get(), + "--artifact-name", + openvinoAndroidPrebuildArtifactName.get(), + "--output", + openvinoAndroidPrebuildArchive.get().asFile.absolutePath, + ) +} + +val extractOpenVinoAndroidPrebuild by tasks.registering(Copy::class) { + group = "ai" + description = "Extract the Android OpenVINO prebuild for native linking and packaging." + + onlyIf { !openvinoGenAiAndroidDir.isPresent } + dependsOn(downloadOpenVinoAndroidPrebuild) + from({ zipTree(openvinoAndroidPrebuildArchive.get().asFile) }) + into(openvinoAndroidPrebuildExtractDir) + outputs.dir(openvinoAndroidPrebuildPackageDir) +} + +val stageOpenVinoRuntimeAssets by tasks.registering { + group = "ai" + description = "Stage OpenVINO runtime metadata that must live next to extracted native libraries." + + dependsOn(extractOpenVinoAndroidPrebuild) + inputs.dir(resolvedOpenvinoGenAiAndroidDir) + outputs.dir(openvinoRuntimeAssetDir) + + doLast { + val packageDir = file(resolvedOpenvinoGenAiAndroidDir.get()) + val jniDir = packageDir.resolve("android-jni/$openvinoAndroidAbi") + val runtimeLibDir = packageDir.resolve("runtime/lib") + val outputRoot = openvinoRuntimeAssetDir.get().asFile + outputRoot.deleteRecursively() + outputRoot.mkdirs() + + val pluginXmlCandidates = + listOf(runtimeLibDir, jniDir) + .flatMap { root -> + if (root.isDirectory) { + val candidates = + root.walkTopDown().filter { candidate -> + candidate.isFile && + candidate.name == "plugins.xml" + } + candidates.toList() + } else { + emptyList() + } + } + val installedPluginXml = pluginXmlCandidates.firstOrNull { it.readText().contains(" + + + + + """.trimIndent() + "\n", + ) + } + } +} + tasks.register("prepareOpenVinoLlmModel") { group = "ai" description = "Export the bundled on-device LLM to an OpenVINO GenAI model bundle." @@ -89,7 +230,7 @@ tasks.register("prepareOpenVinoLlmModel") { "python3", "scripts/prepare_openvino_llm_model.py", "--model-id", - onDeviceLlmModelId, + onDeviceLlmModelId.get(), "--weight-format", onDeviceLlmWeightFormat.get(), "--output", @@ -110,5 +251,7 @@ tasks.register("stageOpenVinoLlmAssets") { } tasks.named("preBuild") { + dependsOn(extractOpenVinoAndroidPrebuild) + dependsOn(stageOpenVinoRuntimeAssets) dependsOn("stageOpenVinoLlmAssets") } diff --git a/ai/scripts/download_openvino_prebuild.py b/ai/scripts/download_openvino_prebuild.py new file mode 100644 index 00000000..f990168b --- /dev/null +++ b/ai/scripts/download_openvino_prebuild.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import http.client +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +import zipfile +from pathlib import Path + +CHUNK_SIZE = 8 * 1024 * 1024 +PROGRESS_STEP = 64 * 1024 * 1024 + + +def github_token() -> str: + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + return token + + try: + return subprocess.check_output(["gh", "auth", "token"], text=True).strip() + except (FileNotFoundError, subprocess.CalledProcessError) as exc: + raise SystemExit("Set GITHUB_TOKEN/GH_TOKEN or authenticate GitHub CLI with `gh auth login`.") from exc + + +def request(url: str, token: str, timeout: int) -> urllib.request.Request: + return urllib.request.Request( + url, + headers={ + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "openvino-notes-prebuild-downloader", + }, + ) + + +def download_request(url: str, range_start: int = 0) -> urllib.request.Request: + headers = { + "User-Agent": "openvino-notes-prebuild-downloader", + } + if range_start > 0: + headers["Range"] = f"bytes={range_start}-" + + return urllib.request.Request( + url, + headers=headers, + ) + + +class NoRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 + return None + + +def load_json(url: str, token: str, timeout: int) -> dict: + with urllib.request.urlopen(request(url, token, timeout), timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def find_artifact_download_url(repo: str, run_id: str, artifact_name: str, token: str, timeout: int) -> str: + data = load_json(f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/artifacts", token, timeout) + for artifact in data.get("artifacts", []): + if artifact.get("name") == artifact_name and not artifact.get("expired", False): + return artifact["archive_download_url"] + + available = ", ".join(artifact.get("name", "") for artifact in data.get("artifacts", [])) + raise SystemExit(f"Artifact '{artifact_name}' was not found in run {run_id}. Available artifacts: {available}") + + +def resolve_artifact_archive_url(url: str, token: str, timeout: int) -> str: + opener = urllib.request.build_opener(NoRedirectHandler) + + try: + with opener.open(request(url, token, timeout), timeout=timeout) as response: + return response.url + except urllib.error.HTTPError as exc: + if exc.code in (301, 302, 303, 307, 308): + location = exc.headers.get("Location") + if location: + return location + raise + + +def stream_download(url: str, temp_path: Path, timeout: int) -> None: + resume_from = temp_path.stat().st_size if temp_path.exists() else 0 + + with urllib.request.urlopen(download_request(url, resume_from), timeout=timeout) as response: + status = response.status + append = resume_from > 0 and status == 206 + if resume_from > 0 and not append: + print("Server did not accept byte-range resume; restarting download.", flush=True) + resume_from = 0 + + mode = "ab" if append else "wb" + downloaded = resume_from + next_report = ((downloaded // PROGRESS_STEP) + 1) * PROGRESS_STEP + + with temp_path.open(mode) as output: + while True: + chunk = response.read(CHUNK_SIZE) + if not chunk: + break + + output.write(chunk) + downloaded += len(chunk) + + if downloaded >= next_report: + print(f"Downloaded {downloaded // (1024 * 1024)} MiB...", flush=True) + next_report += PROGRESS_STEP + + +def download_with_retries(url: str, destination: Path, token: str, timeout: int, retries: int) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + temp_path = destination.with_suffix(destination.suffix + ".tmp") + + for attempt in range(1, retries + 1): + try: + print(f"Downloading GitHub artifact archive, attempt {attempt}/{retries}") + archive_url = resolve_artifact_archive_url(url, token, timeout) + stream_download(archive_url, temp_path, timeout) + temp_path.replace(destination) + return + except urllib.error.HTTPError as exc: + if exc.code == 416 and temp_path.exists() and temp_path.stat().st_size > 0: + temp_path.replace(destination) + return + if attempt == retries: + raise + delay_seconds = min(30, attempt * 5) + print(f"Download failed: {exc}. Retrying in {delay_seconds}s.", file=sys.stderr) + time.sleep(delay_seconds) + except (http.client.IncompleteRead, urllib.error.URLError, TimeoutError, OSError) as exc: + if attempt == retries: + raise + delay_seconds = min(30, attempt * 5) + print(f"Download failed: {exc}. Retrying in {delay_seconds}s.", file=sys.stderr) + time.sleep(delay_seconds) + + +def extract_inner_zip(artifact_archive: Path, inner_name: str, output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + temp_output = output.with_suffix(output.suffix + ".tmp") + + if temp_output.exists(): + temp_output.unlink() + + with zipfile.ZipFile(artifact_archive) as archive: + names = archive.namelist() + member_name = inner_name if inner_name in names else None + if member_name is None and len(names) == 1: + member_name = names[0] + if member_name is None: + raise SystemExit(f"Artifact archive does not contain '{inner_name}'. Members: {', '.join(names)}") + + with archive.open(member_name) as source, temp_output.open("wb") as target: + while True: + chunk = source.read(CHUNK_SIZE) + if not chunk: + break + target.write(chunk) + + temp_output.replace(output) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--repo", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--artifact-name", required=True) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--timeout", type=int, default=60) + parser.add_argument("--retries", type=int, default=20) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.output.is_file() and args.output.stat().st_size > 0: + print(f"Reusing existing prebuild archive: {args.output}") + return + + token = github_token() + download_url = find_artifact_download_url(args.repo, args.run_id, args.artifact_name, token, args.timeout) + artifact_archive = args.output.with_suffix(args.output.suffix + ".github-artifact.zip") + + download_with_retries(download_url, artifact_archive, token, args.timeout, args.retries) + extract_inner_zip(artifact_archive, args.artifact_name, args.output) + print(f"Downloaded prebuild archive: {args.output}") + + +if __name__ == "__main__": + main() diff --git a/ai/scripts/prepare_openvino_llm_model.py b/ai/scripts/prepare_openvino_llm_model.py index 8b76f557..c71aae95 100644 --- a/ai/scripts/prepare_openvino_llm_model.py +++ b/ai/scripts/prepare_openvino_llm_model.py @@ -4,18 +4,30 @@ from __future__ import annotations import argparse +import json import os import subprocess import sys +import urllib.parse +import urllib.request import venv from pathlib import Path -DEFAULT_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct" +DEFAULT_MODEL_ID = "OpenVINO/Qwen3-0.6B-int4-ov" REQUIRED_EXPORT_FILES = ( "openvino_model.xml", "openvino_model.bin", ) +OPENVINO_BUNDLE_PATTERNS = ( + "*.json", + "*.txt", + "*.xml", + "*.bin", + "merges.txt", + "vocab.json", + "tokenizer.model", +) def run(command: list[str]) -> None: @@ -36,9 +48,93 @@ def ensure_venv(venv_dir: Path) -> tuple[Path, Path]: return python, optimum_cli -def export_is_complete(output: Path) -> bool: +def export_is_complete(output: Path, model_id: str, weight_format: str) -> bool: marker = output / ".openvino_llm_export_complete" - return marker.exists() and all((output / name).exists() for name in REQUIRED_EXPORT_FILES) + if not marker.exists() or not all((output / name).exists() for name in REQUIRED_EXPORT_FILES): + return False + + marker_values = dict( + line.split("=", 1) + for line in marker.read_text(encoding="utf-8").splitlines() + if "=" in line + ) + return marker_values.get("model_id") == model_id and marker_values.get("weight_format") == weight_format + + +def repo_has_openvino_bundle(model_id: str) -> bool: + try: + url = f"https://huggingface.co/api/models/{urllib.parse.quote(model_id, safe='/')}?blobs=true" + with urllib.request.urlopen(url, timeout=45) as response: + model_info = json.load(response) + files = {sibling.get("rfilename") for sibling in model_info.get("siblings", [])} + except Exception: + return False + return all(name in files for name in REQUIRED_EXPORT_FILES) + + +def download_openvino_bundle(python: Path, model_id: str, output: Path) -> None: + script = """ +import shutil +import sys +from pathlib import Path + +from huggingface_hub import snapshot_download + +model_id = sys.argv[1] +output = Path(sys.argv[2]) +patterns = sys.argv[3].split("\\n") + +snapshot_dir = Path(snapshot_download(repo_id=model_id, allow_patterns=patterns)) + +if output.exists(): + shutil.rmtree(output) +output.mkdir(parents=True) + +for source in snapshot_dir.rglob("*"): + if not source.is_file(): + continue + relative = source.relative_to(snapshot_dir) + target = output / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) +""" + run( + [ + str(python), + "-c", + script, + model_id, + str(output), + "\n".join(OPENVINO_BUNDLE_PATTERNS), + ], + ) + + +def ensure_openvino_tokenizer(venv_dir: Path | None, output: Path) -> None: + if (output / "openvino_tokenizer.xml").is_file(): + return + + if not (output / "tokenizer.json").is_file(): + return + + if venv_dir is None: + convert_tokenizer = Path("convert_tokenizer") + else: + convert_tokenizer = executable_in_venv(venv_dir, "convert_tokenizer") + + run( + [ + str(convert_tokenizer), + str(output), + "--output", + str(output), + "--with-detokenizer", + "--tokenizer-output-type", + "i64", + "--detokenizer-input-type", + "i64", + ], + ) def parse_args() -> argparse.Namespace: @@ -59,7 +155,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() args.output.parent.mkdir(parents=True, exist_ok=True) - if export_is_complete(args.output) and not args.force: + if export_is_complete(args.output, args.model_id, args.weight_format) and not args.force: print(f"Reusing existing OpenVINO model bundle at {args.output}") return 0 @@ -83,24 +179,30 @@ def main() -> int: ], ) - run( - [ - str(optimum_cli), - "export", - "openvino", - "--model", - args.model_id, - "--weight-format", - args.weight_format, - str(args.output), - ], - ) + downloaded_openvino_bundle = repo_has_openvino_bundle(args.model_id) + if downloaded_openvino_bundle: + download_openvino_bundle(python, args.model_id, args.output) + ensure_openvino_tokenizer(args.venv, args.output) + else: + run( + [ + str(optimum_cli), + "export", + "openvino", + "--model", + args.model_id, + "--weight-format", + args.weight_format, + str(args.output), + ], + ) (args.output / ".openvino_llm_export_complete").write_text( f"model_id={args.model_id}\nweight_format={args.weight_format}\n", encoding="utf-8", ) - print(f"Exported {args.model_id} to {args.output}") + action = "Downloaded" if downloaded_openvino_bundle else "Exported" + print(f"{action} {args.model_id} to {args.output}") return 0 diff --git a/ai/src/main/cpp/CMakeLists.txt b/ai/src/main/cpp/CMakeLists.txt index 1137a8a8..b781f5c1 100644 --- a/ai/src/main/cpp/CMakeLists.txt +++ b/ai/src/main/cpp/CMakeLists.txt @@ -9,6 +9,34 @@ if (NOT DEFINED OPENVINO_GENAI_ANDROID_DIR) message(FATAL_ERROR "Set -DOPENVINO_GENAI_ANDROID_DIR to an Android OpenVINO GenAI package root.") endif () +set(OPENVINO_PREBUILD_INCLUDE_DIRS "") +foreach (include_dir + "${OPENVINO_GENAI_ANDROID_DIR}/include" + "${OPENVINO_GENAI_ANDROID_DIR}/runtime/include") + if (EXISTS "${include_dir}") + list(APPEND OPENVINO_PREBUILD_INCLUDE_DIRS "${include_dir}") + endif () +endforeach () + +if (NOT OPENVINO_PREBUILD_INCLUDE_DIRS) + message(FATAL_ERROR "OpenVINO include directory was not found in ${OPENVINO_GENAI_ANDROID_DIR}.") +endif () + +set(OPENVINO_PREBUILD_LIBRARY_DIRS "") +foreach (library_dir + "${OPENVINO_GENAI_ANDROID_DIR}/android-jni/${ANDROID_ABI}" + "${OPENVINO_GENAI_ANDROID_DIR}/runtime/lib/aarch64" + "${OPENVINO_GENAI_ANDROID_DIR}/lib/${ANDROID_ABI}" + "${OPENVINO_GENAI_ANDROID_DIR}/${ANDROID_ABI}/lib") + if (EXISTS "${library_dir}") + list(APPEND OPENVINO_PREBUILD_LIBRARY_DIRS "${library_dir}") + endif () +endforeach () + +if (NOT OPENVINO_PREBUILD_LIBRARY_DIRS) + message(FATAL_ERROR "OpenVINO library directory was not found in ${OPENVINO_GENAI_ANDROID_DIR}.") +endif () + add_library( notes_llm SHARED @@ -19,14 +47,13 @@ add_library( target_include_directories( notes_llm PRIVATE - "${OPENVINO_GENAI_ANDROID_DIR}/include" + ${OPENVINO_PREBUILD_INCLUDE_DIRS} ) target_link_directories( notes_llm PRIVATE - "${OPENVINO_GENAI_ANDROID_DIR}/lib/${ANDROID_ABI}" - "${OPENVINO_GENAI_ANDROID_DIR}/${ANDROID_ABI}/lib" + ${OPENVINO_PREBUILD_LIBRARY_DIRS} ) find_library(log_lib log) diff --git a/ai/src/main/cpp/llm_engine.cpp b/ai/src/main/cpp/llm_engine.cpp index 4e770a28..2dc2d7d7 100644 --- a/ai/src/main/cpp/llm_engine.cpp +++ b/ai/src/main/cpp/llm_engine.cpp @@ -4,6 +4,7 @@ #include "openvino/genai/llm_pipeline.hpp" +#include #include #include #include @@ -20,7 +21,11 @@ struct LlmEngine::Impl { LlmEngine::LlmEngine() = default; -LlmEngine::~LlmEngine() = default; +LlmEngine::~LlmEngine() { + // OpenVINO GenAI Android teardown currently crashes after successful generation. + // Keep the process-lifetime pipeline allocated instead of running its destructor. + impl_.release(); +} void LlmEngine::init( const std::string& model_dir, @@ -30,12 +35,17 @@ void LlmEngine::init( if (model_dir.empty()) { throw std::invalid_argument("OpenVINO LLM model directory is empty."); } + if (impl_) { + return; + } const std::string target_device = device.empty() ? "CPU" : device; ov::AnyMap pipeline_config; if (!cache_dir.empty()) { pipeline_config.insert({ov::cache_dir(cache_dir)}); } + pipeline_config.insert({ov::hint::inference_precision(ov::element::f32)}); + pipeline_config.insert({ov::hint::dynamic_quantization_group_size(std::numeric_limits::max())}); auto pipeline = std::make_unique(model_dir, target_device, pipeline_config); impl_ = std::make_unique(std::move(pipeline)); @@ -56,7 +66,7 @@ std::string LlmEngine::generate(const std::string& prompt, int max_new_tokens) { } void LlmEngine::close() { - impl_.reset(); + // The process owns the GenAI pipeline for the same reason as the destructor above. } } // namespace notes::ai diff --git a/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt b/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt index ed531584..7340d8de 100644 --- a/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt +++ b/ai/src/main/java/com/itlab/ai/NativeLlmBridge.kt @@ -1,6 +1,6 @@ package com.itlab.ai -class NativeLlmBridge private constructor() : AutoCloseable { +class NativeLlmBridge internal constructor() : AutoCloseable { external fun init( modelDir: String, cacheDir: String, @@ -13,12 +13,4 @@ class NativeLlmBridge private constructor() : AutoCloseable { ): String external override fun close() - - companion object { - fun load(libraryName: String): Result = - runCatching { - System.loadLibrary(libraryName) - NativeLlmBridge() - } - } } diff --git a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt index 8b0bfc96..c0072c3c 100644 --- a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt +++ b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt @@ -15,7 +15,7 @@ data class OnDeviceLlmConfig( companion object { fun defaultAndroid(): OnDeviceLlmConfig = OnDeviceLlmConfig( - modelId = "Qwen/Qwen2.5-0.5B-Instruct", + modelId = "OpenVINO/Qwen3-0.6B-int4-ov", assetModelDir = "models/on-device-llm-openvino", modelDirName = "on-device-llm-openvino", device = "CPU", diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt index f7f75cc8..b8c88672 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt @@ -32,10 +32,15 @@ class OpenVinoGenAiBackend( val cacheDir = File(appContext.cacheDir, config.cacheDirName) .apply { mkdirs() } + val runtime = + OpenVinoNativeRuntime.prepare( + context = appContext, + notesLibraryName = config.nativeLibraryName, + ) val loaded = - NativeLlmBridge - .load(config.nativeLibraryName) + runtime + .loadBridge() .getOrElse { cause -> throw MissingLlmRuntimeException( "OpenVINO GenAI native library '${config.nativeLibraryName}' is not packaged.", @@ -54,10 +59,6 @@ class OpenVinoGenAiBackend( private fun ensureModelDirectory(): File { val targetDir = File(appContext.filesDir, "models/${config.modelDirName}") - if (targetDir.exists() && !targetDir.list().isNullOrEmpty()) { - return targetDir - } - if (!assetDirectoryExists(config.assetModelDir)) { throw MissingLlmRuntimeException( "OpenVINO LLM model assets are missing at assets/${config.assetModelDir}. " + @@ -65,8 +66,16 @@ class OpenVinoGenAiBackend( ) } + val assetMarker = readAssetText("${config.assetModelDir}/$MODEL_MARKER_FILE") + val targetMarker = targetDir.resolve(MODEL_MARKER_FILE).takeIf { it.isFile }?.readText() + if (targetDir.exists() && !targetDir.list().isNullOrEmpty() && assetMarker == targetMarker) { + return targetDir + } + + targetDir.deleteRecursively() targetDir.mkdirs() copyAssetDirectory(config.assetModelDir, targetDir) + File(appContext.cacheDir, config.cacheDirName).deleteRecursively() return targetDir } @@ -77,6 +86,17 @@ class OpenVinoGenAiBackend( false } + private fun readAssetText(assetPath: String): String? = + try { + appContext + .assets + .open(assetPath) + .bufferedReader() + .use { it.readText() } + } catch (_: IOException) { + null + } + private fun copyAssetDirectory( assetPath: String, targetDir: File, @@ -101,4 +121,8 @@ class OpenVinoGenAiBackend( } } } + + private companion object { + const val MODEL_MARKER_FILE = ".openvino_llm_export_complete" + } } diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt b/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt new file mode 100644 index 00000000..de4a1e13 --- /dev/null +++ b/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt @@ -0,0 +1,153 @@ +package com.itlab.ai + +import android.content.Context +import java.io.File + +internal class OpenVinoNativeRuntime private constructor( + private val runtimeDir: File, + private val notesLibrary: File, +) { + fun loadBridge(): Result = + runCatching { + preferredLibraryLoadOrder + .map { File(runtimeDir, it) } + .filter { it.isFile } + .forEach { System.load(it.absolutePath) } + System.load(notesLibrary.absolutePath) + NativeLlmBridge() + } + + companion object { + private const val RUNTIME_ASSET_DIR = "openvino-runtime" + + private val preferredLibraryLoadOrder = + listOf( + "libc++_shared.so", + "libtbb.so", + "libtbbmalloc.so", + "libtbbmalloc_proxy.so", + "libopenvino.so", + "libopenvino_c.so", + "libopenvino_ir_frontend.so", + "libopenvino_tokenizers.so", + "libopenvino_arm_cpu_plugin.so", + "libopenvino_auto_plugin.so", + "libopenvino_hetero_plugin.so", + "libopenvino_auto_batch_plugin.so", + "libopenvino_genai.so", + "libopenvino_genai_c.so", + ) + + fun prepare( + context: Context, + notesLibraryName: String, + ): OpenVinoNativeRuntime { + val appContext = context.applicationContext + val nativeLibraryDir = + File(appContext.applicationInfo.nativeLibraryDir) + .takeIf { it.isDirectory } + ?: throw MissingLlmRuntimeException( + "Android native library directory is not available. " + + "The app must use legacy JNI packaging for OpenVINO GenAI.", + ) + val notesLibrary = nativeLibraryDir.resolve("lib$notesLibraryName.so") + if (!notesLibrary.isFile) { + throw MissingLlmRuntimeException("Native LLM bridge is missing: ${notesLibrary.absolutePath}") + } + + val runtimeDir = File(appContext.filesDir, "openvino-runtime/${nativeLibraryDir.name}") + runtimeDir.mkdirs() + copyOpenVinoLibraries(nativeLibraryDir, runtimeDir) + copyAssetDirectory(appContext, RUNTIME_ASSET_DIR, runtimeDir) + copyPluginLibrariesToVersionDirs(runtimeDir) + return OpenVinoNativeRuntime(runtimeDir, notesLibrary) + } + + private fun copyOpenVinoLibraries( + nativeLibraryDir: File, + runtimeDir: File, + ) { + val libraryFiles = + nativeLibraryDir.listFiles { file -> + file.isFile && + file.name.endsWith(".so") && + ( + file.name.startsWith("libopenvino") || + file.name.startsWith("libtbb") || + file.name == "libc++_shared.so" + ) + } + val libraries = libraryFiles?.toList().orEmpty() + if (libraries.none { it.name == "libopenvino.so" }) { + throw MissingLlmRuntimeException("OpenVINO runtime library is missing in $nativeLibraryDir") + } + if (libraries.none { it.name == "libopenvino_genai.so" }) { + throw MissingLlmRuntimeException("OpenVINO GenAI library is missing in $nativeLibraryDir") + } + + libraries.forEach { source -> + source.copyToIfChanged(runtimeDir.resolve(source.name)) + } + } + + private fun copyPluginLibrariesToVersionDirs(runtimeDir: File) { + val pluginLibraryFiles = + runtimeDir.listFiles { file -> + file.isFile && + file.name.startsWith("libopenvino_") && + file.name.endsWith("_plugin.so") + } + val pluginLibraries = pluginLibraryFiles?.toList().orEmpty() + if (pluginLibraries.isEmpty()) { + return + } + + runtimeDir + .listFiles { file -> file.isDirectory && file.name.startsWith("openvino-") } + ?.forEach { pluginDir -> + pluginLibraries.forEach { library -> + library.copyToIfChanged(pluginDir.resolve(library.name)) + } + } + } + + private fun copyAssetDirectory( + context: Context, + assetPath: String, + targetDir: File, + ) { + val children = + context.assets.list(assetPath) + ?: throw MissingLlmRuntimeException("Unable to list runtime asset directory: $assetPath") + if (children.isEmpty()) { + throw MissingLlmRuntimeException("OpenVINO runtime assets are missing at assets/$assetPath") + } + + children.forEach { child -> + val childAssetPath = "$assetPath/$child" + val childTarget = targetDir.resolve(child) + val nestedChildren = context.assets.list(childAssetPath) + if (nestedChildren.isNullOrEmpty()) { + context.assets.open(childAssetPath).use { input -> + childTarget.parentFile?.mkdirs() + childTarget.outputStream().use { output -> + input.copyTo(output) + } + } + } else { + childTarget.mkdirs() + copyAssetDirectory(context, childAssetPath, childTarget) + } + } + } + + private fun File.copyToIfChanged(target: File) { + if (target.isFile && target.length() == length()) { + return + } + + target.parentFile?.mkdirs() + copyTo(target, overwrite = true) + } + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d927f2b2..eae6e991 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,11 @@ android { buildFeatures { compose = true } + packaging { + jniLibs { + useLegacyPackaging = true + } + } testOptions { managedDevices { localDevices { diff --git a/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt b/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt new file mode 100644 index 00000000..eaa016d6 --- /dev/null +++ b/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt @@ -0,0 +1,25 @@ +package com.itlab.notes + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import androidx.test.platform.app.InstrumentationRegistry +import com.itlab.ai.OpenVinoGenAiBackend +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@LargeTest +@RunWith(AndroidJUnit4::class) +class OpenVinoLlmInstrumentedTest { + @Test(timeout = 10 * 60 * 1000) + fun backendGeneratesTextOnDevice() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val prompt = "Reply with one short word: ok" + + OpenVinoGenAiBackend(context).use { backend -> + val response = backend.generate(prompt, maxNewTokens = 8) + + assertTrue("OpenVINO LLM response must not be blank", response.isNotBlank()) + } + } +} From be6403aab5cab669a39ef1073f89a3639c46a93a Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Fri, 15 May 2026 18:59:48 +0200 Subject: [PATCH 4/9] build(ai): use OpenVINO Android compatibility prebuild --- ai/build.gradle.kts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts index a9662636..f1e9d5b2 100644 --- a/ai/build.gradle.kts +++ b/ai/build.gradle.kts @@ -7,11 +7,15 @@ plugins { val openvinoGenAiAndroidDir = providers.gradleProperty("openvinoGenAiAndroidDir") val openvinoAndroidPrebuildRepo = providers.gradleProperty("openvinoAndroidPrebuildRepo").orElse("embedded-dev-research/openvino-notes") -val openvinoAndroidPrebuildRunId = providers.gradleProperty("openvinoAndroidPrebuildRunId").orElse("25926372315") +val openvinoAndroidPrebuildRunId = providers.gradleProperty("openvinoAndroidPrebuildRunId").orElse("25928695317") val openvinoAndroidPrebuildArtifactName = - providers.gradleProperty("openvinoAndroidPrebuildArtifactName").orElse("openvino-android-arm64-v8a-master.zip") + providers + .gradleProperty("openvinoAndroidPrebuildArtifactName") + .orElse("openvino-android-arm64-v8a-android-mbind-compat.zip") val openvinoAndroidPrebuildPackageName = - providers.gradleProperty("openvinoAndroidPrebuildPackageName").orElse("openvino-android-arm64-v8a-master") + providers + .gradleProperty("openvinoAndroidPrebuildPackageName") + .orElse("openvino-android-arm64-v8a-android-mbind-compat") val openvinoAndroidPrebuildDownloadDir = layout.buildDirectory.dir("openvino/prebuild/download/${openvinoAndroidPrebuildRunId.get()}") val openvinoAndroidPrebuildExtractDir = From 7aafb6a5a5b3b6f7ed03c0c778df9f9107bc48e4 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Fri, 15 May 2026 19:14:33 +0200 Subject: [PATCH 5/9] fix: suppress llm reasoning output --- ai/src/main/cpp/llm_engine.cpp | 1 + .../java/com/itlab/ai/OnDeviceLlmConfig.kt | 4 ++ .../java/com/itlab/ai/OpenVinoGenAiBackend.kt | 41 ++++++++++++++++++- .../notes/OpenVinoLlmInstrumentedTest.kt | 14 +++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/ai/src/main/cpp/llm_engine.cpp b/ai/src/main/cpp/llm_engine.cpp index 2dc2d7d7..973d56fb 100644 --- a/ai/src/main/cpp/llm_engine.cpp +++ b/ai/src/main/cpp/llm_engine.cpp @@ -62,6 +62,7 @@ std::string LlmEngine::generate(const std::string& prompt, int max_new_tokens) { std::lock_guard lock(impl_->mutex); ov::genai::GenerationConfig generation_config = impl_->pipeline->get_generation_config(); generation_config.max_new_tokens = static_cast(max_new_tokens); + generation_config.do_sample = false; return impl_->pipeline->generate(prompt, generation_config); } diff --git a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt index c0072c3c..265e227e 100644 --- a/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt +++ b/ai/src/main/java/com/itlab/ai/OnDeviceLlmConfig.kt @@ -11,6 +11,8 @@ data class OnDeviceLlmConfig( val summaryMaxNewTokens: Int, val tagsMaxNewTokens: Int, val maxTags: Int, + val includeReasoningOutput: Boolean, + val disableReasoningPromptHint: String, ) { companion object { fun defaultAndroid(): OnDeviceLlmConfig = @@ -25,6 +27,8 @@ data class OnDeviceLlmConfig( summaryMaxNewTokens = 96, tagsMaxNewTokens = 48, maxTags = 6, + includeReasoningOutput = false, + disableReasoningPromptHint = "/no_think", ) } } diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt index b8c88672..e19e2c72 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt @@ -18,7 +18,12 @@ class OpenVinoGenAiBackend( maxNewTokens: Int, ): String { val activeBridge = bridge ?: createBridge() - return activeBridge.generate(prompt, maxNewTokens) + val response = activeBridge.generate(preparePrompt(prompt), maxNewTokens) + return if (config.includeReasoningOutput) { + response + } else { + stripReasoningSections(response) + } } @Synchronized @@ -122,7 +127,41 @@ class OpenVinoGenAiBackend( } } + private fun preparePrompt(prompt: String): String { + if (config.includeReasoningOutput || prompt.isBlank()) { + return prompt + } + + val hint = config.disableReasoningPromptHint.trim() + if (hint.isEmpty()) { + return prompt + } + + val trimmedPrompt = prompt.trimEnd() + return if (trimmedPrompt.endsWith(hint, ignoreCase = true)) { + prompt + } else { + "$trimmedPrompt\n$hint" + } + } + + private fun stripReasoningSections(response: String): String { + if (response.isBlank()) { + return response + } + + return THINKING_TAG_REGEX + .replace(THINKING_BLOCK_REGEX.replace(response, ""), "") + .trim() + } + private companion object { const val MODEL_MARKER_FILE = ".openvino_llm_export_complete" + val THINKING_BLOCK_REGEX = + Regex( + pattern = ".*?", + options = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ) + val THINKING_TAG_REGEX = Regex("", RegexOption.IGNORE_CASE) } } diff --git a/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt b/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt index eaa016d6..862b1ad7 100644 --- a/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt +++ b/app/src/androidTest/java/com/itlab/notes/OpenVinoLlmInstrumentedTest.kt @@ -4,6 +4,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import com.itlab.ai.OpenVinoGenAiBackend +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -18,8 +19,21 @@ class OpenVinoLlmInstrumentedTest { OpenVinoGenAiBackend(context).use { backend -> val response = backend.generate(prompt, maxNewTokens = 8) + val normalizedResponse = + response + .lowercase() + .replace(Regex("[^a-z]+"), " ") + .trim() assertTrue("OpenVINO LLM response must not be blank", response.isNotBlank()) + assertFalse( + "OpenVINO LLM response must not expose reasoning tags: $response", + response.contains(" Date: Fri, 15 May 2026 19:23:39 +0200 Subject: [PATCH 6/9] chore: satisfy ai detekt rules --- .../java/com/itlab/ai/OpenVinoGenAiBackend.kt | 156 ++++++++++-------- .../com/itlab/ai/OpenVinoNativeRuntime.kt | 43 +++-- 2 files changed, 114 insertions(+), 85 deletions(-) diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt index e19e2c72..4aa7a7d3 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt @@ -18,7 +18,7 @@ class OpenVinoGenAiBackend( maxNewTokens: Int, ): String { val activeBridge = bridge ?: createBridge() - val response = activeBridge.generate(preparePrompt(prompt), maxNewTokens) + val response = activeBridge.generate(preparePrompt(prompt, config), maxNewTokens) return if (config.includeReasoningOutput) { response } else { @@ -64,14 +64,14 @@ class OpenVinoGenAiBackend( private fun ensureModelDirectory(): File { val targetDir = File(appContext.filesDir, "models/${config.modelDirName}") - if (!assetDirectoryExists(config.assetModelDir)) { + if (!appContext.assetDirectoryExists(config.assetModelDir)) { throw MissingLlmRuntimeException( "OpenVINO LLM model assets are missing at assets/${config.assetModelDir}. " + "Gradle should run :ai:stageOpenVinoLlmAssets during preBuild.", ) } - val assetMarker = readAssetText("${config.assetModelDir}/$MODEL_MARKER_FILE") + val assetMarker = appContext.readAssetText("${config.assetModelDir}/$MODEL_MARKER_FILE") val targetMarker = targetDir.resolve(MODEL_MARKER_FILE).takeIf { it.isFile }?.readText() if (targetDir.exists() && !targetDir.list().isNullOrEmpty() && assetMarker == targetMarker) { return targetDir @@ -79,89 +79,103 @@ class OpenVinoGenAiBackend( targetDir.deleteRecursively() targetDir.mkdirs() - copyAssetDirectory(config.assetModelDir, targetDir) + appContext.copyAssetDirectory(config.assetModelDir, targetDir) File(appContext.cacheDir, config.cacheDirName).deleteRecursively() return targetDir } - private fun assetDirectoryExists(assetPath: String): Boolean = - try { - !appContext.assets.list(assetPath).isNullOrEmpty() - } catch (_: IOException) { - false - } + private companion object { + const val MODEL_MARKER_FILE = ".openvino_llm_export_complete" + } +} - private fun readAssetText(assetPath: String): String? = - try { - appContext - .assets - .open(assetPath) - .bufferedReader() - .use { it.readText() } - } catch (_: IOException) { - null - } +private fun Context.assetDirectoryExists(assetPath: String): Boolean = + try { + !assets.list(assetPath).isNullOrEmpty() + } catch (_: IOException) { + false + } - private fun copyAssetDirectory( - assetPath: String, - targetDir: File, - ) { - val children = - appContext.assets.list(assetPath) - ?: throw MissingLlmRuntimeException("Unable to list model asset directory: $assetPath") - - children.forEach { child -> - val childAssetPath = "$assetPath/$child" - val childTarget = File(targetDir, child) - val nestedChildren = appContext.assets.list(childAssetPath) - if (nestedChildren.isNullOrEmpty()) { - appContext.assets.open(childAssetPath).use { input -> - childTarget.outputStream().use { output -> - input.copyTo(output) - } - } - } else { - childTarget.mkdirs() - copyAssetDirectory(childAssetPath, childTarget) - } - } +private fun Context.readAssetText(assetPath: String): String? = + try { + assets + .open(assetPath) + .bufferedReader() + .use { it.readText() } + } catch (_: IOException) { + null } - private fun preparePrompt(prompt: String): String { - if (config.includeReasoningOutput || prompt.isBlank()) { - return prompt - } +private fun Context.copyAssetDirectory( + assetPath: String, + targetDir: File, +) { + val children = + assets.list(assetPath) + ?: throw MissingLlmRuntimeException("Unable to list model asset directory: $assetPath") - val hint = config.disableReasoningPromptHint.trim() - if (hint.isEmpty()) { - return prompt - } + children.forEach { child -> copyAssetChild(assetPath, targetDir, child) } +} - val trimmedPrompt = prompt.trimEnd() - return if (trimmedPrompt.endsWith(hint, ignoreCase = true)) { - prompt - } else { - "$trimmedPrompt\n$hint" - } +private fun Context.copyAssetChild( + assetPath: String, + targetDir: File, + child: String, +) { + val childAssetPath = "$assetPath/$child" + val childTarget = File(targetDir, child) + val nestedChildren = assets.list(childAssetPath) + if (nestedChildren.isNullOrEmpty()) { + copyAssetFile(childAssetPath, childTarget) + } else { + childTarget.mkdirs() + copyAssetDirectory(childAssetPath, childTarget) } +} - private fun stripReasoningSections(response: String): String { - if (response.isBlank()) { - return response +private fun Context.copyAssetFile( + assetPath: String, + targetFile: File, +) { + assets.open(assetPath).use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) } + } +} - return THINKING_TAG_REGEX - .replace(THINKING_BLOCK_REGEX.replace(response, ""), "") - .trim() +private fun preparePrompt( + prompt: String, + config: OnDeviceLlmConfig, +): String { + val hint = config.disableReasoningPromptHint.trim() + val trimmedPrompt = prompt.trimEnd() + val shouldAppendHint = + !config.includeReasoningOutput && + prompt.isNotBlank() && + hint.isNotEmpty() && + !trimmedPrompt.endsWith(hint, ignoreCase = true) + + return if (shouldAppendHint) { + "$trimmedPrompt\n$hint" + } else { + prompt } +} - private companion object { - const val MODEL_MARKER_FILE = ".openvino_llm_export_complete" - val THINKING_BLOCK_REGEX = - Regex( - pattern = ".*?", - options = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), - ) - val THINKING_TAG_REGEX = Regex("", RegexOption.IGNORE_CASE) +private fun stripReasoningSections(response: String): String { + if (response.isBlank()) { + return response } + + return THINKING_TAG_REGEX + .replace(THINKING_BLOCK_REGEX.replace(response, ""), "") + .trim() } + +private val THINKING_BLOCK_REGEX = + Regex( + pattern = ".*?", + options = setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + ) +private val THINKING_TAG_REGEX = Regex("", RegexOption.IGNORE_CASE) diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt b/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt index de4a1e13..118db3db 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt @@ -123,20 +123,35 @@ internal class OpenVinoNativeRuntime private constructor( throw MissingLlmRuntimeException("OpenVINO runtime assets are missing at assets/$assetPath") } - children.forEach { child -> - val childAssetPath = "$assetPath/$child" - val childTarget = targetDir.resolve(child) - val nestedChildren = context.assets.list(childAssetPath) - if (nestedChildren.isNullOrEmpty()) { - context.assets.open(childAssetPath).use { input -> - childTarget.parentFile?.mkdirs() - childTarget.outputStream().use { output -> - input.copyTo(output) - } - } - } else { - childTarget.mkdirs() - copyAssetDirectory(context, childAssetPath, childTarget) + children.forEach { child -> copyAssetChild(context, assetPath, targetDir, child) } + } + + private fun copyAssetChild( + context: Context, + assetPath: String, + targetDir: File, + child: String, + ) { + val childAssetPath = "$assetPath/$child" + val childTarget = targetDir.resolve(child) + val nestedChildren = context.assets.list(childAssetPath) + if (nestedChildren.isNullOrEmpty()) { + copyAssetFile(context, childAssetPath, childTarget) + } else { + childTarget.mkdirs() + copyAssetDirectory(context, childAssetPath, childTarget) + } + } + + private fun copyAssetFile( + context: Context, + assetPath: String, + targetFile: File, + ) { + context.assets.open(assetPath).use { input -> + targetFile.parentFile?.mkdirs() + targetFile.outputStream().use { output -> + input.copyTo(output) } } } From 1511cfd07aa4500b4454a213e2855e7bc11a4b95 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Fri, 15 May 2026 19:33:58 +0200 Subject: [PATCH 7/9] refactor: satisfy app detekt rules --- .../main/java/com/itlab/notes/ui/NotesApp.kt | 132 ++++---- .../java/com/itlab/notes/ui/NotesViewModel.kt | 318 ++++++++++-------- .../itlab/notes/ui/editor/EditorAiPanel.kt | 146 ++++++++ .../com/itlab/notes/ui/editor/EditorScreen.kt | 180 +++------- .../notes/ui/editor/EditorScreenActions.kt | 10 + 5 files changed, 467 insertions(+), 319 deletions(-) create mode 100644 app/src/main/java/com/itlab/notes/ui/editor/EditorAiPanel.kt create mode 100644 app/src/main/java/com/itlab/notes/ui/editor/EditorScreenActions.kt diff --git a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt index 211d14ac..e6b50415 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesApp.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesApp.kt @@ -1,6 +1,7 @@ package com.itlab.notes.ui import androidx.compose.runtime.Composable +import com.itlab.notes.ui.editor.EditorScreenActions import com.itlab.notes.ui.editor.editorScreen import com.itlab.notes.ui.notes.NotesListActions import com.itlab.notes.ui.notes.directoriesScreen @@ -13,65 +14,82 @@ fun notesApp() { val state = viewModel.uiState when (val screen = state.screen) { - NotesUiScreen.Directories -> { - directoriesScreen( - directories = state.directories, - onCreateDirectory = { name -> - viewModel.onEvent(NotesUiEvent.CreateDirectory(name)) - }, - onDeleteDirectory = { directory -> - viewModel.onEvent(NotesUiEvent.DeleteDirectory(directory.id)) - }, - onRenameDirectory = { directory, newName -> - viewModel.onEvent(NotesUiEvent.RenameDirectory(directory.id, newName)) - }, - onDirectoryClick = { directory -> - viewModel.onEvent(NotesUiEvent.OpenDirectory(directory)) - }, - ) - } + NotesUiScreen.Directories -> directoriesRoute(state, viewModel) - is NotesUiScreen.DirectoryNotes -> { - notesListScreen( - directoryName = screen.directory.name, - notes = state.notes, - directories = state.directories.filter { it.id != "all" }, - actions = - NotesListActions( - onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectories) }, - onAddNoteClick = { viewModel.onEvent(NotesUiEvent.CreateNote) }, - onNoteDelete = { note -> viewModel.onEvent(NotesUiEvent.DeleteNote(note.id)) }, - onNoteMove = { noteId, directoryId -> - viewModel.onEvent( - NotesUiEvent.MoveNoteToDirectory( - noteId = noteId, - targetDirectoryId = directoryId, - ), - ) - }, - onNoteClick = { note -> - viewModel.onEvent(NotesUiEvent.OpenNote(note)) - }, - ), - ) - } + is NotesUiScreen.DirectoryNotes -> notesListRoute(screen, state, viewModel) - is NotesUiScreen.NoteEditor -> { - editorScreen( - directoryName = screen.directory.name, - note = screen.note, - aiState = state.aiState, - onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectoryNotes) }, - onSave = { updated -> - viewModel.onEvent(NotesUiEvent.SaveNote(updated)) - }, - onSuggestSummary = { updated -> - viewModel.onEvent(NotesUiEvent.SuggestSummary(updated)) + is NotesUiScreen.NoteEditor -> editorRoute(screen, state, viewModel) + } +} + +@Composable +private fun directoriesRoute( + state: NotesUiState, + viewModel: NotesViewModel, +) { + directoriesScreen( + directories = state.directories, + onCreateDirectory = { name -> + viewModel.onEvent(NotesUiEvent.CreateDirectory(name)) + }, + onDeleteDirectory = { directory -> + viewModel.onEvent(NotesUiEvent.DeleteDirectory(directory.id)) + }, + onRenameDirectory = { directory, newName -> + viewModel.onEvent(NotesUiEvent.RenameDirectory(directory.id, newName)) + }, + onDirectoryClick = { directory -> + viewModel.onEvent(NotesUiEvent.OpenDirectory(directory)) + }, + ) +} + +@Composable +private fun notesListRoute( + screen: NotesUiScreen.DirectoryNotes, + state: NotesUiState, + viewModel: NotesViewModel, +) { + notesListScreen( + directoryName = screen.directory.name, + notes = state.notes, + directories = state.directories.filter { it.id != "all" }, + actions = + NotesListActions( + onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectories) }, + onAddNoteClick = { viewModel.onEvent(NotesUiEvent.CreateNote) }, + onNoteDelete = { note -> viewModel.onEvent(NotesUiEvent.DeleteNote(note.id)) }, + onNoteMove = { noteId, directoryId -> + viewModel.onEvent( + NotesUiEvent.MoveNoteToDirectory( + noteId = noteId, + targetDirectoryId = directoryId, + ), + ) }, - onSuggestTags = { updated -> - viewModel.onEvent(NotesUiEvent.SuggestTags(updated)) + onNoteClick = { note -> + viewModel.onEvent(NotesUiEvent.OpenNote(note)) }, - ) - } - } + ), + ) +} + +@Composable +private fun editorRoute( + screen: NotesUiScreen.NoteEditor, + state: NotesUiState, + viewModel: NotesViewModel, +) { + editorScreen( + directoryName = screen.directory.name, + note = screen.note, + aiState = state.aiState, + actions = + EditorScreenActions( + onBack = { viewModel.onEvent(NotesUiEvent.BackToDirectoryNotes) }, + onSave = { updated -> viewModel.onEvent(NotesUiEvent.SaveNote(updated)) }, + onSuggestSummary = { updated -> viewModel.onEvent(NotesUiEvent.SuggestSummary(updated)) }, + onSuggestTags = { updated -> viewModel.onEvent(NotesUiEvent.SuggestTags(updated)) }, + ), + ) } diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 8cabdb3c..986bd6c1 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -42,62 +42,122 @@ class NotesViewModel( } override fun onEvent(event: NotesUiEvent) { + when (event) { + is NotesUiEvent.OpenDirectory, + NotesUiEvent.BackToDirectories, + is NotesUiEvent.OpenNote, + NotesUiEvent.CreateNote, + NotesUiEvent.BackToDirectoryNotes, + -> handleNavigationEvent(event) + + is NotesUiEvent.CreateDirectory, + is NotesUiEvent.RenameDirectory, + is NotesUiEvent.DeleteDirectory, + -> handleDirectoryEvent(event) + + is NotesUiEvent.MoveNoteToDirectory, + is NotesUiEvent.DeleteNote, + -> handleNoteEvent(event) + + is NotesUiEvent.SaveNote, + is NotesUiEvent.SuggestSummary, + is NotesUiEvent.SuggestTags, + -> handleEditorEvent(event) + } + } + + private fun handleNavigationEvent(event: NotesUiEvent) { when (event) { is NotesUiEvent.OpenDirectory -> openDirectory(event.directory) NotesUiEvent.BackToDirectories -> backToDirectories() is NotesUiEvent.OpenNote -> openNote(event.note) NotesUiEvent.CreateNote -> createNote() - is NotesUiEvent.CreateDirectory -> { - val normalized = event.name.trim() - if (normalized.isNotBlank()) { - viewModelScope.launch { - useCases.createFolderUseCase(NoteFolder(name = normalized)) - } - } - } + NotesUiEvent.BackToDirectoryNotes -> backToDirectoryNotes() + else -> Unit + } + } + + private fun handleDirectoryEvent(event: NotesUiEvent) { + when (event) { + is NotesUiEvent.CreateDirectory -> createDirectory(event.name) is NotesUiEvent.RenameDirectory -> renameDirectory(event) is NotesUiEvent.DeleteDirectory -> deleteDirectory(event.directoryId) - is NotesUiEvent.MoveNoteToDirectory -> { - if (event.targetDirectoryId == "all") return - viewModelScope.launch { - useCases.moveNoteToFolderUseCase( - folderId = event.targetDirectoryId, - noteId = event.noteId, - ) - } - } - NotesUiEvent.BackToDirectoryNotes -> backToDirectoryNotes() - is NotesUiEvent.SaveNote -> saveNote(event.note) - is NotesUiEvent.SuggestSummary -> suggestSummary(event.note) - is NotesUiEvent.SuggestTags -> suggestTags(event.note) + else -> Unit + } + } + + private fun handleNoteEvent(event: NotesUiEvent) { + when (event) { + is NotesUiEvent.MoveNoteToDirectory -> moveNoteToDirectory(event) is NotesUiEvent.DeleteNote -> { viewModelScope.launch { useCases.deleteNoteUseCase(event.noteId) } } + else -> Unit + } + } + + private fun handleEditorEvent(event: NotesUiEvent) { + when (event) { + is NotesUiEvent.SaveNote -> saveNote(event.note) + is NotesUiEvent.SuggestSummary -> suggestAi(event.note, AiSuggestion.Summary) + is NotesUiEvent.SuggestTags -> suggestAi(event.note, AiSuggestion.Tags) + else -> Unit + } + } + + private val createDirectory: (String) -> Unit = { name -> + val normalized = name.trim() + if (normalized.isNotBlank()) { + viewModelScope.launch { + useCases.createFolderUseCase(NoteFolder(name = normalized)) + } } } - private fun renameDirectory(event: NotesUiEvent.RenameDirectory) { + private val renameDirectory: (NotesUiEvent.RenameDirectory) -> Unit = { event -> val normalized = event.newName.trim() - if (normalized.isBlank() || event.directoryId == "all") return - viewModelScope.launch { - val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch - useCases.updateFolderUseCase(existingFolder.copy(name = normalized)) + if (normalized.isNotBlank() && event.directoryId != "all") { + viewModelScope.launch { + val existingFolder = useCases.getFolderUseCase(event.directoryId) ?: return@launch + useCases.updateFolderUseCase(existingFolder.copy(name = normalized)) + } } } - private fun deleteDirectory(directoryId: String) { - if (directoryId == "all") return - viewModelScope.launch { - useCases.deleteFolderUseCase(directoryId) - if ((uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory?.id == directoryId) { - backToDirectories() + private val backToDirectories: () -> Unit = { + uiState = + uiState.copy( + screen = NotesUiScreen.Directories, + notes = emptyList(), + aiState = AiUiState(), + ) + } + + private val deleteDirectory: (String) -> Unit = { directoryId -> + if (directoryId != "all") { + viewModelScope.launch { + useCases.deleteFolderUseCase(directoryId) + if ((uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory?.id == directoryId) { + backToDirectories() + } } } } - private fun openDirectory(directory: DirectoryItemUi) { + private val moveNoteToDirectory: (NotesUiEvent.MoveNoteToDirectory) -> Unit = { event -> + if (event.targetDirectoryId != "all") { + viewModelScope.launch { + useCases.moveNoteToFolderUseCase( + folderId = event.targetDirectoryId, + noteId = event.noteId, + ) + } + } + } + + private val openDirectory: (DirectoryItemUi) -> Unit = { directory -> uiState = uiState.copy( screen = NotesUiScreen.DirectoryNotes(directory = directory), @@ -134,16 +194,7 @@ class NotesViewModel( } } - private val backToDirectories: () -> Unit = { - uiState = - uiState.copy( - screen = NotesUiScreen.Directories, - notes = emptyList(), - aiState = AiUiState(), - ) - } - - private fun openNote(note: NoteItemUi) { + private val openNote: (NoteItemUi) -> Unit = { note -> val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory if (dir != null) { uiState = @@ -154,7 +205,7 @@ class NotesViewModel( } } - private fun createNote() { + private val createNote: () -> Unit = { val dir = (uiState.screen as? NotesUiScreen.DirectoryNotes)?.directory if (dir != null) { val newNote = @@ -167,7 +218,7 @@ class NotesViewModel( } } - private fun backToDirectoryNotes() { + private val backToDirectoryNotes: () -> Unit = { val editor = uiState.screen as? NotesUiScreen.NoteEditor if (editor != null) { uiState = @@ -181,7 +232,7 @@ class NotesViewModel( private fun saveNote(note: NoteItemUi) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { - upsertEditorNote(note, editor) + upsertEditorNote(note, editor, latestNotes, useCases) .onSuccess { uiState = uiState.copy( @@ -194,117 +245,68 @@ class NotesViewModel( } } - private fun suggestSummary(note: NoteItemUi) { + private fun suggestAi( + note: NoteItemUi, + suggestion: AiSuggestion, + ) { val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return viewModelScope.launch { - updateAiState { it.copy(isGeneratingSummary = true, errorMessage = null) } + updateAiState { suggestion.startState(it) } val savedNote = - upsertEditorNote(note, editor) + upsertEditorNote(note, editor, latestNotes, useCases) .getOrElse { error -> - finishSummary(error) + updateAiState { suggestion.errorState(it, error) } return@launch } updateEditorNote(savedNote) val generated = - useCases - .suggestSummaryUseCase(savedNote.id) - .mapCatching { summary -> - useCases.applySummaryUseCase(savedNote.id, summary).getOrThrow() - savedNote.copy(summary = summary) - } - - generated - .onSuccess { updatedNote -> - updateEditorNote(updatedNote) - updateAiState { it.copy(isGeneratingSummary = false, errorMessage = null) } - }.onFailure { error -> - finishSummary(error) + when (suggestion) { + AiSuggestion.Summary -> + useCases + .suggestSummaryUseCase(savedNote.id) + .mapCatching { summary -> + useCases.applySummaryUseCase(savedNote.id, summary).getOrThrow() + savedNote.copy(summary = summary) + } + AiSuggestion.Tags -> + useCases + .suggestTagsUseCase(savedNote.id) + .mapCatching { tags -> + useCases.applyTagsUseCase(savedNote.id, tags).getOrThrow() + savedNote.copy(tags = tags) + } } - } - } - - private fun suggestTags(note: NoteItemUi) { - val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return - viewModelScope.launch { - updateAiState { it.copy(isGeneratingTags = true, errorMessage = null) } - val savedNote = - upsertEditorNote(note, editor) - .getOrElse { error -> - finishTags(error) - return@launch - } - updateEditorNote(savedNote) - - val generated = - useCases - .suggestTagsUseCase(savedNote.id) - .mapCatching { tags -> - useCases.applyTagsUseCase(savedNote.id, tags).getOrThrow() - savedNote.copy(tags = tags) - } generated .onSuccess { updatedNote -> updateEditorNote(updatedNote) - updateAiState { it.copy(isGeneratingTags = false, errorMessage = null) } + updateAiState { suggestion.successState(it) } }.onFailure { error -> - finishTags(error) + updateAiState { suggestion.errorState(it, error) } } } } - private suspend fun upsertEditorNote( - note: NoteItemUi, - editor: NotesUiScreen.NoteEditor, - ): Result = - runCatching { - val targetFolderId = note.folderId ?: editor.directory.id.asDomainFolderId() - val existing = latestNotes.firstOrNull { it.id == note.id } - if (existing != null) { - useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)).getOrThrow() - note.copy(folderId = targetFolderId) - } else { - val savedId = useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)).getOrThrow() - note.copy(id = savedId, folderId = targetFolderId) - } + private val updateEditorNote: (NoteItemUi) -> Unit = { note -> + val editor = uiState.screen as? NotesUiScreen.NoteEditor + if (editor != null) { + uiState = + uiState.copy( + screen = + NotesUiScreen.NoteEditor( + directory = editor.directory, + note = note, + ), + ) } - - private fun updateEditorNote(note: NoteItemUi) { - val editor = uiState.screen as? NotesUiScreen.NoteEditor ?: return - uiState = - uiState.copy( - screen = - NotesUiScreen.NoteEditor( - directory = editor.directory, - note = note, - ), - ) } - private fun updateAiState(update: (AiUiState) -> AiUiState) { + private val updateAiState: ((AiUiState) -> AiUiState) -> Unit = { update -> uiState = uiState.copy(aiState = update(uiState.aiState)) } - private fun finishSummary(error: Throwable) { - updateAiState { - it.copy( - isGeneratingSummary = false, - errorMessage = error.userMessage("Unable to generate summary"), - ) - } - } - - private fun finishTags(error: Throwable) { - updateAiState { - it.copy( - isGeneratingTags = false, - errorMessage = error.userMessage("Unable to suggest tags"), - ) - } - } - - private fun recomputeDirectories() { + private val recomputeDirectories: () -> Unit = { val countsByFolderId = latestNotes.groupingBy { it.folderId }.eachCount() val allNotesCount = latestNotes.size @@ -335,6 +337,58 @@ class NotesViewModel( } } +private enum class AiSuggestion { + Summary, + Tags, +} + +private fun AiSuggestion.startState(state: AiUiState): AiUiState = + when (this) { + AiSuggestion.Summary -> state.copy(isGeneratingSummary = true, errorMessage = null) + AiSuggestion.Tags -> state.copy(isGeneratingTags = true, errorMessage = null) + } + +private fun AiSuggestion.successState(state: AiUiState): AiUiState = + when (this) { + AiSuggestion.Summary -> state.copy(isGeneratingSummary = false, errorMessage = null) + AiSuggestion.Tags -> state.copy(isGeneratingTags = false, errorMessage = null) + } + +private fun AiSuggestion.errorState( + state: AiUiState, + error: Throwable, +): AiUiState = + when (this) { + AiSuggestion.Summary -> + state.copy( + isGeneratingSummary = false, + errorMessage = error.userMessage("Unable to generate summary"), + ) + AiSuggestion.Tags -> + state.copy( + isGeneratingTags = false, + errorMessage = error.userMessage("Unable to suggest tags"), + ) + } + +private suspend fun upsertEditorNote( + note: NoteItemUi, + editor: NotesUiScreen.NoteEditor, + latestNotes: List, + useCases: NotesUseCases, +): Result = + runCatching { + val targetFolderId = note.folderId ?: editor.directory.id.asDomainFolderId() + val existing = latestNotes.firstOrNull { it.id == note.id } + if (existing != null) { + useCases.updateNoteUseCase(existing.applyUiUpdate(note, targetFolderId)).getOrThrow() + note.copy(folderId = targetFolderId) + } else { + val savedId = useCases.createNoteUseCase(note.toDomain(folderId = targetFolderId)).getOrThrow() + note.copy(id = savedId, folderId = targetFolderId) + } + } + internal fun NoteFolder.toUi(noteCount: Int): DirectoryItemUi = DirectoryItemUi(id = id, name = name, noteCount = noteCount) diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorAiPanel.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorAiPanel.kt new file mode 100644 index 00000000..d74007c9 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorAiPanel.kt @@ -0,0 +1,146 @@ +package com.itlab.notes.ui.editor + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.itlab.notes.ui.AiUiState + +internal data class EditorAiPanelState( + val summary: String?, + val tags: Set, + val aiState: AiUiState, +) + +internal data class EditorAiPanelActions( + val onSuggestSummary: () -> Unit, + val onSuggestTags: () -> Unit, +) + +@Composable +internal fun editorAiPanel( + state: EditorAiPanelState, + actions: EditorAiPanelActions, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + editorAiActionsRow(state, actions) + editorAiError(state.aiState.errorMessage) + editorSummaryCard(state.summary) + editorTagsText(state.tags) + } +} + +@Composable +private fun editorAiActionsRow( + state: EditorAiPanelState, + actions: EditorAiPanelActions, +) { + val isGenerating = state.aiState.isGeneratingSummary || state.aiState.isGeneratingTags + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = actions.onSuggestSummary, + enabled = !isGenerating, + modifier = Modifier.weight(1f), + ) { + summaryButtonContent(state.aiState.isGeneratingSummary) + } + + OutlinedButton( + onClick = actions.onSuggestTags, + enabled = !isGenerating, + modifier = Modifier.weight(1f), + ) { + tagsButtonContent(state.aiState.isGeneratingTags) + } + } +} + +@Composable +private fun summaryButtonContent(isGenerating: Boolean) { + val colors = MaterialTheme.colorScheme + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = colors.onPrimary, + ) + } else { + Text("Summarize") + } +} + +@Composable +private fun tagsButtonContent(isGenerating: Boolean) { + if (isGenerating) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + ) + } else { + Text("Suggest tags") + } +} + +@Composable +private fun editorAiError(errorMessage: String?) { + val colors = MaterialTheme.colorScheme + errorMessage?.let { error -> + Text( + text = error, + color = colors.error, + style = MaterialTheme.typography.bodySmall, + ) + } +} + +@Composable +private fun editorSummaryCard(summary: String?) { + if (!summary.isNullOrBlank()) { + val colors = MaterialTheme.colorScheme + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "Summary", + color = colors.onSurfaceVariant, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = summary, + color = colors.onSurface, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp), + ) + } + } + } +} + +@Composable +private fun editorTagsText(tags: Set) { + if (tags.isNotEmpty()) { + val colors = MaterialTheme.colorScheme + Text( + text = "Tags: ${tags.joinToString(", ")}", + color = colors.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + } +} diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt index d79a0563..a866dd42 100644 --- a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreen.kt @@ -1,25 +1,17 @@ package com.itlab.notes.ui.editor -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material3.Button import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -39,10 +31,7 @@ fun editorScreen( directoryName: String, note: NoteItemUi, aiState: AiUiState, - onBack: () -> Unit, - onSave: (NoteItemUi) -> Unit, - onSuggestSummary: (NoteItemUi) -> Unit, - onSuggestTags: (NoteItemUi) -> Unit, + actions: EditorScreenActions, ) { val colors = MaterialTheme.colorScheme val editorVm = remember(note.id) { EditorViewModel(initialNote = note) } @@ -53,25 +42,31 @@ fun editorScreen( editorTopBar( directoryName = directoryName, title = editorVm.title, - onBack = onBack, + onBack = actions.onBack, ) }, floatingActionButton = { editorFab( - onClick = { onSave(editorVm.buildUpdatedNote()) }, + onClick = { actions.onSave(editorVm.buildUpdatedNote()) }, ) }, ) { paddingValues -> editorContent( - title = editorVm.title, - content = editorVm.content, - onTitleChange = editorVm::onTitleChange, - onContentChange = editorVm::onContentChange, - summary = note.summary, - tags = note.tags, - aiState = aiState, - onSuggestSummary = { onSuggestSummary(editorVm.buildUpdatedNote()) }, - onSuggestTags = { onSuggestTags(editorVm.buildUpdatedNote()) }, + state = + EditorContentState( + title = editorVm.title, + content = editorVm.content, + summary = note.summary, + tags = note.tags, + aiState = aiState, + ), + actions = + EditorContentActions( + onTitleChange = editorVm::onTitleChange, + onContentChange = editorVm::onContentChange, + onSuggestSummary = { actions.onSuggestSummary(editorVm.buildUpdatedNote()) }, + onSuggestTags = { actions.onSuggestTags(editorVm.buildUpdatedNote()) }, + ), modifier = Modifier.padding(paddingValues), ) } @@ -127,20 +122,27 @@ private fun editorFab(onClick: () -> Unit) { } } +private data class EditorContentState( + val title: String, + val content: String, + val summary: String?, + val tags: Set, + val aiState: AiUiState, +) + +private data class EditorContentActions( + val onTitleChange: (String) -> Unit, + val onContentChange: (String) -> Unit, + val onSuggestSummary: () -> Unit, + val onSuggestTags: () -> Unit, +) + @Composable private fun editorContent( - title: String, - content: String, - onTitleChange: (String) -> Unit, - onContentChange: (String) -> Unit, - summary: String?, - tags: Set, - aiState: AiUiState, - onSuggestSummary: () -> Unit, - onSuggestTags: () -> Unit, + state: EditorContentState, + actions: EditorContentActions, modifier: Modifier = Modifier, ) { - val colors = MaterialTheme.colorScheme Column( modifier = modifier @@ -148,22 +150,28 @@ private fun editorContent( .padding(horizontal = 16.dp, vertical = 12.dp), ) { editorTitleField( - value = title, - onValueChange = onTitleChange, + value = state.title, + onValueChange = actions.onTitleChange, ) editorContentField( - value = content, - onValueChange = onContentChange, + value = state.content, + onValueChange = actions.onContentChange, modifier = Modifier.padding(top = 12.dp), ) editorAiPanel( - summary = summary, - tags = tags, - aiState = aiState, - onSuggestSummary = onSuggestSummary, - onSuggestTags = onSuggestTags, + state = + EditorAiPanelState( + summary = state.summary, + tags = state.tags, + aiState = state.aiState, + ), + actions = + EditorAiPanelActions( + onSuggestSummary = actions.onSuggestSummary, + onSuggestTags = actions.onSuggestTags, + ), modifier = Modifier.padding(top = 16.dp), ) } @@ -224,91 +232,3 @@ private fun editorContentField( ), ) } - -@Composable -private fun editorAiPanel( - summary: String?, - tags: Set, - aiState: AiUiState, - onSuggestSummary: () -> Unit, - onSuggestTags: () -> Unit, - modifier: Modifier = Modifier, -) { - val colors = MaterialTheme.colorScheme - val isGenerating = aiState.isGeneratingSummary || aiState.isGeneratingTags - - Column( - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = onSuggestSummary, - enabled = !isGenerating, - modifier = Modifier.weight(1f), - ) { - if (aiState.isGeneratingSummary) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = colors.onPrimary, - ) - } else { - Text("Summarize") - } - } - - OutlinedButton( - onClick = onSuggestTags, - enabled = !isGenerating, - modifier = Modifier.weight(1f), - ) { - if (aiState.isGeneratingTags) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - ) - } else { - Text("Suggest tags") - } - } - } - - aiState.errorMessage?.let { error -> - Text( - text = error, - color = colors.error, - style = MaterialTheme.typography.bodySmall, - ) - } - - if (!summary.isNullOrBlank()) { - ElevatedCard(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.padding(12.dp)) { - Text( - text = "Summary", - color = colors.onSurfaceVariant, - style = MaterialTheme.typography.labelMedium, - ) - Text( - text = summary, - color = colors.onSurface, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 4.dp), - ) - } - } - } - - if (tags.isNotEmpty()) { - Text( - text = "Tags: ${tags.joinToString(", ")}", - color = colors.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium, - ) - } - } -} diff --git a/app/src/main/java/com/itlab/notes/ui/editor/EditorScreenActions.kt b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreenActions.kt new file mode 100644 index 00000000..9d564cf5 --- /dev/null +++ b/app/src/main/java/com/itlab/notes/ui/editor/EditorScreenActions.kt @@ -0,0 +1,10 @@ +package com.itlab.notes.ui.editor + +import com.itlab.notes.ui.notes.NoteItemUi + +data class EditorScreenActions( + val onBack: () -> Unit, + val onSave: (NoteItemUi) -> Unit, + val onSuggestSummary: (NoteItemUi) -> Unit, + val onSuggestTags: (NoteItemUi) -> Unit, +) From 3af98432dfcff21c8174a54ffd590d6c0f39a034 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Fri, 15 May 2026 19:44:50 +0200 Subject: [PATCH 8/9] fix: satisfy Android lint for LLM integration --- ai/build.gradle.kts | 5 +++++ .../main/java/com/itlab/ai/OpenVinoGenAiBackend.kt | 14 +++++++------- .../java/com/itlab/ai/OpenVinoNativeRuntime.kt | 2 ++ .../main/java/com/itlab/notes/ui/NotesViewModel.kt | 12 ++++++------ 4 files changed, 20 insertions(+), 13 deletions(-) diff --git a/ai/build.gradle.kts b/ai/build.gradle.kts index f1e9d5b2..7da59f36 100644 --- a/ai/build.gradle.kts +++ b/ai/build.gradle.kts @@ -97,6 +97,11 @@ android { } } + lint { + // The OpenVINO GenAI Android prebuild used by this module is arm64-v8a only. + disable += "ChromeOsAbiSupport" + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt index 4aa7a7d3..be9e1a49 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoGenAiBackend.kt @@ -89,14 +89,14 @@ class OpenVinoGenAiBackend( } } -private fun Context.assetDirectoryExists(assetPath: String): Boolean = +internal fun Context.assetDirectoryExists(assetPath: String): Boolean = try { !assets.list(assetPath).isNullOrEmpty() } catch (_: IOException) { false } -private fun Context.readAssetText(assetPath: String): String? = +internal fun Context.readAssetText(assetPath: String): String? = try { assets .open(assetPath) @@ -106,7 +106,7 @@ private fun Context.readAssetText(assetPath: String): String? = null } -private fun Context.copyAssetDirectory( +internal fun Context.copyAssetDirectory( assetPath: String, targetDir: File, ) { @@ -117,7 +117,7 @@ private fun Context.copyAssetDirectory( children.forEach { child -> copyAssetChild(assetPath, targetDir, child) } } -private fun Context.copyAssetChild( +internal fun Context.copyAssetChild( assetPath: String, targetDir: File, child: String, @@ -133,7 +133,7 @@ private fun Context.copyAssetChild( } } -private fun Context.copyAssetFile( +internal fun Context.copyAssetFile( assetPath: String, targetFile: File, ) { @@ -144,7 +144,7 @@ private fun Context.copyAssetFile( } } -private fun preparePrompt( +internal fun preparePrompt( prompt: String, config: OnDeviceLlmConfig, ): String { @@ -163,7 +163,7 @@ private fun preparePrompt( } } -private fun stripReasoningSections(response: String): String { +internal fun stripReasoningSections(response: String): String { if (response.isBlank()) { return response } diff --git a/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt b/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt index 118db3db..e14eb803 100644 --- a/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt +++ b/ai/src/main/java/com/itlab/ai/OpenVinoNativeRuntime.kt @@ -1,5 +1,6 @@ package com.itlab.ai +import android.annotation.SuppressLint import android.content.Context import java.io.File @@ -7,6 +8,7 @@ internal class OpenVinoNativeRuntime private constructor( private val runtimeDir: File, private val notesLibrary: File, ) { + @SuppressLint("UnsafeDynamicallyLoadedCode") fun loadBridge(): Result = runCatching { preferredLibraryLoadOrder diff --git a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt index 986bd6c1..bab3814f 100644 --- a/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt +++ b/app/src/main/java/com/itlab/notes/ui/NotesViewModel.kt @@ -337,24 +337,24 @@ class NotesViewModel( } } -private enum class AiSuggestion { +internal enum class AiSuggestion { Summary, Tags, } -private fun AiSuggestion.startState(state: AiUiState): AiUiState = +internal fun AiSuggestion.startState(state: AiUiState): AiUiState = when (this) { AiSuggestion.Summary -> state.copy(isGeneratingSummary = true, errorMessage = null) AiSuggestion.Tags -> state.copy(isGeneratingTags = true, errorMessage = null) } -private fun AiSuggestion.successState(state: AiUiState): AiUiState = +internal fun AiSuggestion.successState(state: AiUiState): AiUiState = when (this) { AiSuggestion.Summary -> state.copy(isGeneratingSummary = false, errorMessage = null) AiSuggestion.Tags -> state.copy(isGeneratingTags = false, errorMessage = null) } -private fun AiSuggestion.errorState( +internal fun AiSuggestion.errorState( state: AiUiState, error: Throwable, ): AiUiState = @@ -371,7 +371,7 @@ private fun AiSuggestion.errorState( ) } -private suspend fun upsertEditorNote( +internal suspend fun upsertEditorNote( note: NoteItemUi, editor: NotesUiScreen.NoteEditor, latestNotes: List, @@ -436,4 +436,4 @@ internal fun Note.applyUiUpdate( internal fun String.asDomainFolderId(): String? = if (this == "all") null else this -private fun Throwable.userMessage(fallback: String): String = message ?: fallback +internal fun Throwable.userMessage(fallback: String): String = message ?: fallback From 7b75896453eeaa232a048aa71bc845a1b8a50e19 Mon Sep 17 00:00:00 2001 From: Alexander Nesterov Date: Fri, 15 May 2026 23:15:15 +0200 Subject: [PATCH 9/9] test: restore ai coverage gate --- .../java/com/itlab/ai/OpenVinoAiLayerTest.kt | 111 ++++++++++++++++++ build.gradle.kts | 8 ++ 2 files changed, 119 insertions(+) diff --git a/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt b/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt index 9a6ccdd4..1491c81a 100644 --- a/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt +++ b/ai/src/test/java/com/itlab/ai/OpenVinoAiLayerTest.kt @@ -2,6 +2,7 @@ package com.itlab.ai import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test @@ -95,6 +96,116 @@ class OpenVinoAiLayerTest { assertTrue(!prompt.contains("123456")) } + @Test + fun openVinoEngine_returnsEmptyResultForBlankInputWithoutCallingBackend() { + val backend = RecordingLlmBackend("unused") + val engine = OpenVinoEngine(llmBackend = backend) + + assertEquals("", engine.runLlmSummary(" ")) + assertEquals("", engine.runLlmTagging("\n\t")) + assertEquals(null, backend.lastPrompt) + } + + @Test + fun unavailableBackend_failsWithConfiguredReason() { + val backend = UnavailableLlmBackend("missing runtime") + + val failure = + runCatching { + backend.generate("prompt", maxNewTokens = 1) + }.exceptionOrNull() + + assertTrue(failure is MissingLlmRuntimeException) + assertEquals("missing runtime", failure?.message) + } + + @Test + fun missingRuntimeException_preservesCause() { + val cause = IllegalArgumentException("native loader") + + val failure = MissingLlmRuntimeException("runtime failed", cause) + + assertEquals("runtime failed", failure.message) + assertEquals(cause, failure.cause) + } + + @Test + fun preparePrompt_appendsNoThinkHintWhenReasoningOutputIsDisabled() { + val config = + OnDeviceLlmConfig.defaultAndroid().copy( + includeReasoningOutput = false, + disableReasoningPromptHint = "/no_think", + ) + + val prompt = preparePrompt("Say ok ", config) + + assertEquals("Say ok\n/no_think", prompt) + } + + @Test + fun preparePrompt_keepsPromptWhenHintAlreadyExists() { + val config = OnDeviceLlmConfig.defaultAndroid() + + val prompt = preparePrompt("Say ok\n/NO_THINK", config) + + assertEquals("Say ok\n/NO_THINK", prompt) + } + + @Test + fun preparePrompt_keepsPromptWhenReasoningOutputIsEnabled() { + val config = + OnDeviceLlmConfig.defaultAndroid().copy( + includeReasoningOutput = true, + disableReasoningPromptHint = "/no_think", + ) + + val prompt = preparePrompt("Say ok", config) + + assertEquals("Say ok", prompt) + } + + @Test + fun preparePrompt_keepsBlankPrompt() { + val config = OnDeviceLlmConfig.defaultAndroid() + + val prompt = preparePrompt(" ", config) + + assertEquals(" ", prompt) + } + + @Test + fun stripReasoningSections_removesThinkingBlockAndTags() { + val response = + """ + + hidden chain + + + ok + """.trimIndent() + + val cleaned = stripReasoningSections(response) + + assertEquals("ok", cleaned) + assertFalse(cleaned.contains("think", ignoreCase = true)) + } + + @Test + fun stripReasoningSections_removesDanglingThinkingTags() { + val cleaned = stripReasoningSections("ok") + + assertEquals("ok", cleaned) + } + + @Test + fun stripReasoningSections_keepsBlankResponse() { + val response = " " + + val cleaned = stripReasoningSections(response) + + assertEquals(response, cleaned) + } + private class RecordingLlmBackend( private val response: String, ) : LlmInferenceBackend { diff --git a/build.gradle.kts b/build.gradle.kts index b1c4055b..9149736c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -102,6 +102,14 @@ subprojects { "com.itlab.notes.ui.notes.*", "com.itlab.notes.ui.theme.*", ) + if (name == "ai") { + classes( + "com.itlab.ai.NativeLlmBridge", + "com.itlab.ai.OpenVinoGenAiBackend", + "com.itlab.ai.OpenVinoNativeRuntime*", + "com.itlab.ai.di.*", + ) + } } } verify {