Scaffold tsjetpiti: on-device Qwen3.5-2B chat app

Android app that runs an uncensored Qwen3.5-2B GGUF fully on-device via an
embedded llama.cpp (pinned b10333, built through the NDK) behind a barebones
WebView chat UI. Model is downloaded on first launch. "new tsjet" wipes the
conversation and resets the KV cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 20:24:14 +02:00
commit ac8490b452
20 changed files with 1416 additions and 0 deletions

62
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,62 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "monster.autisme.tsjetpiti"
compileSdk = 35
// NDK is needed to compile the llama.cpp JNI bridge. Android Studio will
// offer to install this exact version; adjust if you have a different one.
ndkVersion = "27.2.12479018"
defaultConfig {
applicationId = "monster.autisme.tsjetpiti"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// On-device LLM => 64-bit ARM only. Keeps the APK from ballooning.
ndk {
abiFilters += "arm64-v8a"
}
externalNativeBuild {
cmake {
arguments += "-DANDROID_STL=c++_shared"
cppFlags += "-std=c++17"
}
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
// Intentionally dependency-free: framework WebView + org.json + native lib.
}

10
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,10 @@
# Keep the JNI bridge: native method names must not be renamed.
-keepclasseswithmembernames class * {
native <methods>;
}
-keep class monster.autisme.tsjetpiti.LlamaBridge { *; }
# Keep @JavascriptInterface methods reachable from the WebView.
-keepclassmembers class monster.autisme.tsjetpiti.MainActivity$Bridge {
@android.webkit.JavascriptInterface <methods>;
}

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Needs a 64-bit ARM device with enough RAM to run a ~1.2 GB model. -->
<application
android:allowBackup="false"
android:label="tsjet"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
android:exported="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,130 @@
(function () {
"use strict";
var chat = document.getElementById("chat");
var overlay = document.getElementById("overlay");
var statusEl = document.getElementById("status");
var form = document.getElementById("composer");
var input = document.getElementById("input");
var sendBtn = document.getElementById("sendBtn");
var newBtn = document.getElementById("newBtn");
var messages = []; // conversation history: {role, content}
var ready = false; // model loaded?
var streamActive = false;// a reply is currently generating
var curEl = null; // the assistant bubble being streamed into
function native() { return window.TsjetNative; }
// Qwen may emit <think>...</think>. Hide it from the chat and from history.
function stripThink(t) {
return t
.replace(/<think>[\s\S]*?<\/think>/g, "")
.replace(/<think>[\s\S]*$/, "")
.replace(/^\s+/, "");
}
function addBubble(role, text) {
var el = document.createElement("div");
el.className = "msg " + role;
el.textContent = text;
chat.appendChild(el);
scrollDown();
return el;
}
function scrollDown() { chat.scrollTop = chat.scrollHeight; }
function setGenerating(on) {
streamActive = on;
sendBtn.textContent = on ? "stop" : "send";
sendBtn.classList.toggle("stop", on);
sendBtn.disabled = !ready;
}
function autoGrow() {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 160) + "px";
}
function send() {
if (!ready) return;
if (streamActive) { native().stop(); return; } // send doubles as "stop"
var text = input.value.trim();
if (!text) return;
messages.push({ role: "user", content: text });
addBubble("user", text);
input.value = "";
autoGrow();
setGenerating(true);
curEl = addBubble("assistant", "…");
try {
native().generate(JSON.stringify(messages));
} catch (e) {
window.__tsjet.onError("" + e);
}
}
function newChat() {
if (native()) native().newChat();
messages = [];
curEl = null;
chat.innerHTML = "";
setGenerating(false);
if (ready) input.focus();
}
// Callbacks invoked from the native side.
window.__tsjet = {
onStatus: function (s) {
overlay.style.display = "flex";
statusEl.textContent = s;
},
onReady: function () {
ready = true;
overlay.style.display = "none";
sendBtn.disabled = false;
input.focus();
},
onStart: function () {
if (streamActive && curEl) curEl.textContent = "…";
},
onUpdate: function (full) {
if (!streamActive || !curEl) return;
var shown = stripThink(full);
curEl.textContent = shown.length ? shown : "…";
scrollDown();
},
onDone: function (full) {
if (curEl && curEl.isConnected && streamActive) {
var finalText = stripThink(full).trim();
curEl.textContent = finalText.length ? finalText : "(no reply)";
messages.push({ role: "assistant", content: finalText });
}
curEl = null;
setGenerating(false);
if (ready) input.focus();
},
onError: function (msg) {
overlay.style.display = "none";
if (curEl && curEl.isConnected) {
curEl.textContent = "⚠ " + msg;
curEl.classList.add("error");
} else {
addBubble("assistant", "⚠ " + msg).classList.add("error");
}
curEl = null;
setGenerating(false);
}
};
form.addEventListener("submit", function (e) { e.preventDefault(); send(); });
newBtn.addEventListener("click", newChat);
input.addEventListener("input", autoGrow);
input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
})();

View File

@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>tsjet</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header id="bar">
<div id="brand">
<!-- juli's logo goes here -->
<span id="logo" aria-hidden="true">🐟</span>
<span id="title">tsjet</span>
</div>
<button id="newBtn" type="button">new tsjet</button>
</header>
<main id="chat"></main>
<div id="overlay">
<div class="spinner" aria-hidden="true"></div>
<div id="status">Waking the tsjet…</div>
</div>
<form id="composer">
<textarea id="input" rows="1" placeholder="talk to the tsjet…"
autocomplete="off" autocapitalize="sentences"></textarea>
<button id="sendBtn" type="submit" disabled>send</button>
</form>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
:root {
--bg: #0e0f13;
--bar: #16181f;
--line: #262a35;
--text: #e8e9ee;
--muted: #8a90a2;
--user: #2b6cff;
--assistant: #1d2230;
--accent: #ff4d6d;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font: 16px/1.45 -apple-system, "Roboto", "Segoe UI", system-ui, sans-serif;
overscroll-behavior: none;
}
body {
display: flex;
flex-direction: column;
height: 100dvh;
}
/* --- top bar --- */
#bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: max(env(safe-area-inset-top), 10px) 14px 10px;
background: var(--bar);
border-bottom: 1px solid var(--line);
flex: 0 0 auto;
}
#brand { display: flex; align-items: center; gap: 8px; font-weight: 700; }
#logo { font-size: 22px; }
#title { letter-spacing: 0.5px; }
#newBtn {
background: transparent;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 999px;
padding: 7px 14px;
font-size: 14px;
font-weight: 600;
}
#newBtn:active { background: var(--line); color: var(--text); }
/* --- messages --- */
#chat {
flex: 1 1 auto;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.msg {
max-width: 85%;
padding: 10px 13px;
border-radius: 16px;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: anywhere;
}
.msg.user {
align-self: flex-end;
background: var(--user);
color: #fff;
border-bottom-right-radius: 4px;
}
.msg.assistant {
align-self: flex-start;
background: var(--assistant);
border-bottom-left-radius: 4px;
}
.msg.error { background: #3a1420; color: #ffb3c1; }
/* --- composer --- */
#composer {
display: flex;
gap: 8px;
align-items: flex-end;
padding: 10px 12px calc(env(safe-area-inset-bottom) + 10px);
background: var(--bar);
border-top: 1px solid var(--line);
flex: 0 0 auto;
}
#input {
flex: 1 1 auto;
resize: none;
max-height: 160px;
padding: 11px 13px;
border-radius: 18px;
border: 1px solid var(--line);
background: var(--bg);
color: var(--text);
font: inherit;
outline: none;
}
#input::placeholder { color: var(--muted); }
#sendBtn {
flex: 0 0 auto;
border: none;
border-radius: 18px;
padding: 11px 18px;
font: inherit;
font-weight: 700;
color: #fff;
background: var(--user);
}
#sendBtn:disabled { opacity: 0.45; }
#sendBtn.stop { background: var(--accent); }
/* --- loading overlay --- */
#overlay {
position: fixed;
inset: 0;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
background: rgba(14, 15, 19, 0.96);
z-index: 10;
padding: 24px;
text-align: center;
}
#status { color: var(--muted); font-size: 15px; }
.spinner {
width: 34px;
height: 34px;
border: 3px solid var(--line);
border-top-color: var(--user);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

View File

@@ -0,0 +1,31 @@
cmake_minimum_required(VERSION 3.22.1)
project(tsjet LANGUAGES C CXX)
include(FetchContent)
# --- llama.cpp (pinned) -----------------------------------------------------
# Pinned to a specific release so the JNI wrapper below always matches the API
# it was written against. Bump GIT_TAG deliberately, not casually.
set(LLAMA_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_TOOLS OFF CACHE BOOL "" FORCE)
set(LLAMA_BUILD_SERVER OFF CACHE BOOL "" FORCE)
set(LLAMA_CURL OFF CACHE BOOL "" FORCE)
set(GGML_OPENMP OFF CACHE BOOL "" FORCE)
FetchContent_Declare(
llama
GIT_REPOSITORY https://github.com/ggml-org/llama.cpp.git
GIT_TAG b10333
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(llama)
# --- our JNI bridge ---------------------------------------------------------
add_library(tsjet SHARED llama-jni.cpp)
find_library(log-lib log)
target_compile_features(tsjet PRIVATE cxx_std_17)
target_link_libraries(tsjet PRIVATE llama ${log-lib})

View File

@@ -0,0 +1,205 @@
// JNI bridge between the Android app and llama.cpp.
//
// Written against the llama.cpp C API as of tag b10333 (see CMakeLists.txt).
// If you bump the pinned tag, re-check the signatures used here against
// include/llama.h — this file talks to the raw C API, no `common` helpers.
#include <jni.h>
#include <android/log.h>
#include <algorithm>
#include <cstring>
#include <string>
#include <vector>
#include "llama.h"
#define LOG_TAG "tsjet-native"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
namespace {
// Everything needed to run one model + generate from it.
struct tsjet_state {
llama_model * model = nullptr;
llama_context * ctx = nullptr;
const llama_vocab * vocab = nullptr;
llama_sampler * smpl = nullptr;
int n_decoded = 0;
int n_predict = 512;
bool done = false;
};
inline tsjet_state *as_state(jlong handle) {
return reinterpret_cast<tsjet_state *>(static_cast<uintptr_t>(handle));
}
} // namespace
extern "C" {
JNIEXPORT void JNICALL
Java_monster_autisme_tsjetpiti_LlamaBridge_backendInit(JNIEnv *, jclass) {
llama_backend_init();
}
JNIEXPORT jlong JNICALL
Java_monster_autisme_tsjetpiti_LlamaBridge_loadModel(
JNIEnv *env, jclass, jstring jpath, jint n_ctx, jint n_threads) {
const char *path = env->GetStringUTFChars(jpath, nullptr);
if (path == nullptr) return 0;
auto *s = new tsjet_state();
llama_model_params mparams = llama_model_default_params();
mparams.n_gpu_layers = 0; // CPU only on-device
s->model = llama_model_load_from_file(path, mparams);
env->ReleaseStringUTFChars(jpath, path);
if (s->model == nullptr) {
LOGE("failed to load model");
delete s;
return 0;
}
s->vocab = llama_model_get_vocab(s->model);
llama_context_params cparams = llama_context_default_params();
cparams.n_ctx = static_cast<uint32_t>(n_ctx);
cparams.n_batch = 512;
cparams.n_ubatch = 512;
cparams.n_threads = n_threads;
cparams.n_threads_batch = n_threads;
s->ctx = llama_init_from_model(s->model, cparams);
if (s->ctx == nullptr) {
LOGE("failed to create context");
llama_model_free(s->model);
delete s;
return 0;
}
llama_sampler_chain_params sp = llama_sampler_chain_default_params();
s->smpl = llama_sampler_chain_init(sp);
llama_sampler_chain_add(s->smpl, llama_sampler_init_top_k(40));
llama_sampler_chain_add(s->smpl, llama_sampler_init_top_p(0.95f, 1));
llama_sampler_chain_add(s->smpl, llama_sampler_init_temp(0.7f));
llama_sampler_chain_add(s->smpl, llama_sampler_init_dist(LLAMA_DEFAULT_SEED));
LOGI("model loaded, n_ctx=%d threads=%d", n_ctx, n_threads);
return static_cast<jlong>(reinterpret_cast<uintptr_t>(s));
}
JNIEXPORT void JNICALL
Java_monster_autisme_tsjetpiti_LlamaBridge_resetMemory(JNIEnv *, jclass, jlong handle) {
tsjet_state *s = as_state(handle);
if (s == nullptr) return;
llama_memory_clear(llama_get_memory(s->ctx), true);
llama_sampler_reset(s->smpl);
s->n_decoded = 0;
s->done = false;
}
JNIEXPORT jint JNICALL
Java_monster_autisme_tsjetpiti_LlamaBridge_startCompletion(
JNIEnv *env, jclass, jlong handle, jbyteArray jprompt, jint max_tokens) {
tsjet_state *s = as_state(handle);
if (s == nullptr) return -1;
jbyte *data = env->GetByteArrayElements(jprompt, nullptr);
jsize len = env->GetArrayLength(jprompt);
std::string prompt(reinterpret_cast<const char *>(data), static_cast<size_t>(len));
env->ReleaseByteArrayElements(jprompt, data, JNI_ABORT);
// Tokenize (parse ChatML special tokens; let the model decide on BOS).
const int32_t n_needed = -llama_tokenize(
s->vocab, prompt.c_str(), (int32_t) prompt.size(),
nullptr, 0, /*add_special=*/true, /*parse_special=*/true);
if (n_needed <= 0) return -2;
std::vector<llama_token> tokens(n_needed);
const int32_t n_tokens = llama_tokenize(
s->vocab, prompt.c_str(), (int32_t) prompt.size(),
tokens.data(), (int32_t) tokens.size(),
/*add_special=*/true, /*parse_special=*/true);
if (n_tokens < 0) return -3;
tokens.resize(n_tokens);
s->n_decoded = 0;
s->n_predict = max_tokens;
s->done = false;
// Feed the prompt in n_batch-sized chunks; positions are tracked automatically.
const int n_batch = 512;
for (int i = 0; i < (int) tokens.size(); i += n_batch) {
int count = std::min(n_batch, (int) tokens.size() - i);
llama_batch batch = llama_batch_get_one(tokens.data() + i, count);
if (llama_decode(s->ctx, batch) != 0) {
LOGE("prompt decode failed at offset %d", i);
return -4;
}
}
return 0;
}
// Returns the raw UTF-8 bytes of the next generated token, or null when the
// generation is finished (end-of-generation token or predict limit reached).
JNIEXPORT jbyteArray JNICALL
Java_monster_autisme_tsjetpiti_LlamaBridge_nextToken(JNIEnv *env, jclass, jlong handle) {
tsjet_state *s = as_state(handle);
if (s == nullptr || s->done) return nullptr;
if (s->n_decoded >= s->n_predict) {
s->done = true;
return nullptr;
}
llama_token id = llama_sampler_sample(s->smpl, s->ctx, -1);
if (llama_vocab_is_eog(s->vocab, id)) {
s->done = true;
return nullptr;
}
// token -> bytes
char buf[256];
int n = llama_token_to_piece(s->vocab, id, buf, sizeof(buf), 0, /*special=*/false);
std::string piece;
if (n < 0) {
std::vector<char> big(-n);
int n2 = llama_token_to_piece(s->vocab, id, big.data(), (int) big.size(), 0, false);
if (n2 > 0) piece.assign(big.data(), n2);
} else if (n > 0) {
piece.assign(buf, n);
}
// Advance the context so the next sample has fresh logits.
llama_batch batch = llama_batch_get_one(&id, 1);
if (llama_decode(s->ctx, batch) != 0) {
LOGE("decode failed during generation");
s->done = true;
}
s->n_decoded++;
jbyteArray arr = env->NewByteArray((jsize) piece.size());
if (!piece.empty()) {
env->SetByteArrayRegion(arr, 0, (jsize) piece.size(),
reinterpret_cast<const jbyte *>(piece.data()));
}
return arr;
}
JNIEXPORT void JNICALL
Java_monster_autisme_tsjetpiti_LlamaBridge_freeModel(JNIEnv *, jclass, jlong handle) {
tsjet_state *s = as_state(handle);
if (s == nullptr) return;
if (s->smpl) llama_sampler_free(s->smpl);
if (s->ctx) llama_free(s->ctx);
if (s->model) llama_model_free(s->model);
delete s;
}
} // extern "C"

View File

@@ -0,0 +1,32 @@
package monster.autisme.tsjetpiti
/**
* Thin Kotlin front for the native llama.cpp bridge (see cpp/llama-jni.cpp).
*
* Generation is a two-step stream: [startCompletion] feeds the prompt, then
* [nextToken] is called in a loop until it returns null.
*/
object LlamaBridge {
init {
System.loadLibrary("tsjet")
}
/** Initialise the llama.cpp backend once per process. */
@JvmStatic external fun backendInit()
/** Load a GGUF model. Returns an opaque handle, or 0 on failure. */
@JvmStatic external fun loadModel(path: String, nCtx: Int, nThreads: Int): Long
/** Clear the KV cache + sampler so the next completion starts clean. */
@JvmStatic external fun resetMemory(handle: Long)
/** Tokenize + decode [promptUtf8]. Returns 0 on success, negative on error. */
@JvmStatic external fun startCompletion(handle: Long, promptUtf8: ByteArray, maxTokens: Int): Int
/** Next token's raw UTF-8 bytes, or null when generation is finished. */
@JvmStatic external fun nextToken(handle: Long): ByteArray?
/** Free the model + context. The handle must not be used afterwards. */
@JvmStatic external fun freeModel(handle: Long)
}

View File

@@ -0,0 +1,173 @@
package monster.autisme.tsjetpiti
import android.annotation.SuppressLint
import android.app.Activity
import android.os.Bundle
import android.webkit.JavascriptInterface
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
/**
* The whole app: a full-screen WebView (the barebones chat UI) wired to an
* on-device llama.cpp model through [LlamaBridge].
*
* The web layer owns the conversation. Each turn it hands the full message
* history to [Bridge.generate]; native rebuilds the prompt from scratch, so
* "new tsjet" is just: clear the JS state + reset the KV cache.
*/
class MainActivity : Activity() {
private lateinit var webView: WebView
private val setupExecutor = Executors.newSingleThreadExecutor()
private val genExecutor = Executors.newSingleThreadExecutor()
private val stopFlag = AtomicBoolean(false)
private val busy = AtomicBoolean(false)
@Volatile private var handle: Long = 0L
@Volatile private var setupStarted = false
companion object {
private const val N_CTX = 4096
private const val MAX_TOKENS = 512
// Optional system prompt. Blank keeps the model's own persona.
private const val SYSTEM_PROMPT = ""
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
webView = WebView(this)
setContentView(webView)
webView.settings.apply {
javaScriptEnabled = true
domStorageEnabled = true
cacheMode = WebSettings.LOAD_NO_CACHE
}
webView.addJavascriptInterface(Bridge(), "TsjetNative")
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (!setupStarted) {
setupStarted = true
startSetup()
}
}
}
webView.loadUrl("file:///android_asset/web/index.html")
}
/** Fire a callback into the web layer: window.__tsjet.<fn>("<arg>"). */
private fun callJs(fn: String, arg: String) {
val quoted = JSONObject.quote(arg)
runOnUiThread {
webView.evaluateJavascript("window.__tsjet && window.__tsjet.$fn($quoted);", null)
}
}
private fun startSetup() = setupExecutor.execute {
try {
callJs("onStatus", "Waking the tsjet…")
val modelFile = ModelDownloader.ensureModel(this) { pct ->
callJs("onStatus", "Downloading the tsjet brain… $pct%")
}
callJs("onStatus", "Loading model…")
LlamaBridge.backendInit()
val threads = (Runtime.getRuntime().availableProcessors() - 2).coerceAtLeast(2)
val h = LlamaBridge.loadModel(modelFile.absolutePath, N_CTX, threads)
if (h == 0L) {
callJs("onError", "Failed to load the model.")
return@execute
}
handle = h
callJs("onReady", "")
} catch (e: Exception) {
callJs("onError", e.message ?: "Setup failed.")
}
}
private fun buildPrompt(messages: JSONArray): ByteArray {
val sb = StringBuilder()
if (SYSTEM_PROMPT.isNotBlank()) {
sb.append("<|im_start|>system\n").append(SYSTEM_PROMPT).append("<|im_end|>\n")
}
for (i in 0 until messages.length()) {
val m = messages.getJSONObject(i)
val role = m.optString("role", "user")
val content = m.optString("content", "")
sb.append("<|im_start|>").append(role).append('\n')
.append(content).append("<|im_end|>\n")
}
sb.append("<|im_start|>assistant\n")
return sb.toString().toByteArray(Charsets.UTF_8)
}
inner class Bridge {
@JavascriptInterface
fun generate(messagesJson: String) {
if (handle == 0L) {
callJs("onError", "Model not ready yet.")
return
}
if (!busy.compareAndSet(false, true)) return
stopFlag.set(false)
genExecutor.execute {
try {
val messages = JSONArray(messagesJson)
val prompt = buildPrompt(messages)
LlamaBridge.resetMemory(handle)
val rc = LlamaBridge.startCompletion(handle, prompt, MAX_TOKENS)
if (rc != 0) {
callJs("onError", "Inference failed to start ($rc).")
return@execute
}
callJs("onStart", "")
val out = ByteArrayOutputStream()
while (!stopFlag.get()) {
val piece = LlamaBridge.nextToken(handle) ?: break
if (piece.isNotEmpty()) {
out.write(piece)
callJs("onUpdate", out.toString("UTF-8"))
}
}
callJs("onDone", out.toString("UTF-8"))
} catch (e: Exception) {
callJs("onError", e.message ?: "Inference error.")
} finally {
busy.set(false)
}
}
}
@JavascriptInterface
fun stop() {
stopFlag.set(true)
}
@JavascriptInterface
fun newChat() {
stopFlag.set(true)
val h = handle
if (h != 0L) genExecutor.execute { LlamaBridge.resetMemory(h) }
}
}
override fun onDestroy() {
stopFlag.set(true)
val h = handle
handle = 0L
genExecutor.execute { if (h != 0L) LlamaBridge.freeModel(h) }
setupExecutor.shutdown()
genExecutor.shutdown()
super.onDestroy()
}
}

View File

@@ -0,0 +1,89 @@
package monster.autisme.tsjetpiti
import android.content.Context
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Downloads the GGUF model on first launch and caches it in the app's private
* files dir. ~1.2 GB, so it only happens once.
*/
object ModelDownloader {
const val MODEL_FILENAME = "Qwen3.5-2B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf"
private const val MODEL_URL =
"https://huggingface.co/HauhauCS/Qwen3.5-2B-Uncensored-HauhauCS-Aggressive" +
"/resolve/main/$MODEL_FILENAME?download=true"
/**
* Returns the local model file, downloading it if needed.
* [onProgress] is called with 0..100 during download.
*/
fun ensureModel(context: Context, onProgress: (Int) -> Unit): File {
val dir = File(context.filesDir, "models").apply { mkdirs() }
val target = File(dir, MODEL_FILENAME)
if (target.exists() && target.length() > 0) return target
val part = File(dir, "$MODEL_FILENAME.part")
if (part.exists()) part.delete()
var url = URL(MODEL_URL)
var conn = open(url)
// Follow redirects manually as a safety net (HF -> CDN).
var redirects = 0
while (conn.responseCode in 300..399 && redirects < 5) {
val location = conn.getHeaderField("Location") ?: break
conn.disconnect()
url = URL(url, location)
conn = open(url)
redirects++
}
if (conn.responseCode !in 200..299) {
val code = conn.responseCode
conn.disconnect()
throw RuntimeException("Download failed: HTTP $code")
}
val total = conn.contentLengthLong
conn.inputStream.use { input ->
part.outputStream().use { output ->
val buf = ByteArray(1 shl 16)
var downloaded = 0L
var lastPct = -1
while (true) {
val read = input.read(buf)
if (read < 0) break
output.write(buf, 0, read)
downloaded += read
if (total > 0) {
val pct = (downloaded * 100 / total).toInt()
if (pct != lastPct) {
lastPct = pct
onProgress(pct)
}
}
}
output.flush()
}
}
conn.disconnect()
if (!part.renameTo(target)) {
throw RuntimeException("Could not finalize model file")
}
return target
}
private fun open(url: URL): HttpURLConnection =
(url.openConnection() as HttpURLConnection).apply {
instanceFollowRedirects = true
connectTimeout = 30_000
readTimeout = 30_000
setRequestProperty("User-Agent", "tsjetpiti")
connect()
}
}