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:
62
app/build.gradle.kts
Normal file
62
app/build.gradle.kts
Normal 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
10
app/proguard-rules.pro
vendored
Normal 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>;
|
||||
}
|
||||
24
app/src/main/AndroidManifest.xml
Normal file
24
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
130
app/src/main/assets/web/app.js
Normal file
130
app/src/main/assets/web/app.js
Normal 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(); }
|
||||
});
|
||||
})();
|
||||
35
app/src/main/assets/web/index.html
Normal file
35
app/src/main/assets/web/index.html
Normal 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>
|
||||
148
app/src/main/assets/web/styles.css
Normal file
148
app/src/main/assets/web/styles.css
Normal 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); } }
|
||||
31
app/src/main/cpp/CMakeLists.txt
Normal file
31
app/src/main/cpp/CMakeLists.txt
Normal 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})
|
||||
205
app/src/main/cpp/llama-jni.cpp
Normal file
205
app/src/main/cpp/llama-jni.cpp
Normal 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"
|
||||
32
app/src/main/java/monster/autisme/tsjetpiti/LlamaBridge.kt
Normal file
32
app/src/main/java/monster/autisme/tsjetpiti/LlamaBridge.kt
Normal 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)
|
||||
}
|
||||
173
app/src/main/java/monster/autisme/tsjetpiti/MainActivity.kt
Normal file
173
app/src/main/java/monster/autisme/tsjetpiti/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user