Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d5269a420 | |||
| bd9f311b4b | |||
| f417fb7331 |
61
README.md
61
README.md
@@ -1,12 +1,31 @@
|
|||||||
# tsjetpiti
|
<p align="center">
|
||||||
|
<img src="piti-icon.png" alt="tsjetpiti icon" width="132">
|
||||||
|
</p>
|
||||||
|
|
||||||
A dead-simple Android chat app that runs an **uncensored Qwen3.5-2B** model
|
<p align="center">
|
||||||
fully **on-device** via an embedded [llama.cpp](https://github.com/ggml-org/llama.cpp),
|
<img src="piti-logo.png" alt="tsjetpiti" width="360">
|
||||||
wrapped in an **extremely barebones WebView UI**.
|
</p>
|
||||||
|
|
||||||
tsjet has a deliberate personality: it **always answers**, sounds confident and
|
<p align="center">
|
||||||
plausible, and is **hilariously, purposely wrong**. Hit **new tsjet** to wipe the
|
<strong>Tiny app. Big model. Absolutely no idea what it's talking about.</strong>
|
||||||
conversation and start fresh.
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>ANDROID 11+ • ARM64 • FULLY ON-DEVICE • CONFIDENTLY WRONG</sub>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Meet tsjet
|
||||||
|
|
||||||
|
**tsjetpiti** is a dead-simple Android chat app running an uncensored
|
||||||
|
**Qwen3.5-2B** model entirely on your phone through embedded
|
||||||
|
[llama.cpp](https://github.com/ggml-org/llama.cpp). No cloud inference, no
|
||||||
|
account, and no conversation leaving the device.
|
||||||
|
|
||||||
|
tsjet has one special talent: it **always answers**, sounds completely
|
||||||
|
confident, and is **hilariously, deliberately wrong**. Hit **new tsjet** whenever
|
||||||
|
you want to erase the evidence and start fresh.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,9 +49,11 @@ conversation and start fresh.
|
|||||||
- **Inference:** llama.cpp compiled from source (pinned tag `b10333`) through the
|
- **Inference:** llama.cpp compiled from source (pinned tag `b10333`) through the
|
||||||
NDK. Pulled automatically at build time via CMake `FetchContent` — no submodule
|
NDK. Pulled automatically at build time via CMake `FetchContent` — no submodule
|
||||||
to init.
|
to init.
|
||||||
- **Model:** `Qwen3.5-2B-Uncensored-HauhauCS-Aggressive-Q6_K.gguf` (~1.5 GB) is
|
- **Model:**
|
||||||
**downloaded on first launch** from Hugging Face into the app's private storage.
|
[`Qwen3.5-2B-Uncensored-HauhauCS-Aggressive`](https://huggingface.co/HauhauCS/Qwen3.5-2B-Uncensored-HauhauCS-Aggressive)
|
||||||
It is *not* bundled in the APK. (Swap the quant in `ModelDownloader.kt`.)
|
in Q6_K format (~1.5 GB) is **downloaded on first launch** from Hugging Face
|
||||||
|
into the app's private storage. It is *not* bundled in the APK. (Swap the
|
||||||
|
quant in `ModelDownloader.kt`.)
|
||||||
- **Persona:** set via `SYSTEM_PROMPT` in `MainActivity.kt`; sampling is a little
|
- **Persona:** set via `SYSTEM_PROMPT` in `MainActivity.kt`; sampling is a little
|
||||||
hot (temp 0.9) for playful answers. The model is a Qwen3 "thinking" model, so the
|
hot (temp 0.9) for playful answers. The model is a Qwen3 "thinking" model, so the
|
||||||
prompt disables reasoning (empty `<think></think>` prefill + `/no_think`) to keep
|
prompt disables reasoning (empty `<think></think>` prefill + `/no_think`) to keep
|
||||||
@@ -46,7 +67,7 @@ conversation and start fresh.
|
|||||||
- **A build machine** with the Android SDK + NDK. Easiest path: open the project
|
- **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`
|
in **Android Studio** (it will offer to install the matching NDK `27.2.12479018`
|
||||||
and CMake `3.22.1`).
|
and CMake `3.22.1`).
|
||||||
- **A phone:** 64-bit ARM (`arm64-v8a`), Android 8.0+ (minSdk 26), and enough free
|
- **A phone:** 64-bit ARM (`arm64-v8a`), Android 11+ (minSdk 30), and enough free
|
||||||
RAM to hold a ~1.5 GB Q6 model (~4 GB RAM device recommended).
|
RAM to hold a ~1.5 GB Q6 model (~4 GB RAM device recommended).
|
||||||
- Network on first launch to download the model.
|
- Network on first launch to download the model.
|
||||||
|
|
||||||
@@ -80,6 +101,24 @@ manager, and tap it. First launch downloads the ~1.5 GB model over Wi-Fi.
|
|||||||
| Model URL / filename | `ModelDownloader.kt` |
|
| Model URL / filename | `ModelDownloader.kt` |
|
||||||
| Generation params (ctx, temp, tokens) | `MainActivity.kt` + `llama-jni.cpp` |
|
| Generation params (ctx, temp, tokens) | `MainActivity.kt` + `llama-jni.cpp` |
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
tsjetpiti is small because it stands on the shoulders of some decidedly
|
||||||
|
not-small projects:
|
||||||
|
|
||||||
|
- [Qwen3.5-2B](https://huggingface.co/Qwen/Qwen3.5-2B) by the Qwen team is the
|
||||||
|
upstream base model.
|
||||||
|
- [Qwen3.5-2B-Uncensored-HauhauCS-Aggressive](https://huggingface.co/HauhauCS/Qwen3.5-2B-Uncensored-HauhauCS-Aggressive)
|
||||||
|
by [HauhauCS](https://huggingface.co/HauhauCS) is the model variant and GGUF
|
||||||
|
quantization used by the app.
|
||||||
|
- [llama.cpp](https://github.com/ggml-org/llama.cpp) by the ggml-org community
|
||||||
|
provides the on-device inference engine and GGUF runtime.
|
||||||
|
- [Android](https://developer.android.com/) and
|
||||||
|
[Kotlin](https://kotlinlang.org/) provide the application platform and native
|
||||||
|
app layer.
|
||||||
|
- [Gradle](https://gradle.org/) and [CMake](https://cmake.org/) power the Kotlin
|
||||||
|
and C++ build.
|
||||||
|
|
||||||
## TODO / notes
|
## TODO / notes
|
||||||
|
|
||||||
- **Branding:** launcher icon (adaptive + legacy densities) is generated from
|
- **Branding:** launcher icon (adaptive + legacy densities) is generated from
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "monster.autisme.tsjetpiti"
|
applicationId = "monster.autisme.tsjetpiti"
|
||||||
minSdk = 26
|
minSdk = 30
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
versionCode = 2
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.1"
|
||||||
|
|
||||||
// On-device LLM => 64-bit ARM only. Keeps the APK from ballooning.
|
// On-device LLM => 64-bit ARM only. Keeps the APK from ballooning.
|
||||||
ndk {
|
ndk {
|
||||||
|
|||||||
@@ -7,6 +7,12 @@
|
|||||||
--bubble: #141414;
|
--bubble: #141414;
|
||||||
--bubble-line: #242424;
|
--bubble-line: #242424;
|
||||||
|
|
||||||
|
/* Window insets (status/nav bar, cutout, keyboard) — set from Android. */
|
||||||
|
--inset-top: 0px;
|
||||||
|
--inset-right: 0px;
|
||||||
|
--inset-bottom: 0px;
|
||||||
|
--inset-left: 0px;
|
||||||
|
|
||||||
/* Generic, corporate/IT sans stack — resolves offline on any device. */
|
/* Generic, corporate/IT sans stack — resolves offline on any device. */
|
||||||
--font: "Segoe UI", "Helvetica Neue", Helvetica, Arial, "Liberation Sans",
|
--font: "Segoe UI", "Helvetica Neue", Helvetica, Arial, "Liberation Sans",
|
||||||
system-ui, sans-serif;
|
system-ui, sans-serif;
|
||||||
@@ -37,7 +43,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: max(env(safe-area-inset-top), 12px) 16px 12px;
|
padding: calc(var(--inset-top) + 12px) calc(var(--inset-right) + 16px) 12px
|
||||||
|
calc(var(--inset-left) + 16px);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
@@ -64,7 +71,7 @@ body {
|
|||||||
#chat {
|
#chat {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 18px 16px;
|
padding: 18px calc(var(--inset-right) + 16px) 18px calc(var(--inset-left) + 16px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -99,7 +106,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: 12px 14px calc(env(safe-area-inset-bottom) + 12px);
|
padding: 12px calc(var(--inset-right) + 14px) calc(var(--inset-bottom) + 12px)
|
||||||
|
calc(var(--inset-left) + 14px);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package monster.autisme.tsjetpiti
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.WindowInsets
|
||||||
import android.webkit.JavascriptInterface
|
import android.webkit.JavascriptInterface
|
||||||
import android.webkit.WebSettings
|
import android.webkit.WebSettings
|
||||||
import android.webkit.WebView
|
import android.webkit.WebView
|
||||||
@@ -32,6 +34,12 @@ class MainActivity : Activity() {
|
|||||||
@Volatile private var handle: Long = 0L
|
@Volatile private var handle: Long = 0L
|
||||||
@Volatile private var setupStarted = false
|
@Volatile private var setupStarted = false
|
||||||
|
|
||||||
|
// Window insets (status/nav bar, cutout, keyboard) in CSS px, pushed to the page.
|
||||||
|
private var insetTop = 0
|
||||||
|
private var insetBottom = 0
|
||||||
|
private var insetLeft = 0
|
||||||
|
private var insetRight = 0
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val N_CTX = 4096
|
private const val N_CTX = 4096
|
||||||
private const val MAX_TOKENS = 220
|
private const val MAX_TOKENS = 220
|
||||||
@@ -55,6 +63,30 @@ class MainActivity : Activity() {
|
|||||||
webView = WebView(this)
|
webView = WebView(this)
|
||||||
setContentView(webView)
|
setContentView(webView)
|
||||||
|
|
||||||
|
// Edge-to-edge (default on Android 15+): draw behind the system bars, then
|
||||||
|
// inset the WebView by the status bar, nav bar, display cutout, and IME so
|
||||||
|
// none of them ever cover the chat UI (portrait or landscape).
|
||||||
|
window.setDecorFitsSystemWindows(false)
|
||||||
|
webView.setBackgroundColor(Color.BLACK)
|
||||||
|
// WebView.setPadding doesn't inset HTML content (Chromium sizes 100dvh to the
|
||||||
|
// full view), so feed the insets to CSS as variables instead. This also tracks
|
||||||
|
// the IME: when the keyboard shows, bottom grows and the composer rises.
|
||||||
|
webView.setOnApplyWindowInsetsListener { _, insets ->
|
||||||
|
val bars = insets.getInsets(
|
||||||
|
WindowInsets.Type.systemBars() or
|
||||||
|
WindowInsets.Type.ime() or
|
||||||
|
WindowInsets.Type.displayCutout()
|
||||||
|
)
|
||||||
|
val d = resources.displayMetrics.density
|
||||||
|
insetTop = (bars.top / d).toInt()
|
||||||
|
insetBottom = (bars.bottom / d).toInt()
|
||||||
|
insetLeft = (bars.left / d).toInt()
|
||||||
|
insetRight = (bars.right / d).toInt()
|
||||||
|
pushInsets()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
webView.requestApplyInsets()
|
||||||
|
|
||||||
webView.settings.apply {
|
webView.settings.apply {
|
||||||
javaScriptEnabled = true
|
javaScriptEnabled = true
|
||||||
domStorageEnabled = true
|
domStorageEnabled = true
|
||||||
@@ -63,6 +95,7 @@ class MainActivity : Activity() {
|
|||||||
webView.addJavascriptInterface(Bridge(), "TsjetNative")
|
webView.addJavascriptInterface(Bridge(), "TsjetNative")
|
||||||
webView.webViewClient = object : WebViewClient() {
|
webView.webViewClient = object : WebViewClient() {
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
pushInsets() // apply insets now that the DOM exists
|
||||||
if (!setupStarted) {
|
if (!setupStarted) {
|
||||||
setupStarted = true
|
setupStarted = true
|
||||||
startSetup()
|
startSetup()
|
||||||
@@ -80,6 +113,16 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Push the current window insets to the page as CSS variables on :root. */
|
||||||
|
private fun pushInsets() {
|
||||||
|
val js = "var s=document.documentElement.style;" +
|
||||||
|
"s.setProperty('--inset-top','${insetTop}px');" +
|
||||||
|
"s.setProperty('--inset-bottom','${insetBottom}px');" +
|
||||||
|
"s.setProperty('--inset-left','${insetLeft}px');" +
|
||||||
|
"s.setProperty('--inset-right','${insetRight}px');"
|
||||||
|
runOnUiThread { webView.evaluateJavascript(js, null) }
|
||||||
|
}
|
||||||
|
|
||||||
private fun startSetup() = setupExecutor.execute {
|
private fun startSetup() = setupExecutor.execute {
|
||||||
try {
|
try {
|
||||||
callJs("onStatus", "Waking the tsjet…")
|
callJs("onStatus", "Waking the tsjet…")
|
||||||
|
|||||||
Reference in New Issue
Block a user