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

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# Android / Gradle
.gradle/
build/
/local.properties
/captures/
.cxx/
.externalNativeBuild/
# IDE
*.iml
.idea/
# Artifacts
*.apk
*.aab
# Never commit model weights
*.gguf

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
# tsjetpiti
A dead-simple Android chat app that runs an **uncensored Qwen3.5-2B** model
fully **on-device** via an embedded [llama.cpp](https://github.com/ggml-org/llama.cpp),
wrapped in an **extremely barebones WebView UI**.
Talk to the tsjet. Hit **new tsjet** to wipe the conversation and start fresh.
---
## How it works
```
┌─────────────────────────────────────────┐
│ MainActivity (Kotlin) │
│ • full-screen WebView ── UI ──────────┼──> assets/web/{index.html,app.js,styles.css}
│ • JS bridge "TsjetNative" │ (the whole chat UI, ~200 lines)
│ • model download + prompt formatting │
└──────────────┬──────────────────────────┘
│ JNI (LlamaBridge)
┌─────────────────────────────────────────┐
│ cpp/llama-jni.cpp → libtsjet.so │
│ talks to llama.cpp C API (pinned) │
└─────────────────────────────────────────┘
```
- **Inference:** llama.cpp compiled from source (pinned tag `b10333`) through the
NDK. Pulled automatically at build time via CMake `FetchContent` — no submodule
to init.
- **Model:** `Qwen3.5-2B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf` (~1.2 GB) is
**downloaded on first launch** from Hugging Face into the app's private storage.
It is *not* bundled in the APK.
- **Conversation:** the web layer holds the full history and sends it each turn;
native rebuilds the ChatML prompt and clears the KV cache before every reply, so
"new tsjet" is just: clear JS state + reset cache.
## Requirements
- **A build machine** with the Android SDK + NDK. Easiest path: open the project
in **Android Studio** (it will offer to install the matching NDK `27.2.12479018`
and CMake `3.22.1`).
- **A phone:** 64-bit ARM (`arm64-v8a`), Android 8.0+ (minSdk 26), and enough free
RAM to hold a ~1.2 GB Q4 model (~34 GB RAM device recommended).
- Network on first launch to download the model.
## Build
```bash
./gradlew assembleDebug
```
The APK lands in `app/build/outputs/apk/debug/`. Or just Run ▶ from Android Studio.
> First build compiles llama.cpp from source, so it takes a while and needs
> network (CMake fetches the pinned llama.cpp).
## Where things live
| What | Where |
|------|-------|
| Chat UI (HTML/CSS/JS) | `app/src/main/assets/web/` |
| Android glue + model download | `app/src/main/java/monster/autisme/tsjetpiti/` |
| Native llama.cpp bridge | `app/src/main/cpp/llama-jni.cpp` |
| Pinned llama.cpp version | `app/src/main/cpp/CMakeLists.txt` (`GIT_TAG b10333`) |
| Model URL / filename | `ModelDownloader.kt` |
| Generation params (ctx, temp, tokens) | `MainActivity.kt` + `llama-jni.cpp` |
## TODO / notes
- **Logo:** juli is on it. The header logo is a placeholder `🐟` in
`assets/web/index.html` (`#logo`), and there's no launcher icon yet — add an
`android:icon` in `AndroidManifest.xml` + a `mipmap` when the artwork is ready.
- The model may emit `<think>…</think>` blocks; the UI strips them from the
display and from history (`stripThink` in `app.js`).
- Bumping the llama.cpp tag? Re-check the C API calls in `llama-jni.cpp` against
that tag's `include/llama.h` — it uses the raw C API directly.
- Not yet compiled end-to-end in CI; first real build is the acid test for the
native layer.

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()
}
}

4
build.gradle.kts Normal file
View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
}

5
gradle.properties Normal file
View File

@@ -0,0 +1,5 @@
org.gradle.jvmargs=-Xmx2560m -Dfile.encoding=UTF-8
org.gradle.caching=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

252
gradlew vendored Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
settings.gradle.kts Normal file
View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "tsjetpiti"
include(":app")