14 Commits
Author SHA1 Message Date
alexpolo1andClaude Sonnet 4.6 f9bfd76e00 Upgrade to LiteRT-LM runtime for Gemma 3n E4B support
Release / Build and Release APK (push) Failing after 9m46s
- Add com.google.ai.edge.litertlm:litertlm-android:0.9.0-alpha05 dependency
- New LiteRTModel backend: loads .litertlm files via Engine API with GPU backend
  (CPU fallback if GPU init fails)
- OnDeviceModel.create() priority: LiteRT-LM → MediaPipe → Gemini Nano
- ModelDownloader: updated to Gemma 3n E4B (4.9 GB) and E4B Web (4.3 GB)
  from google/gemma-3n-E4B-it-litert-lm (HuggingFace, license required)
- Download uses Bearer token auth; token saved to SharedPreferences

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:49:22 +01:00
alexpolo1andClaude Sonnet 4.6 af84d8ebc4 Fix downloads: HuggingFace auth + Gemma 3 1B IT models
Release / Build and Release APK (push) Failing after 11m18s
Google moved all models to HuggingFace (old CDN returns 404).
All downloads now require a HuggingFace API token.

- Add HF token input field (saved to SharedPreferences, restored on relaunch)
- Download uses Bearer auth header
- Switch to Gemma 3 1B IT (litert-community) — .task format, works with
  current MediaPipe API, 555 MB Q4 or 1 GB Q8
- Clear auth error messages (401/403 shown to user)
- Gemma 3n E4B/E2B (.litertlm format) requires runtime upgrade — planned next

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:36:05 +01:00
alexpolo1andClaude Sonnet 4.6 0861ed38fc Add multi-model download: Gemma 3n E4B (recommended), E2B, Gemma 2B
Release / Build and Release APK (push) Failing after 14m8s
- ModelSpec enum with 3 downloadable options from MediaPipe CDN
- Gemma 3n E4B set as recommended default (best coding/reasoning via MoE)
- Gemma 3n E2B as faster/smaller alternative
- Gemma 2B kept as lightest option
- UI shows all 3 download buttons, hides all once any model is installed
- Installed model name shown in status (e.g. "✓ Gemma 3n E4B ready")
- Custom models (DeepSeek Coder, Qwen2.5-Coder) can be manually placed in files dir

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:25:49 +01:00
alexpolo1andClaude Sonnet 4.6 371bd55e18 Fix background inference: MediaPipe-first, in-app model downloader
Release / Build and Release APK (push) Failing after 9m2s
Root cause: ML Kit Gemini Nano enforces ErrorCode 30 (foreground restriction)
at the AICore binder level. No foreground service or wake lock can bypass it —
the app's Activity must be the visible top window.

Fix: MediaPipe LLM Inference runs entirely in the app process via the Tensor G5
GPU (OpenCL/Vulkan), with no AICore dependency and no foreground restriction.

Changes:
- OnDeviceModel.create() now tries MediaPipe FIRST, Nano second
- ModelDownloader.kt: downloads Gemma 2B IT GPU INT4 (~1.3GB) from Google's
  MediaPipe model CDN with resume support (Range header)
- MainActivity: "Download Model" card shows download status and progress bar;
  auto-hides once model is present; uses lifecycleScope for coroutine
- Layout: model card inserted between status and port field
- Strings: btn_download_model, model_downloaded, model_not_downloaded

Once the model is downloaded the server accepts requests in the background
indefinitely with no foreground Activity required.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:19:14 +01:00
alexpolo1andClaude Sonnet 4.6 08b6a20ff1 Add coding agent system prompt, tool set, and /v1/agent endpoint
Release / Build and Release APK (push) Failing after 9m20s
AgentConfig.kt defines the full agent configuration optimised for Gemini
Nano on the Tensor G5 chip:

System prompt (~700 tokens):
- Five explicit workflow stages: EXPLORE → PLAN → CHANGE → VERIFY → DONE
- Hard rules: one tool per turn, 1-3 sentence replies, 120-line read limit,
  max 3 files per task, patch_file preferred over write_file

Seven tools (OpenAI function-calling format):
  read_file(path, start_line?, end_line?)  — sectioned reads, max 120 lines
  write_file(path, content)               — new files / full rewrites < 80 ln
  patch_file(path, old_str, new_str)      — targeted in-place edits (preferred)
  list_dir(path, depth?)                  — directory structure
  search_code(pattern, path?, include?)   — regex search across files
  run_command(command, cwd?)              — build, test, lint
  task_done(summary, files_changed?)      — explicit completion signal

AIApiServer changes:
- Auto-injects the agent system prompt when the conversation has no system
  message, and auto-injects DEFAULT_TOOLS when the request provides none.
  Makes the server zero-config for any OpenAI-compatible agent client.
- New GET /v1/agent endpoint returns system_prompt + tools + notes as JSON
  so clients like OpenClaw can fetch the config and apply it automatically.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 22:03:32 +01:00
alexpolo1andClaude Sonnet 4.6 598053e94a Remove cloud backend — on-device Tensor chip only
Delete GeminiCloudModel.kt entirely. All inference now runs through
Gemini Nano on the Tensor G5 TPU via ML Kit, with MediaPipe as fallback
for custom local model files. No data leaves the device.

- OnDeviceModel.create() reverts to Nano → MediaPipe chain, no apiKey param
- Removed: API key UI, SharedPreferences key storage, cloud model routing
- Removed: thinking mode (cloud-only feature)
- Removed: pixel10-fast / pixel10-thinking model IDs → single "pixel10" model
- /v1/models now reports honest on-device limits (4096 ctx, 1024 output)
- CI smoke test updated: installs APK on emulator and verifies package,
  no inference tests (require real Pixel hardware with Tensor chip)

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 21:58:21 +01:00
alexpolo1andClaude Sonnet 4.6 48cf8eb347 Raise defaults and expose 1M context window to clients
- max_tokens default: 1024 → 8192 (enough for full functions and files)
- thinking mode max_tokens default: 2048 → 16384
- chat() default: 1024 → 8192
- ModelInfo gains context_length (1_000_000) and max_output_tokens fields
  so OpenClaw and other agent frameworks can auto-configure correctly
- /v1/models now advertises full 1M input context for both models

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 21:49:16 +01:00
alexpolo1andClaude Sonnet 4.6 8171930805 Add tool/function calling — enables use as an agent brain
OnDeviceModel gains a chat() method accepting the full message history and
a list of ToolDef entries. Returns ChatResult which is either a text reply or
a list of ToolCallData the model wants to invoke. Default impl flattens
messages to a prompt so Nano and MediaPipe backends work unchanged.

GeminiCloudModel overrides chat() with native Gemini function calling:
- Converts OpenAI tools to Gemini functionDeclarations
- Handles system messages via Gemini's systemInstruction field
- Converts multi-turn history including assistant tool_calls and tool results
  (role=tool → Gemini functionResponse with name resolved from prior turns)
- Parses functionCall parts in the response and returns ToolCallData list
- Falls back to text content when no function call is present

AIApiServer routes to chat() when tools are provided or the conversation
has more than one turn. Returns finish_reason=tool_calls and the tool_calls
array in the assistant message so OpenClaw / any OpenAI-compatible agent
client can execute tools and feed results back.

ApiModels updated: Message.content nullable, tool_calls and tool_call_id
added to Message, Tool/ToolFunction/ToolCall/FunctionCallDetail added,
tools and tool_choice added to ChatRequest.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 21:45:31 +01:00
alexpolo1andClaude Sonnet 4.6 2f829ef2ae Add thinking mode — fast and thinking model support
Expose two model IDs matching the Gemini app's modes:
- pixel10-fast: Gemini 2.0 Flash, low latency, no reasoning trace
- pixel10-thinking: Gemini 2.5 Flash, step-by-step reasoning before answering

OnDeviceModel gains generateWithThinking() returning ThinkingResult
(thinking: String, response: String). Default impl delegates to generate()
so Nano and MediaPipe backends work unchanged.

GeminiCloudModel overrides generateWithThinking() to call
gemini-2.5-flash-preview-04-17 with thinkingConfig.thinkingBudget. Parts
with thought=true are collected as the reasoning trace; remaining parts form
the final answer.

ChatRequest gains thinking_budget (0 = fast, >0 = thinking) and model fields.
AIApiServer routes to thinking mode when model name contains "think" or
thinking_budget > 0. Thinking responses include a non-standard thinking field
in Choice alongside the normal content. /v1/models lists both model IDs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 21:36:47 +01:00
alexpolo1andClaude Sonnet 4.6 e2fb7bf1da Add GeminiCloudModel, background inference fix, and CI pipeline
- Add GeminiCloudModel: proxies inference to Gemini 2.0 Flash via HTTPS
  using HttpURLConnection (no new deps). Supports both blocking and SSE
  streaming. Works from any context — background, emulator, CI.

- Fix background inference: OnDeviceModel.create() now accepts an apiKey;
  when set, GeminiCloudModel is selected immediately, bypassing Gemini
  Nano's foreground-only restriction. ApiServerService reads the key from
  SharedPreferences at startup.

- Add API key UI: password field in MainActivity saved to SharedPreferences
  before the service starts; disabled while server is running.

- Add GitHub Actions CI (.github/workflows/ci.yml): build job produces a
  debug APK artifact; test job spins up a KVM-accelerated Android 31
  emulator, installs the APK, writes the GEMINI_API_KEY secret into
  SharedPreferences via adb run-as, starts the service, and runs curl
  assertions against /health, /v1/models, /v1/chat/completions, and the
  SSE streaming endpoint.

- Add release workflow (.github/workflows/release.yml): triggered on v*
  tags, builds the APK and creates a GitHub Release with it attached.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-02-28 21:29:47 +01:00
alexpolo1andClaude Opus 4.6 b0147405c0 Make project production-grade for public release
- Add adaptive app icon with AI chip + signal wave vector design
- Add Apache 2.0 LICENSE file
- Rewrite README with badges, full API docs, Python examples,
  device compatibility table, dependency licenses, and ToS disclaimer
- Extract all hardcoded layout strings to strings.xml
- Add roundIcon support in AndroidManifest
- Add GitHub community files: issue templates, PR template,
  CONTRIBUTING.md, SECURITY.md

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-28 16:03:30 +01:00
alexpolo1andClaude Opus 4.6 01db3cedbc Fix build: upgrade Kotlin, fix ML Kit/MediaPipe API imports
- Bump Kotlin from 2.0.21 to 2.1.20 for ML Kit genai metadata 2.2.0 compat
- Fix GeminiNanoModel imports: DownloadStatus/FeatureStatus moved to genai.common,
  TextPart/generateContentRequest moved out of .type subpackage
- Fix MediaPipeModel: replace removed setTopK/setTemperature/setRandomSeed
  with setMaxTopK (MediaPipe 0.10.24 API)
- Fix gradlew: remove broken lines that passed GradleWrapperMain as task arg

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-02-28 15:50:21 +01:00
Claude 65b5d66e67 Fix API correctness issues found during static audit
- GeminiNanoModel: Rewrite to use actual ML Kit Prompt API
  (Generation.getClient(), checkStatus(), download(), warmup())
- MediaPipeModel: Fix streaming to use sync fallback since
  MediaPipe requires result listener set at build time
- ApiModels: Add @SerializedName("object") for OpenAI compat,
  add "created" timestamp to ChatResponse/StreamChunk
- settings.gradle.kts: Fix dependencyResolutionManagement typo
- MainActivity: Use GradientDrawable.setColor() to preserve
  oval shape on status dot
- Add Gradle wrapper scripts (gradlew, gradlew.bat, jar)

https://claude.ai/code/session_01GvqMLSMmfMR8uz66BFVXX2
2026-02-28 14:27:46 +00:00
Claude 631bc30c40 Add Pixel10 AI Server — expose on-device AI chip as REST API
Android app that turns a Pixel 10 into a free AI API server by leveraging
the Tensor G5's on-device AI capabilities through an embedded HTTP server.

Key components:
- Dual AI backend: Gemini Nano (ML Kit Prompt API) + MediaPipe LLM fallback
- OpenAI-compatible REST API (chat/completions, completions, models, health)
- NanoHTTPD embedded server with CORS support and SSE streaming
- Foreground service with wake lock for persistent background operation
- Material Design 3 dashboard with live request logging

All inference runs entirely on-device — zero cloud costs, full offline capability.

https://claude.ai/code/session_01GvqMLSMmfMR8uz66BFVXX2
2026-02-28 14:09:41 +00:00
19 changed files with 72 additions and 686 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
key: gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: gradle-
- name: Build signed APK
- name: Build debug APK
run: ./gradlew assembleDebug --no-daemon
- name: Rename APK with version tag
-2
View File
@@ -15,5 +15,3 @@ local.properties
*.bin
*.task
*.tflite
*.keystore
*.jks
+2 -29
View File
@@ -11,25 +11,8 @@ android {
applicationId = "com.pixel10.ai"
minSdk = 31
targetSdk = 35
versionCode = 8
versionName = "1.8.0"
}
val localProps = rootProject.file("local.properties")
val props = java.util.Properties().apply {
if (localProps.exists()) load(localProps.inputStream())
}
val keystoreFile = rootProject.file(props.getProperty("KEYSTORE_FILE", "pixel10.keystore"))
signingConfigs {
create("release") {
if (keystoreFile.exists() && props.containsKey("KEYSTORE_PASSWORD")) {
storeFile = keystoreFile
storePassword = props.getProperty("KEYSTORE_PASSWORD")
keyAlias = props.getProperty("KEY_ALIAS", "pixel10")
keyPassword = props.getProperty("KEY_PASSWORD")
}
}
versionCode = 1
versionName = "1.0.0"
}
buildTypes {
@@ -39,16 +22,6 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
val releaseSigning = signingConfigs.getByName("release")
if (releaseSigning.storeFile != null) {
signingConfig = releaseSigning
}
}
debug {
val releaseSigning = signingConfigs.getByName("release")
if (releaseSigning.storeFile != null) {
signingConfig = releaseSigning
}
}
}
-5
View File
@@ -27,11 +27,6 @@
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@style/Theme.Pixel10AI">
<activity
android:name=".ui.ChatActivity"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.MainActivity"
android:exported="true"
@@ -7,8 +7,6 @@ import com.google.ai.edge.litertlm.Engine
import com.google.ai.edge.litertlm.EngineConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
@@ -31,49 +29,42 @@ class LiteRTModel private constructor(
override var isReady: Boolean = true
private set
// LiteRT Engine is not thread-safe — serialize all inference calls
private val mutex = Mutex()
override suspend fun generate(
prompt: String,
maxTokens: Int,
temperature: Float
): String = mutex.withLock {
withContext(Dispatchers.Default) {
val conversation = engine.createConversation()
try {
conversation.sendMessage(prompt).toString()
} catch (e: Exception) {
Log.e(TAG, "LiteRT inference error", e)
throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e)
} finally {
conversation.close()
}
): String = withContext(Dispatchers.Default) {
val conversation = engine.createConversation()
try {
conversation.sendMessage(prompt).toString()
} catch (e: Exception) {
Log.e(TAG, "LiteRT inference error", e)
throw OnDeviceModel.InferenceException("Generation failed: ${e.message}", e)
} finally {
conversation.close()
}
}
override suspend fun generateStreaming(
prompt: String,
onToken: (String) -> Unit
): String = mutex.withLock {
withContext(Dispatchers.Default) {
val conversation = engine.createConversation()
val sb = StringBuilder()
try {
conversation.sendMessageAsync(prompt)
.catch { e ->
throw OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e)
}
.collect { message ->
val token = message.toString()
sb.append(token)
onToken(token)
}
} finally {
conversation.close()
}
sb.toString()
): String = withContext(Dispatchers.Default) {
val conversation = engine.createConversation()
val sb = StringBuilder()
try {
conversation.sendMessageAsync(prompt)
.catch { e ->
throw OnDeviceModel.InferenceException("Streaming failed: ${e.message}", e)
}
.collect { message ->
val token = message.toString()
sb.append(token)
onToken(token)
}
} finally {
conversation.close()
}
sb.toString()
}
override fun close() {
@@ -27,16 +27,9 @@ import java.util.concurrent.atomic.AtomicLong
* -H "Content-Type: application/json" \
* -d '{"messages":[{"role":"user","content":"Hello!"}]}'
*/
data class ServerConfig(
val defaultTemperature: Float = 0.7f,
val defaultMaxTokens: Int = 1024,
val autoSystemPrompt: Boolean = true
)
class AIApiServer(
port: Int,
private val model: OnDeviceModel,
private val config: ServerConfig = ServerConfig()
private val model: OnDeviceModel
) : NanoHTTPD(port) {
private val gson = Gson()
@@ -44,7 +37,6 @@ class AIApiServer(
val requestCount = AtomicLong(0)
var onRequestLogged: ((String) -> Unit)? = null
var onActiveRequest: ((Boolean) -> Unit)? = null
override fun serve(session: IHTTPSession): Response {
val method = session.method
@@ -121,48 +113,31 @@ class AIApiServer(
return errorResponse(400, "messages array is required and must not be empty")
}
// Auto-inject agent system prompt if enabled and no system message present
val messages = if (config.autoSystemPrompt && raw.messages.none { it.role == "system" }) {
// Auto-inject agent system prompt if the conversation has no system message.
// Auto-inject default tools if the request provides none.
// This makes the server zero-config as a coding agent for any OpenAI-compatible client.
val messages = if (raw.messages.none { it.role == "system" }) {
listOf(Message(role = "system", content = AgentConfig.SYSTEM_PROMPT)) + raw.messages
} else {
raw.messages
}
val request = raw.copy(
messages = messages,
tools = raw.tools.takeUnless { it.isNullOrEmpty() }
?: if (config.autoSystemPrompt) AgentConfig.DEFAULT_TOOLS else null,
temperature = if (raw.temperature == 0.7f) config.defaultTemperature else raw.temperature,
max_tokens = if (raw.max_tokens == 8192) config.defaultMaxTokens else raw.max_tokens
tools = raw.tools.takeUnless { it.isNullOrEmpty() } ?: AgentConfig.DEFAULT_TOOLS
)
val id = "chatcmpl-${UUID.randomUUID().toString().take(8)}"
val hasTools = !request.tools.isNullOrEmpty()
log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}, temp=${request.temperature}")
// ── Streaming — always uses flat prompt + generateStreaming ────────────
if (request.stream) {
val prompt = buildFlatPrompt(request.messages)
onActiveRequest?.invoke(true)
return try {
handleStreamingResponse(id, prompt, request)
} finally {
onActiveRequest?.invoke(false)
}
}
log("Chat: ${request.messages.size} messages, tools=${request.tools?.size ?: 0}, stream=${request.stream}")
// ── Tool calling / multi-turn chat ─────────────────────────────────────
if (hasTools || request.messages.size > 1 || request.messages.any { it.role == "system" }) {
val convMessages = request.messages.map { it.toConvMessage() }
val toolDefs = request.tools?.map { it.toToolDef() } ?: emptyList()
onActiveRequest?.invoke(true)
val result = try {
runBlocking {
model.chat(convMessages, toolDefs, request.max_tokens, request.temperature)
}
} finally {
onActiveRequest?.invoke(false)
val result = runBlocking {
model.chat(convMessages, toolDefs, request.max_tokens, request.temperature)
}
if (result.toolCalls != null) {
@@ -34,11 +34,9 @@ class ApiServerService : Service() {
var onStatusChanged: ((ServerState) -> Unit)? = null
var onLog: ((String) -> Unit)? = null
var onActiveRequest: ((Boolean) -> Unit)? = null
val isRunning: Boolean get() = server != null
val requestCount: Long get() = server?.requestCount?.get() ?: 0
val currentModel: OnDeviceModel? get() = model
inner class LocalBinder : Binder() {
val service: ApiServerService get() = this@ApiServerService
@@ -72,15 +70,8 @@ class ApiServerService : Service() {
// Start the HTTP server
notifyLog("Starting API server on port $port...")
val prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
val serverConfig = ServerConfig(
defaultTemperature = prefs.getFloat("temperature", 0.7f),
defaultMaxTokens = prefs.getInt("max_tokens", 1024),
autoSystemPrompt = prefs.getBoolean("auto_system_prompt", true)
)
val apiServer = AIApiServer(port, model!!, serverConfig)
val apiServer = AIApiServer(port, model!!)
apiServer.onRequestLogged = { msg -> notifyLog(msg) }
apiServer.onActiveRequest = { active -> onActiveRequest?.invoke(active) }
apiServer.start()
server = apiServer
@@ -1,158 +0,0 @@
package com.pixel10.ai.ui
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.view.inputmethod.EditorInfo
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.pixel10.ai.databinding.ActivityChatBinding
import com.pixel10.ai.inference.OnDeviceModel
import com.pixel10.ai.server.AgentConfig
import com.pixel10.ai.server.ApiServerService
import kotlinx.coroutines.launch
class ChatActivity : AppCompatActivity() {
private lateinit var binding: ActivityChatBinding
private val messages = mutableListOf<ChatMessage>()
private lateinit var adapter: MessageAdapter
private var service: ApiServerService? = null
private var bound = false
private var generating = false
private val serviceConnection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
service = (binder as ApiServerService.LocalBinder).service
bound = true
}
override fun onServiceDisconnected(name: ComponentName?) {
service = null
bound = false
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityChatBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
binding.toolbar.setNavigationOnClickListener { finish() }
adapter = MessageAdapter(messages)
binding.rvMessages.layoutManager = LinearLayoutManager(this).also {
it.stackFromEnd = true
}
binding.rvMessages.adapter = adapter
binding.btnSend.setOnClickListener { sendMessage() }
binding.etMessage.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { sendMessage(); true } else false
}
}
override fun onStart() {
super.onStart()
bindService(
Intent(this, ApiServerService::class.java),
serviceConnection,
Context.BIND_AUTO_CREATE
)
}
override fun onStop() {
super.onStop()
if (bound) { unbindService(serviceConnection); bound = false }
}
private fun sendMessage() {
if (generating) return
val text = binding.etMessage.text.toString().trim()
if (text.isEmpty()) return
binding.etMessage.text?.clear()
// Add user message
messages.add(ChatMessage("user", text))
adapter.notifyItemInserted(messages.size - 1)
scrollToBottom()
// Add empty AI placeholder
messages.add(ChatMessage("assistant", ""))
val aiIndex = messages.size - 1
adapter.notifyItemInserted(aiIndex)
scrollToBottom()
binding.tvTyping.visibility = View.VISIBLE
binding.btnSend.isEnabled = false
generating = true
val model = service?.currentModel
if (model == null || !model.isReady) {
messages[aiIndex].content = "⚠️ Server not running — start the server first."
adapter.notifyItemChanged(aiIndex)
finishGeneration()
return
}
val prompt = buildPrompt()
lifecycleScope.launch {
try {
model.generateStreaming(prompt) { token ->
runOnUiThread {
messages[aiIndex].content += token
adapter.notifyItemChanged(aiIndex)
scrollToBottom()
}
}
} catch (e: Exception) {
runOnUiThread {
messages[aiIndex].content = "⚠️ Error: ${e.message}"
adapter.notifyItemChanged(aiIndex)
}
} finally {
runOnUiThread { finishGeneration() }
}
}
}
private fun finishGeneration() {
generating = false
binding.tvTyping.visibility = View.GONE
binding.btnSend.isEnabled = true
scrollToBottom()
}
private fun buildPrompt(): String {
val prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
val useSystemPrompt = prefs.getBoolean("auto_system_prompt", true)
val sb = StringBuilder()
if (useSystemPrompt) {
sb.append("System: ${AgentConfig.SYSTEM_PROMPT}\n\n")
}
// Include all messages except the last empty AI placeholder
for (i in 0 until messages.size - 1) {
val msg = messages[i]
when (msg.role) {
"user" -> sb.append("User: ${msg.content}\n\n")
"assistant" -> sb.append("Assistant: ${msg.content}\n\n")
}
}
sb.append("Assistant:")
return sb.toString()
}
private fun scrollToBottom() {
if (messages.isNotEmpty()) {
binding.rvMessages.smoothScrollToPosition(messages.size - 1)
}
}
}
@@ -1,3 +0,0 @@
package com.pixel10.ai.ui
data class ChatMessage(val role: String, var content: String)
@@ -13,7 +13,6 @@ import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.view.View
import android.widget.SeekBar
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
@@ -54,16 +53,6 @@ class MainActivity : AppCompatActivity() {
service?.onLog = { message ->
runOnUiThread { appendLog(message) }
}
service?.onActiveRequest = { active ->
runOnUiThread {
if (active) {
binding.tvActiveRequest.text = "⚡ Processing request…"
binding.tvActiveRequest.visibility = View.VISIBLE
} else {
binding.tvActiveRequest.visibility = View.GONE
}
}
}
if (service?.isRunning == true) {
updateStatus(ApiServerService.ServerState.RUNNING)
@@ -84,30 +73,13 @@ class MainActivity : AppCompatActivity() {
prefs = getSharedPreferences("pixel10_prefs", MODE_PRIVATE)
requestNotificationPermission()
// Restore saved settings
// Restore saved HF token
binding.etHfToken.setText(prefs.getString("hf_token", ""))
val savedTemp = (prefs.getFloat("temperature", 0.7f) * 100).toInt()
binding.seekTemperature.progress = savedTemp
binding.tvTemperatureValue.text = "%.1f".format(savedTemp / 100f)
binding.etMaxTokens.setText(prefs.getInt("max_tokens", 1024).toString())
binding.switchSystemPrompt.isChecked = prefs.getBoolean("auto_system_prompt", true)
binding.seekTemperature.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
binding.tvTemperatureValue.text = "%.1f".format(progress / 100f)
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
})
binding.btnToggle.setOnClickListener {
if (service?.isRunning == true) stopServer() else startServer()
}
binding.btnChat.setOnClickListener {
startActivity(Intent(this, ChatActivity::class.java))
}
binding.btnDownloadModel.setOnClickListener {
saveHfToken()
startModelDownload(ModelSpec.GEMMA_3N_E4B)
@@ -229,16 +201,7 @@ class MainActivity : AppCompatActivity() {
}
}
private fun saveSettings() {
prefs.edit()
.putFloat("temperature", binding.seekTemperature.progress / 100f)
.putInt("max_tokens", binding.etMaxTokens.text.toString().toIntOrNull() ?: 1024)
.putBoolean("auto_system_prompt", binding.switchSystemPrompt.isChecked)
.apply()
}
private fun startServer() {
saveSettings()
val port = binding.etPort.text.toString().toIntOrNull() ?: 8080
val intent = Intent(this, ApiServerService::class.java).apply {
action = ApiServerService.ACTION_START
@@ -264,9 +227,6 @@ class MainActivity : AppCompatActivity() {
val canEdit = state == ApiServerService.ServerState.STOPPED ||
state == ApiServerService.ServerState.ERROR
binding.etPort.isEnabled = canEdit
binding.seekTemperature.isEnabled = canEdit
binding.etMaxTokens.isEnabled = canEdit
binding.switchSystemPrompt.isEnabled = canEdit
when (state) {
ApiServerService.ServerState.STOPPED -> {
@@ -276,7 +236,6 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = "Model: not loaded"
binding.btnToggle.text = getString(R.string.btn_start)
binding.btnToggle.isEnabled = true
binding.btnChat.isEnabled = false
}
ApiServerService.ServerState.LOADING_MODEL -> {
binding.tvServerStatus.text = getString(R.string.server_status_starting)
@@ -293,7 +252,6 @@ class MainActivity : AppCompatActivity() {
binding.tvModelStatus.text = getString(R.string.model_ready)
binding.btnToggle.text = getString(R.string.btn_stop)
binding.btnToggle.isEnabled = true
binding.btnChat.isEnabled = true
}
ApiServerService.ServerState.ERROR -> {
binding.tvServerStatus.text = getString(R.string.server_status_error)
@@ -1,43 +0,0 @@
package com.pixel10.ai.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView
import com.pixel10.ai.R
class MessageAdapter(private val messages: List<ChatMessage>) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
companion object {
private const val TYPE_USER = 0
private const val TYPE_AI = 1
}
override fun getItemViewType(position: Int) =
if (messages[position].role == "user") TYPE_USER else TYPE_AI
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_USER) {
val view = inflater.inflate(R.layout.item_message_user, parent, false)
UserViewHolder(view.findViewById(R.id.tvContent))
} else {
val view = inflater.inflate(R.layout.item_message_ai, parent, false)
AiViewHolder(view.findViewById(R.id.tvContent))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val msg = messages[position]
when (holder) {
is UserViewHolder -> holder.tv.text = msg.content
is AiViewHolder -> holder.tv.text = msg.content.ifEmpty { "" }
}
}
override fun getItemCount() = messages.size
class UserViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv.parent as android.view.View)
class AiViewHolder(val tv: TextView) : RecyclerView.ViewHolder(tv.parent as android.view.View)
}
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/surface_variant" />
<corners
android:topLeftRadius="4dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="16dp" />
</shape>
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/primary" />
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"
android:bottomLeftRadius="16dp"
android:bottomRightRadius="4dp" />
</shape>
-11
View File
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="@color/on_surface">
<path
android:fillColor="@color/on_surface"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" />
</vector>
-76
View File
@@ -1,76 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/surface">
<!-- Toolbar -->
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/surface_variant"
android:paddingStart="4dp"
app:title="Chat"
app:titleTextColor="@color/on_surface"
app:navigationIcon="@drawable/ic_back" />
<!-- Message list -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:padding="12dp"
android:clipToPadding="false" />
<!-- Typing indicator -->
<TextView
android:id="@+id/tvTyping"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="4dp"
android:text="⚡ Generating…"
android:textColor="@color/primary"
android:textSize="12sp"
android:visibility="gone" />
<!-- Input row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:padding="8dp"
android:background="@color/surface_variant">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMessage"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="Message…"
android:textColor="@color/on_surface"
android:textColorHint="@color/log_text"
android:textSize="15sp"
android:maxLines="4"
android:inputType="textMultiLine|textCapSentences"
android:backgroundTint="@color/primary" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSend"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="Send"
android:textSize="14sp"
app:cornerRadius="8dp" />
</LinearLayout>
</LinearLayout>
+32 -168
View File
@@ -102,16 +102,6 @@
android:textColor="@color/log_text"
android:textSize="13sp"
android:layout_marginTop="2dp" />
<TextView
android:id="@+id/tvActiveRequest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/primary"
android:textSize="13sp"
android:textStyle="bold"
android:layout_marginTop="2dp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -186,176 +176,51 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Settings Card -->
<com.google.android.material.card.MaterialCardView
<!-- Port Config -->
<LinearLayout
android:id="@+id/layoutPort"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:cardBackgroundColor="@color/surface_variant"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="0dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"
app:layout_constraintTop_toBottomOf="@id/cardModel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<LinearLayout
android:layout_width="match_parent"
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:text="@string/port_label"
android:textColor="@color/on_surface"
android:textSize="16sp" />
<!-- Port row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="100dp"
android:layout_height="48dp"
android:layout_marginStart="12dp"
android:text="@string/port_default"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="16sp"
android:gravity="center" />
</LinearLayout>
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/port_label"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etPort"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="@string/port_default"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp"
android:gravity="center" />
</LinearLayout>
<!-- Temperature row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_temperature"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<TextView
android:id="@+id/tvTemperatureValue"
android:layout_width="36dp"
android:layout_height="wrap_content"
android:text="0.7"
android:textColor="@color/log_text"
android:textSize="13sp"
android:fontFamily="monospace"
android:gravity="end" />
<SeekBar
android:id="@+id/seekTemperature"
android:layout_width="120dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:max="100"
android:progress="70" />
</LinearLayout>
<!-- Max tokens row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_max_tokens"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etMaxTokens"
android:layout_width="80dp"
android:layout_height="40dp"
android:text="1024"
android:inputType="number"
android:textColor="@color/on_surface"
android:backgroundTint="@color/primary"
android:fontFamily="monospace"
android:textSize="14sp"
android:gravity="center" />
</LinearLayout>
<!-- System prompt toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/setting_auto_system_prompt"
android:textColor="@color/on_surface"
android:textSize="14sp" />
<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/switchSystemPrompt"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Start/Stop + Chat Buttons -->
<LinearLayout
android:id="@+id/layoutButtons"
<!-- Start/Stop Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_height="56dp"
android:layout_marginTop="16dp"
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp"
app:layout_constraintTop_toBottomOf="@id/layoutPort"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnToggle"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:text="@string/btn_start"
android:textSize="16sp"
app:cornerRadius="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChat"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="8dp"
android:text="Chat"
android:textSize="16sp"
android:enabled="false"
app:cornerRadius="12dp" />
</LinearLayout>
app:layout_constraintEnd_toEndOf="parent" />
<!-- Log Output -->
<TextView
@@ -367,7 +232,7 @@
android:textSize="14sp"
android:textStyle="bold"
android:layout_marginTop="20dp"
app:layout_constraintTop_toBottomOf="@id/layoutButtons"
app:layout_constraintTop_toBottomOf="@id/btnToggle"
app:layout_constraintStart_toStartOf="parent" />
<ScrollView
@@ -378,7 +243,6 @@
android:background="@color/log_bg"
android:padding="12dp"
app:layout_constraintTop_toBottomOf="@id/tvLogLabel"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="start"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:background="@drawable/bubble_ai"
android:padding="10dp"
android:textColor="@color/on_surface"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2"
android:fontFamily="monospace" />
</LinearLayout>
@@ -1,22 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:paddingTop="4dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/tvContent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="280dp"
android:background="@drawable/bubble_user"
android:padding="10dp"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:lineSpacingMultiplier="1.2" />
</LinearLayout>
+1 -4
View File
@@ -25,10 +25,7 @@
<string name="model_not_downloaded">No local model. Enter HuggingFace token and download.</string>
<string name="btn_start">Start Server</string>
<string name="btn_stop">Stop Server</string>
<string name="port_label">Port</string>
<string name="setting_temperature">Temperature</string>
<string name="setting_max_tokens">Max tokens</string>
<string name="setting_auto_system_prompt">Agent system prompt</string>
<string name="port_label">Port:</string>
<string name="port_default">8080</string>
<string name="requests_served">Requests served: %d</string>
<string name="request_log_label">Request Log</string>